Compare commits

...
96 Commits
Author SHA1 Message Date
Vitor Pamplona f224fe6b40 v0.32.2 2023-04-06 12:23:35 -04:00
Vitor Pamplona 403a1723de Previews Notes that are not a citation using #[n] 2023-04-06 11:59:21 -04:00
Vitor Pamplona 6cf1960df4 Adds a New Message Tagger to Chats (tags Notes and Users by default) and Private Message (doesn't tag Users and Notes) 2023-04-06 11:58:49 -04:00
Vitor Pamplona 8676752f19 Fixes Channel's reply to filtration (removes citations and the channel id from replies) 2023-04-06 11:57:28 -04:00
Vitor Pamplona 7012d949c2 Fixes formatting 2023-04-06 11:56:23 -04:00
Vitor Pamplona 8344274011 Adds Invoice Creation to New Posts. 2023-04-06 11:54:28 -04:00
Vitor Pamplona b0953310c2 Making the poll click animation wider. 2023-04-05 11:10:14 -04:00
Vitor Pamplona 65910295db Adds notification for Zaps that don't have a Note attached to them. 2023-04-05 08:46:25 -04:00
Vitor Pamplona 7a957bd6ec Initializing indexes with large sizes from the start. 2023-04-05 08:45:58 -04:00
Vitor Pamplona 292ce779a1 Lazy initialization of the json mapper (300ms) 2023-04-05 08:45:33 -04:00
Vitor Pamplona 924b21cdfc Reducing the use of Slow disk access of Local Preferences. 2023-04-05 08:44:48 -04:00
Vitor PamplonaandGitHub 7b539e63bd Merge pull request #334 from Poussinou/patch-1
Update README.md
2023-04-05 08:21:06 -04:00
PoussinouandGitHub d37fef94fe Update README.md 2023-04-05 21:24:47 +09:30
Vitor PamplonaandGitHub 93a1853317 Merge pull request #332 from braydonf/readme
Update readme gradlew install commands
2023-04-04 19:48:16 -04:00
Braydon 9944f98bde Update readme gradlew install commands 2023-04-04 16:24:44 -07:00
Vitor Pamplona 05138232f4 Fixes missing function 2023-04-04 16:12:20 -04:00
Vitor Pamplona 83d7085375 Hides Poll option when replying to channel or DMs. 2023-04-04 16:10:55 -04:00
Vitor Pamplona a8a3915c8d Allowing replies of PMs from Notifications. 2023-04-04 16:09:54 -04:00
Vitor Pamplona 83bcf53583 v0.32.1 2023-04-04 14:11:05 -04:00
Vitor Pamplona 875d9544ec Fixes Poll Color for Light mode. 2023-04-04 13:59:02 -04:00
Vitor Pamplona 7ce2a0bbea Makes sure before and after voting the padding is the same. 2023-04-04 13:57:06 -04:00
Vitor Pamplona a3b9ea6a42 Fixes format 2023-04-04 13:51:27 -04:00
Vitor Pamplona ca2c811859 Fixes Voted option highlight. 2023-04-04 13:49:41 -04:00
Vitor Pamplona b09bb0c3c5 Fixes margins when images are used in the poll 2023-04-04 13:39:12 -04:00
Vitor Pamplona a68ea1abe5 Adds margin between the reply note and the text 2023-04-04 12:35:42 -04:00
Vitor Pamplona 02a9aaab60 Removes the 5dp space on the generic RichText Viewer 2023-04-04 12:29:25 -04:00
Vitor Pamplona efd2ddba71 Adds payment animation to Polls 2023-04-04 12:25:32 -04:00
Vitor Pamplona e10c5771d8 v0.32.0 2023-04-04 11:27:23 -04:00
Vitor Pamplona b685b38565 Avoids triggering exceptions when the NIP19 parser receives a simple hex key 2023-04-04 11:21:36 -04:00
Vitor Pamplona 15822fee8e Fixes Translation bug not being active on google play edition 2023-04-04 11:20:57 -04:00
Vitor Pamplona e96e218025 Adds relay param in spam filter for polls 2023-04-03 20:46:22 -04:00
Vitor Pamplona a11263ce9e Correcting format 2023-04-03 20:42:05 -04:00
Vitor PamplonaandGitHub 4417201582 Merge pull request #330 from vitorpamplona/pr/321
Moving new pool button to NewPost
2023-04-03 20:35:41 -04:00
Vitor Pamplona 6a85ab743d Moving new pool button to NewPost 2023-04-03 20:31:25 -04:00
Vitor PamplonaandGitHub 4434b0e0e5 Merge pull request #321 from toadlyBroodle/main
Polls
2023-04-03 20:26:57 -04:00
Vitor PamplonaandGitHub 7c8381624b Merge branch 'main' into main 2023-04-03 20:26:50 -04:00
Vitor Pamplona e470531210 Adds Show QR option to Profile screen 2023-04-03 15:04:52 -04:00
Vitor Pamplona 182cf62a3b v0.31.4 2023-04-03 14:02:19 -04:00
Vitor Pamplona 3e666df896 Adds full nip19 parsing on key decoding capabilities. 2023-04-03 14:01:43 -04:00
Vitor Pamplona 20aca144fa Moves Login QR Button to a leading QR, not a trailing QR 2023-04-03 14:01:00 -04:00
Vitor PamplonaandGitHub 25d9f7b529 Merge pull request #327 from believethehype/main
added message to LnZapEvent and interface
2023-04-03 13:07:48 -04:00
Vitor PamplonaandGitHub 306a7b3041 Merge pull request #329 from greenart7c3/main
Add qrCode scanner to the login page
2023-04-03 13:07:08 -04:00
greenart7c3andGitHub f2311f294e Merge branch 'vitorpamplona:main' into main 2023-04-03 13:54:48 -03:00
greenart7c3 b51f551b4b fix formatting 2023-04-03 13:49:08 -03:00
Vitor Pamplona af751d56ce Hunting a bug of missing zapRequests 2023-04-03 12:03:00 -04:00
Vitor Pamplona f4d66d9cbf Speeding up RichText rendering parsers 2023-04-03 12:02:26 -04:00
Vitor Pamplona 37d839504c Avoiding crashes when zap description is blank 2023-04-03 11:53:02 -04:00
Believethehype e8e696dcea added message to LnZapEvent and interface 2023-04-02 23:25:37 +02:00
Vitor Pamplona 4db0faeda4 Printing time on EOSETime class for logs 2023-04-02 11:54:40 -04:00
Vitor Pamplona b8ca1a15fb Adds event kind to the NoteCompose log 2023-04-02 11:43:15 -04:00
Vitor Pamplona 407ee74a55 Faster processing of Contact List events 2023-04-02 11:42:38 -04:00
Vitor Pamplona 67bcc52b25 Faster user in follow list algo 2023-04-02 11:42:11 -04:00
Vitor Pamplona 9afc94c050 Display relay info in spam message logs 2023-04-02 11:32:38 -04:00
toadlyBroodle 9d9283e05f clean up imports,
fix translatable typo
2023-04-02 13:58:07 +09:00
toadlyBroodle 5692625922 show amount for atomic polls before zapping 2023-04-02 13:46:24 +09:00
toadlyBroodle 7e07573fa5 fix voting on closed polls bug 2023-04-01 10:23:45 +09:00
toadlyBroodle 1f08f33600 merge with recent changes to main 2023-04-01 09:54:13 +09:00
toadlyBroodle 24dc4ba7a5 remove hacky fun for getting poll_option from ZapEvent 2023-03-31 13:33:42 +09:00
toadlyBroodle a01a28de0e merge with recent changes to main 2023-03-31 10:50:37 +09:00
toadlyBroodle 32067fe800 merge branch polls into main 2023-03-29 16:00:15 +09:00
toadlyBroodle 573cfa8b75 add expandable FAB compose menu,
increase bolt size of poll icon
2023-03-28 14:09:07 +09:00
toadlyBroodle b65139f520 only allow one vote per option on atomic (main==max) polls
fix buggy click event on disabled zap button
2023-03-26 14:34:32 +09:00
toadlyBroodle 62ff9ac94b color option progress bar green when consensus achieved,
add padding to option text
2023-03-26 13:52:27 +09:00
toadlyBroodle 584c2860e4 move option tally logic to PollNoteViewModel 2023-03-26 09:51:00 +09:00
toadlyBroodle 12a1c3fe6d add option tally percentage bars,
create new PollNoteViewModel for each PollNote,
fix isVoteAmountAtomic to directly launch wallet
2023-03-25 15:46:54 +09:00
toadlyBroodle 5b1e7c3451 don't pass in context to voteAmountPlaceHolderText() 2023-03-25 09:18:13 +09:00
toadlyBroodle 9b9cc092dc move PollNote logic to PollNoteViewModel,
fix PollNoteEvent tag getter funs for the final fucking time (hopefully)!,
only show option amounts if user has zapped poll note,
make poll options wider
2023-03-25 08:59:11 +09:00
toadlyBroodle 712c8ab2dd fix isPollOptionZappedBy fun,
option zap widgets layout improvements
2023-03-24 20:26:58 +09:00
toadlyBroodle dbf0256b1c add zap vote amount dialog,
show vote option tallies,
format zapped vote button icons
2023-03-24 18:10:02 +09:00
toadlyBroodle 171a7841b3 add zap buttons to PollNote options,
add poll_option tag and handlers to LnZapEvents and zap funs,
2023-03-23 19:11:07 +09:00
toadlyBroodle 611dcc0189 implement new NIP69 poll_option tags format List<List<String>>,
fix PollNoteEvent field getter return types
2023-03-23 16:18:16 +09:00
toadlyBroodle ef31f56eab add PollNote type to ThreadFeedView,
simplify PollNote() arguments,
use parent's backgroundColor,
breakout PollNote modifier
2023-03-21 20:52:30 +09:00
toadlyBroodle 771cdd6ebe add PollNote to ui.note,
simplify code for displaying PollNoteEvents with replyTos or mentions,
fix pollOptions (and other poll fields) get from tags functions
2023-03-21 20:05:53 +09:00
toadlyBroodle d1f61c0bba add PollNoteEvent type to all relevant filters, dataSources, etc. 2023-03-20 17:38:14 +09:00
toadlyBroodle 5d04f3ea99 enable send poll event,
add PollNoteEvent.kind to DataSources,
add consume(PollNoteEvent) to LocalCache,
2023-03-20 15:36:12 +09:00
toadlyBroodle bd37e4a9df fix add new poll option button onclick,
prefix all poll composables with 'New',
move all poll composables to actions folder,
move jsonToPollOptions parser to PollNoteEvent
2023-03-20 10:20:39 +09:00
toadlyBroodle 3cdfdfa8e8 rewrite pollOptions json parser 2023-03-20 09:52:23 +09:00
toadlyBroodle 8171c1ee5e change pollOptions to type Map<Int, String>,
remove always true condition from Account.isAcceptable()
2023-03-19 22:15:00 +09:00
toadlyBroodle 0366f6235c move valid input flags to pollViewModel,
disable sendPoll button unless all fields valid
2023-03-19 14:08:37 +09:00
toadlyBroodle 7890ac9db5 create pollSend funs, 2023-03-19 12:43:15 +09:00
toadlyBroodle 7a53708ccc add fields to pollNoteEvent,
add clearState fun to pollViewModel,
add refined PollNoteEvent to Event,
2023-03-18 15:38:58 +09:00
toadlyBroodle 551ed64e98 add PollNoteEvent type 2023-03-17 16:57:17 +09:00
toadlyBroodle f0b4660719 validate user input for all poll fields,
add user's pubkey to recipients field,
add poll field saveable texts to pollViewModel,
2023-03-17 16:48:26 +09:00
toadlyBroodle 29c4c42547 delete pollOption values on cancel(),
move recipients field to optional section,
make recipients, options textFields fillMaxWidth
2023-03-16 19:25:54 +09:00
toadlyBroodle 107e09aefb move pollOptions list to pollViewModel,
add/delete poll options functionality,
don't allow deleting first 2 poll options
2023-03-16 19:06:00 +09:00
toadlyBroodle 63ad7fd205 add PollClosing component,
rename poll strings/values
2023-03-13 17:33:00 +09:00
toadlyBroodle c31b99b1dc add consensus threshold component/preview,
change value range placeholder strings
2023-03-13 16:32:38 +09:00
toadlyBroodle 851ec71f4a add PollVoteValueRange component,
add PollOption, PollVoteValueRange component previews,
rename poll_recipients string
2023-03-13 15:46:48 +09:00
toadlyBroodle e30b4f7c7e add PollRecipientsField,
add buttons for add/delete options,
format poll text fields,
change poll option placeholder string
rename primaryPollDescription string
2023-03-13 14:39:40 +09:00
toadlyBroodle 00f9f7ba52 add PollPrimaryDescription, PollOption objects 2023-03-13 13:15:40 +09:00
toadlyBroodle 564f926213 remove closePollButton 2023-03-11 18:57:02 +09:00
toadlyBroodle 0cf891bb4d pollViewModel -> override postViewModel funs,
remove imageUpload from pollView,
2023-03-11 18:16:47 +09:00
toadlyBroodle fba873dab9 add floating action buttons column 2023-03-11 16:57:09 +09:00
toadlyBroodle 322853f696 ignore .idea/deploymentTargetDropDown, remove duplicate ignore rules 2023-03-11 15:22:33 +09:00
toadlyBroodle 19c3421e92 ignore .idea/deploymentTargetDropDown 2023-03-11 15:20:25 +09:00
toadlyBroodle 206d6c68bb Add new poll button, view, viewModel, icons, string; replace fab with pollButton; supress missing translations errors when debugging 2023-03-11 15:13:02 +09:00
73 changed files with 3787 additions and 1640 deletions
+1
View File
@@ -6,6 +6,7 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
.DS_Store
/build
/captures
+18 -1
View File
@@ -4,6 +4,15 @@
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
@@ -50,6 +59,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Message Sent feedback (NIP-20)
- [ ] Polls (NIP-69)
# Development Overview
@@ -105,8 +115,15 @@ Build the app:
```
## Installing on device
For the F-Droid build:
```bash
./gradlew installDebug
./gradlew installFdroidDebug
```
For the Play build:
```bash
./gradlew installPlayDebug
```
## How to Deploy
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 113
versionName "0.31.3"
versionCode 117
versionName "0.32.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -1,26 +1,26 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) = ExpandableRichTextViewer(
content,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) = ExpandableRichTextViewer(
content,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
@@ -27,10 +27,7 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val current: Boolean,
val displayName: String?,
val profilePicture: String?
val hasPrivKey: Boolean = false
)
private object PrefKeys {
@@ -38,8 +35,6 @@ private object PrefKeys {
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val DISPLAY_NAME = "display_name"
const val PROFILE_PICTURE_URL = "profile_picture"
const val FOLLOWING_CHANNELS = "following_channels"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
@@ -59,53 +54,73 @@ private val gson = GsonBuilder().create()
object LocalPreferences {
private const val comma = ","
private var currentAccount: String?
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
set(npub) {
val prefs = encryptedPreferences()
prefs.edit().apply {
private var _currentAccount: String? = null
private fun currentAccount(): String? {
if (_currentAccount == null) {
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
}
return _currentAccount
}
private fun updateCurrentAccount(npub: String) {
if (_currentAccount != npub) {
_currentAccount = npub
encryptedPreferences().edit().apply {
putString(PrefKeys.CURRENT_ACCOUNT, npub)
}.apply()
}
}
private val savedAccounts: List<String>
get() = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
private var _savedAccounts: List<String>? = null
private fun savedAccounts(): List<String> {
if (_savedAccounts == null) {
_savedAccounts = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
}
return _savedAccounts!!
}
private fun updateSavedAccounts(accounts: List<String>) {
if (_savedAccounts != accounts) {
_savedAccounts = accounts
encryptedPreferences().edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
}
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
private fun addAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
val accounts = savedAccounts().toMutableList()
if (npub !in accounts) {
accounts.add(npub)
updateSavedAccounts(accounts)
}
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
private fun setCurrentAccount(account: Account) {
val npub = account.userProfile().pubkeyNpub()
currentAccount = npub
updateCurrentAccount(npub)
addAccount(npub)
}
fun switchToAccount(npub: String) {
currentAccount = npub
updateCurrentAccount(npub)
}
/**
* Removes the account from the app level shared preferences
*/
private fun removeAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
val accounts = savedAccounts().toMutableList()
if (accounts.remove(npub)) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
updateSavedAccounts(accounts)
}
}
@@ -145,11 +160,11 @@ object LocalPreferences {
removeAccount(npub)
deleteUserPreferenceFile(npub)
if (savedAccounts.isEmpty()) {
if (savedAccounts().isEmpty()) {
val appPrefs = encryptedPreferences()
appPrefs.edit().clear().apply()
} else if (currentAccount == npub) {
currentAccount = savedAccounts.elementAt(0)
} else if (currentAccount() == npub) {
updateCurrentAccount(savedAccounts().elementAt(0))
}
}
@@ -159,17 +174,8 @@ object LocalPreferences {
}
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts.map { npub ->
val prefs = encryptedPreferences(npub)
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
AccountInfo(
npub = npub,
hasPrivKey = hasPrivKey,
current = npub == currentAccount,
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
)
return savedAccounts().map { npub ->
AccountInfo(npub = npub)
}
}
@@ -189,13 +195,11 @@ object LocalPreferences {
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences(currentAccount).apply {
encryptedPreferences(currentAccount()).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
@@ -247,7 +251,7 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
return Account(
val a = Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
@@ -261,23 +265,25 @@ object LocalPreferences {
hideBlockAlertDialog,
latestContactList
)
return a
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences(currentAccount).edit().apply {
encryptedPreferences(currentAccount()).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences(currentAccount).run {
encryptedPreferences(currentAccount()).run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
fun migrateSingleUserPrefs() {
if (currentAccount != null) return
if (currentAccount() != null) return
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
val npub = Hex.decode(pubkey).toNpub()
@@ -314,6 +320,6 @@ object LocalPreferences {
encryptedPreferences().edit().clear().apply()
addAccount(npub)
currentAccount = npub
updateCurrentAccount(npub)
}
}
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.Dispatchers
@@ -14,7 +15,7 @@ class AntiSpamFilter {
val spamMessages = LruCache<Int, Spammer>(1000)
@Synchronized
fun isSpam(event: Event): Boolean {
fun isSpam(event: Event, relay: Relay?): Boolean {
val idHex = event.id
// if short message, ok
@@ -27,7 +28,7 @@ class AntiSpamFilter {
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
// Log down offenders
if (spamMessages.get(hash) == null) {
@@ -38,14 +38,14 @@ fun HexKey.toDisplayHexKey(): String {
}
fun decodePublicKey(key: String): ByteArray {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray()
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
} else if (key.startsWith("npub")) {
key.bechToBytes()
} else if (key.startsWith("note")) {
key.bechToBytes()
} else { // if (pattern.matcher(key).matches()) {
// } else {
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key)
}
}
@@ -4,28 +4,7 @@ import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import fr.acinq.secp256k1.Hex
@@ -38,16 +17,18 @@ import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
object LocalCache {
val metadataParser = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val users = ConcurrentHashMap<HexKey, User>(5000)
val notes = ConcurrentHashMap<HexKey, Note>(5000)
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -187,7 +168,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event)) {
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
@@ -223,7 +204,7 @@ object LocalCache {
// Already processed this event.
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event)) {
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
@@ -241,6 +222,42 @@ object LocalCache {
}
}
fun consume(event: PollNoteEvent, relay: Relay? = null) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.addNote(note)
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers()
}
fun consume(event: BadgeDefinitionEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -302,12 +319,10 @@ object LocalCache {
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
val follows = event.unverifiedFollowKeySet()
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) {
// Saves relay list only if it's a user that is currently been seen
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
}
@@ -543,16 +558,16 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event)) {
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.replyTos()
val replyTo = event.tagsWithoutCitations()
.filter { it != event.channel() }
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.loadEvent(event, author, replyTo)
@@ -587,8 +602,7 @@ object LocalCache {
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()
?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
)
note.loadEvent(event, author, repliesTo)
@@ -642,6 +656,7 @@ object LocalCache {
fun findNotesStartingWith(text: String): List<Note> {
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) ||
it.idHex.startsWith(text, true) ||
it.idNote().startsWith(text, true)
@@ -155,6 +155,7 @@ open class Note(val idHex: String) {
}
}
@Synchronized
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
@@ -273,7 +273,7 @@ class User(val pubkeyHex: String) {
}
fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
@@ -289,7 +289,7 @@ class User(val pubkeyHex: String) {
}
fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
@@ -387,6 +387,10 @@ class UserMetadata {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }
}
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
@@ -82,6 +83,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
ReportEvent.kind,
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
@@ -90,6 +91,7 @@ abstract class NostrDataSource(val debugName: String) {
LocalCache.consume(event)
}
is TextNoteEvent -> LocalCache.consume(event, relay)
is PollNoteEvent -> LocalCache.consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -11,7 +12,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -54,7 +55,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.relayList
@@ -1,12 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -65,7 +60,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
search = mySearchString,
limit = 20
)
@@ -2,19 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -43,7 +31,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
TextNoteEvent.kind, LongTextNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
@@ -94,7 +83,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
RepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind
LnZapRequestEvent.kind,
PollNoteEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -127,7 +117,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind
),
ids = interestedEvents.toList()
)
@@ -2,15 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -43,7 +35,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
@@ -13,7 +13,7 @@ open class BaseTextNoteEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
@@ -12,11 +12,10 @@ class ChannelMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 42
@@ -227,6 +227,7 @@ open class Event(
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
@@ -1,62 +1,72 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
class LnZapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
override fun zappedPost() = tags
.filter { it.firstOrNull() == "e" }
.mapNotNull { it.getOrNull(1) }
override fun zappedAuthor() = tags
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun amount(): BigDecimal? {
return amount
}
// Keeps this as a field because it's a heavier function used everywhere.
val amount by lazy {
try {
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
null
}
}
override fun containedPost(): Event? = try {
description()?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
}
private fun lnInvoice(): String? = tags
.filter { it.firstOrNull() == "bolt11" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun description(): String? = tags
.filter { it.firstOrNull() == "description" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
companion object {
const val kind = 9735
}
}
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
class LnZapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
override fun zappedPost() = tags
.filter { it.firstOrNull() == "e" }
.mapNotNull { it.getOrNull(1) }
override fun zappedPollOption(): Int? = containedPost()?.tags
?.filter { it.firstOrNull() == POLL_OPTION }
?.getOrNull(0)?.getOrNull(1)?.toInt()
override fun zappedAuthor() = tags
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun zappedRequestAuthor(): String? = containedPost()?.pubKey()
override fun amount(): BigDecimal? {
return amount
}
// Keeps this as a field because it's a heavier function used everywhere.
val amount by lazy {
try {
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
null
}
}
override fun message(): String {
return message
}
val message = content
override fun containedPost(): Event? = try {
description()?.ifBlank { null }?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
}
private fun lnInvoice(): String? = tags
.filter { it.firstOrNull() == "bolt11" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun description(): String? = tags
.filter { it.firstOrNull() == "description" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
companion object {
const val kind = 9735
}
}
@@ -6,11 +6,17 @@ interface LnZapEventInterface : EventInterface {
fun zappedPost(): List<String>
fun zappedPollOption(): Int?
fun zappedAuthor(): List<String>
fun zappedRequestAuthor(): String?
fun taggedAddresses(): List<ATag>
fun amount(): BigDecimal?
fun containedPost(): Event?
fun message(): String
}
@@ -1,97 +1,106 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class LnZapRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 9734
fun create(
originalNote: EventInterface,
relays: Set<String>,
privateKey: ByteArray,
message: String,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(
userHex: String,
relays: Set<String>,
privateKey: ByteArray,
message: String,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"content": "",
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
"created_at": 1674164539,
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
"kind": 9734,
"tags": [
[
"e",
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
],
[
"p",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
],
[
"relays",
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
"wss://nostr.bitcoiner.social",
"ws://monad.jb55.com:8080",
"wss://relay.snort.social"
]
]
}
*/
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class LnZapRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 9734
fun create(
originalNote: EventInterface,
relays: Set<String>,
privateKey: ByteArray,
pollOption: Int?,
message: String,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
if (pollOption != null && pollOption >= 0) {
tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(
userHex: String,
relays: Set<String>,
privateKey: ByteArray,
message: String,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"content": "",
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
"created_at": 1674164539,
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
"kind": 9734,
"tags": [
[
"e",
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
],
[
"p",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
],
[
"relays",
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
"wss://nostr.bitcoiner.social",
"ws://monad.jb55.com:8080",
"wss://relay.snort.social"
],
[
"poll_option", "n"
]
],
"ots": <base64-encoded OTS file data> // TODO
}
*/
@@ -0,0 +1,106 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
const val POLL_OPTION = "poll_option"
const val VALUE_MAXIMUM = "value_maximum"
const val VALUE_MINIMUM = "value_minimum"
const val CONSENSUS_THRESHOLD = "consensus_threshold"
const val CLOSED_AT = "closed_at"
class PollNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
// ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun pollOptions(): Map<Int, String> {
val map = mutableMapOf<Int, String>()
tags.filter { it.first() == POLL_OPTION }
.forEach { map[it[1].toInt()] = it[2] }
return map
}
fun getTagInt(property: String): Int? {
val tagList = tags.filter {
it.firstOrNull() == property
}
val tag = tagList.getOrNull(0)
val s = tag?.getOrNull(1)
return if (s.isNullOrBlank() || s == "null") {
null
} else {
s.toInt()
}
}
companion object {
const val kind = 6969
fun create(
msg: String,
replyTos: List<String>?,
mentions: List<String>?,
addresses: List<ATag>?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
pollOptions: Map<Int, String>,
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
pollOptions.forEach { poll_op ->
tags.add(listOf(POLL_OPTION, poll_op.key.toString(), poll_op.value))
}
tags.add(listOf(VALUE_MAXIMUM, valueMaximum.toString()))
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
tags.add(listOf(CLOSED_AT, closedAt.toString()))
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
/*
{
"id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
"created_at": <unix timestamp in seconds>,
"kind": 6969,
"tags": [
["e", <32-bytes hex of the id of the poll event>, <primary poll host relay URL>],
["p", <32-bytes hex of the key>, <primary poll host relay URL>],
["poll_option", "0", "poll option 0 description string"],
["poll_option", "1", "poll option 1 description string"],
["poll_option", "n", "poll option <n> description string"],
["value_maximum", "maximum satoshi value for inclusion in tally"],
["value_minimum", "minimum satoshi value for inclusion in tally"],
["consensus_threshold", "required percentage to attain consensus <0..100>"],
["closed_at", "unix timestamp in seconds"],
],
"ots": <base64-encoded OTS file data>
"content": <primary poll description string>,
"sig": <64-bytes hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field>
}
*/
@@ -19,7 +19,10 @@ object Nip19 {
try {
val matcher = nip19regex.matcher(uri)
matcher.find()
if (!matcher.find()) {
return null
}
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
@@ -2,7 +2,11 @@ package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.User
class EOSETime(var time: Long)
class EOSETime(var time: Long) {
override fun toString(): String {
return time.toString()
}
}
class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
fun addOrUpdate(relayUrl: String, time: Long) {
@@ -0,0 +1,81 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
import com.vitorpamplona.amethyst.service.nip19.Nip19
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
open fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
open fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
open fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
open fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun run() {
// adds all references to mentions and reply tos
message.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
addNoteToReplyTos(note)
}
}
}
}
// Tags the text in the correct order.
message = message.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
"#[${tagIndex(user)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
}
}
@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
@Composable
fun NewPollClosing(pollViewModel: NewPostViewModel) {
var text by rememberSaveable { mutableStateOf("") }
pollViewModel.isValidClosedAt.value = true
if (text.isNotEmpty()) {
try {
val int = text.toInt()
if (int < 0) {
pollViewModel.isValidClosedAt.value = false
} else { pollViewModel.closedAt = int }
} catch (e: Exception) { pollViewModel.isValidClosedAt.value = false }
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_closing_time),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_closing_time_days),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}
}
@Preview
@Composable
fun NewPollClosingPreview() {
NewPollClosing(NewPostViewModel())
}
@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
@Composable
fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
var text by rememberSaveable { mutableStateOf("") }
pollViewModel.isValidConsensusThreshold.value = true
if (text.isNotEmpty()) {
try {
val int = text.toInt()
if (int < 0 || int > 100) {
pollViewModel.isValidConsensusThreshold.value = false
} else { pollViewModel.consensusThreshold = int }
} catch (e: Exception) { pollViewModel.isValidConsensusThreshold.value = false }
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_consensus_threshold),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_consensus_threshold_percent),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}
}
@Preview
@Composable
fun NewPollConsensusThresholdPreview() {
NewPollConsensusThreshold(NewPostViewModel())
}
@@ -0,0 +1,70 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
@Composable
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = {
pollViewModel.pollOptions.remove(optionIndex)
}
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.clear)
)
}
}
OutlinedTextField(
modifier = Modifier.weight(1F),
value = pollViewModel.pollOptions[optionIndex] ?: "",
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_option_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
// colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else colorInValid,
trailingIcon = if (optionIndex > 1) deleteIcon else null
)
}
}
@Preview
@Composable
fun NewPollOptionPreview() {
NewPollOption(NewPostViewModel(), 0)
}
@@ -0,0 +1,77 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var isInputValid = true
if (pollViewModel.message.text.isEmpty()) {
isInputValid = false
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
OutlinedTextField(
value = pollViewModel.message,
onValueChange = {
pollViewModel.updateMessage(it)
},
label = {
Text(
text = stringResource(R.string.poll_primary_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.poll_primary_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
colors = if (isInputValid) colorValid else colorInValid,
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@@ -0,0 +1,43 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
@Composable
fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) {
// if no recipients, add user's pubkey
if (pollViewModel.zapRecipients.isEmpty()) {
pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex)
}
// TODO allow add multiple recipients and check input validity
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = pollViewModel.zapRecipients[0],
onValueChange = { /* TODO */ },
enabled = false, // TODO enable add recipients
label = {
Text(
text = stringResource(R.string.poll_zap_recipients),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_zap_recipients),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}
@@ -0,0 +1,197 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import kotlinx.coroutines.delay
@Composable
fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
val pollViewModel: NewPostViewModel = viewModel()
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
pollViewModel.load(account, baseReplyTo, quote)
delay(100)
pollViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.imePadding()
.weight(1f)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
pollViewModel.cancel()
onClose()
})
PollButton(
onPost = {
pollViewModel.sendPost()
onClose()
},
isActive = pollViewModel.message.text.isNotBlank() &&
pollViewModel.pollOptions.values.all { it.isNotEmpty() } &&
pollViewModel.isValidRecipients.value &&
pollViewModel.isValidvalueMaximum.value &&
pollViewModel.isValidvalueMinimum.value &&
pollViewModel.isValidConsensusThreshold.value &&
pollViewModel.isValidClosedAt.value
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
) {
if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "") {
pollViewModel.removeFromReplyList(it)
}
}
Text(stringResource(R.string.poll_heading_required))
// NewPollRecipientsField(pollViewModel, account)
NewPollPrimaryDescription(pollViewModel)
pollViewModel.pollOptions.values.forEachIndexed { index, element ->
NewPollOption(pollViewModel, index)
}
Button(
onClick = { pollViewModel.pollOptions[pollViewModel.pollOptions.size] = "" },
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Image(
painterResource(id = android.R.drawable.ic_input_add),
contentDescription = "Add poll option button",
modifier = Modifier.size(18.dp)
)
}
Text(stringResource(R.string.poll_heading_optional))
NewPollVoteValueRange(pollViewModel)
NewPollConsensusThreshold(pollViewModel)
NewPollClosing(pollViewModel)
}
}
val userSuggestions = pollViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { index, item ->
UserLine(item, account) {
pollViewModel.autocompleteWithUser(item)
}
}
}
}
Row(modifier = Modifier.fillMaxWidth()) {
/*UploadFromGallery(
isUploading = pollViewModel.isUploadingImage
) {
pollViewModel.upload(it, context)
}*/
}
}
}
}
}
}
@Composable
fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = stringResource(R.string.post_poll), color = Color.White)
}
}
/*@Preview
@Composable
fun NewPollViewPreview() {
NewPollView(onClose = {}, account = Account(loggedIn = Persona()))
}*/
@@ -0,0 +1,126 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@Composable
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
var textMax by rememberSaveable { mutableStateOf("") }
var textMin by rememberSaveable { mutableStateOf("") }
// check for zapMax amounts < 1
pollViewModel.isValidvalueMaximum.value = true
if (textMax.isNotEmpty()) {
try {
val int = textMax.toInt()
if (int < 1) {
pollViewModel.isValidvalueMaximum.value = false
} else { pollViewModel.valueMaximum = int }
} catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false }
}
// check for minZap amounts < 1
pollViewModel.isValidvalueMinimum.value = true
if (textMin.isNotEmpty()) {
try {
val int = textMin.toInt()
if (int < 1) {
pollViewModel.isValidvalueMinimum.value = false
} else { pollViewModel.valueMinimum = int }
} catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false }
}
// check for zapMin > zapMax
if (textMin.isNotEmpty() && textMax.isNotEmpty()) {
try {
val intMin = textMin.toInt()
val intMax = textMax.toInt()
if (intMin > intMax) {
pollViewModel.isValidvalueMinimum.value = false
pollViewModel.isValidvalueMaximum.value = false
}
} catch (e: Exception) {
pollViewModel.isValidvalueMinimum.value = false
pollViewModel.isValidvalueMaximum.value = false
}
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = textMin,
onValueChange = { textMin = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_zap_value_min),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
OutlinedTextField(
value = textMax,
onValueChange = { textMax = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_zap_value_max),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}
}
@Preview
@Composable
fun NewPollVoteValueRangePreview() {
NewPollVoteValueRange(NewPostViewModel())
}
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -10,6 +12,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MonetizationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -27,6 +31,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -104,8 +109,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.message.text.isNotBlank() &&
!postViewModel.isUploadingImage
isActive = postViewModel.canPost()
)
}
@@ -161,13 +165,55 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
if (postViewModel.wantsPoll) {
postViewModel.pollOptions.values.forEachIndexed { index, element ->
NewPollOption(postViewModel, index)
}
Button(
onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" },
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Image(
painterResource(id = android.R.drawable.ic_input_add),
contentDescription = "Add poll option button",
modifier = Modifier.size(18.dp)
)
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (lud16 != null && user != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(
lud16,
user.pubkeyHex,
account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onClose = {
postViewModel.wantsInvoice = false
}
)
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it, true) }) {
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
@@ -182,7 +228,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
RoundedCornerShape(15.dp)
)
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it, true) }) {
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(myUrlPreview)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
@@ -217,11 +263,23 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Row(modifier = Modifier.fillMaxWidth()) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.primary,
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 10.dp)
) {
postViewModel.upload(it, context)
}
if (postViewModel.canUsePoll()) {
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
}
}
if (postViewModel.canAddLnInvoice()) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
}
}
}
@@ -229,6 +287,62 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
@Composable
private fun AddPollButton(
isPollActive: Boolean,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
if (!isPollActive) {
Icon(
painter = painterResource(R.drawable.ic_poll),
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
painter = painterResource(R.drawable.ic_lists),
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
}
}
}
@Composable
private fun AddLnInvoiceButton(
isLnInvoiceActive: Boolean,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.MonetizationOn,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Default.MonetizationOn,
null,
modifier = Modifier.size(20.dp),
tint = Color.Green.copy(alpha = 0.52f)
)
}
}
}
@Composable
fun CloseButton(onCancel: () -> Unit) {
Button(
@@ -3,15 +3,18 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
@@ -19,9 +22,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
class NewPostViewModel : ViewModel() {
private var account: Account? = null
private var originalNote: Note? = null
open class NewPostViewModel : ViewModel() {
var account: Account? = null
var originalNote: Note? = null
var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<List<Note>?>(null)
@@ -34,7 +37,25 @@ class NewPostViewModel : ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
fun load(account: Account, replyingTo: Note?, quote: Note?) {
// Polls
var wantsPoll by mutableStateOf(false)
var zapRecipients = mutableStateListOf<HexKey>()
var pollOptions = newStateMapPollOptions()
var valueMaximum: Int? = null
var valueMinimum: Int? = null
var consensusThreshold: Int? = null
var closedAt: Int? = null
var isValidRecipients = mutableStateOf(true)
var isValidvalueMaximum = mutableStateOf(true)
var isValidvalueMinimum = mutableStateOf(true)
var isValidConsensusThreshold = mutableStateOf(true)
var isValidClosedAt = mutableStateOf(true)
// Invoices
var wantsInvoice by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
@@ -61,85 +82,21 @@ class NewPostViewModel : ViewModel() {
this.account = account
}
fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun sendPost() {
// adds all references to mentions and reply tos
message.text.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
tagger.run()
if (results?.key?.type == Nip19.Type.USER) {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
addNoteToReplyTos(note)
}
}
}
}
// Tags the text in the correct order.
val newMessage = message.text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
"#[${tagIndex(user)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
if (originalNote?.channel() != null) {
account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt)
} else if (originalNote?.channel() != null) {
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions)
} else {
account?.sendPost(newMessage, replyTos, mentions)
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions)
}
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
cancel()
}
fun upload(it: Uri, context: Context) {
@@ -166,14 +123,24 @@ class NewPostViewModel : ViewModel() {
)
}
fun cancel() {
open fun cancel() {
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
wantsPoll = false
zapRecipients = mutableStateListOf<HexKey>()
pollOptions = newStateMapPollOptions()
valueMaximum = null
valueMinimum = null
consensusThreshold = null
closedAt = null
wantsInvoice = false
}
fun findUrlInMessage(): String? {
open fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
@@ -181,11 +148,11 @@ class NewPostViewModel : ViewModel() {
}
}
fun removeFromReplyList(it: User) {
open fun removeFromReplyList(it: User) {
mentions = mentions?.minus(it)
}
fun updateMessage(it: TextFieldValue) {
open fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
@@ -200,7 +167,7 @@ class NewPostViewModel : ViewModel() {
}
}
fun autocompleteWithUser(item: User) {
open fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
@@ -214,4 +181,21 @@ class NewPostViewModel : ViewModel() {
userSuggestions = emptyList()
}
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
return mutableStateMapOf(Pair(0, ""), Pair(1, ""))
}
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() })
}
fun canUsePoll(): Boolean {
return originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
}
fun canAddLnInvoice(): Boolean {
return account?.userProfile()?.info?.lnAddress() != null
}
}
@@ -0,0 +1,97 @@
package com.vitorpamplona.amethyst.ui.buttons
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPollView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
@Composable
fun FabColumn(account: Account) {
var isOpen by remember {
mutableStateOf(false)
}
var wantsToPoll by remember {
mutableStateOf(false)
}
var wantsToPost by remember {
mutableStateOf(false)
}
Column() {
if (isOpen) {
OutlinedButton(
onClick = {
wantsToPoll = true
isOpen = false
},
modifier = Modifier.size(45.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_poll),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
Spacer(modifier = Modifier.height(20.dp))
OutlinedButton(
onClick = {
wantsToPost = true
isOpen = false
},
modifier = Modifier.size(45.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_lists),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
Spacer(modifier = Modifier.height(20.dp))
}
OutlinedButton(
onClick = { isOpen = !isOpen },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
}
if (wantsToPoll) {
NewPollView({ wantsToPoll = false }, account = account)
}
}
@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst.buttons
package com.vitorpamplona.amethyst.ui.buttons
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
@@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
@@ -36,14 +34,21 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import kotlinx.coroutines.launch
@Composable
fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit) {
fun InvoiceRequest(
lud16: String,
toUserPubKeyHex: String,
account: Account,
titleText: String? = null,
buttonText: String? = null,
onSuccess: (String) -> Unit,
onClose: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -73,7 +78,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
)
Text(
text = stringResource(R.string.lightning_tips),
text = titleText ?: stringResource(R.string.lightning_tips),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -137,13 +142,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
amount * 1000,
message,
zapRequest?.toJson(),
onSuccess = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
startActivity(context, intent, null)
}
onClose()
},
onSuccess = onSuccess,
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
@@ -159,7 +158,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
Text(text = buttonText ?: stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
}
}
}
@@ -15,7 +15,6 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -138,10 +137,10 @@ fun RichTextViewer(
// sequence of images will render in a slideview
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { word.endsWith(it, true) }) {
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
imagesForPager.add(word)
}
if (videoExtensions.any { word.endsWith(it, true) }) {
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
imagesForPager.add(word)
}
}
@@ -155,28 +154,65 @@ fun RichTextViewer(
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { word.endsWith(it, true) }) {
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImageView(word, imagesForPager)
} else if (videoExtensions.any { word.endsWith(it, true) }) {
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImageView(word, imagesForPager)
} else {
UrlPreview(word, "$word ")
}
} else if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else if (word.startsWith("lnbc", true)) {
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(word, navController)
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
@@ -185,10 +221,6 @@ fun RichTextViewer(
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else {
Text(
text = "$word ",
@@ -198,12 +230,46 @@ fun RichTextViewer(
} else {
if (isValidURL(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(word, navController)
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
@@ -212,10 +278,6 @@ fun RichTextViewer(
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else {
Text(
text = "$word ",
@@ -235,19 +297,48 @@ private fun isArabic(text: String): Boolean {
}
fun isBechLink(word: String): Boolean {
val cleaned = word.removePrefix("@").removePrefix("nostr:").removePrefix("@")
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@").take(7)
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it, true) }
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}
@Composable
fun BechLink(word: String, navController: NavController) {
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val nip19Route = Nip19.uriToRoute(word)
if (nip19Route == null) {
Text(text = "$word ")
} else {
ClickableRoute(nip19Route, navController)
if (nip19Route.type == Nip19.Type.NOTE || nip19Route.type == Nip19.Type.EVENT || nip19Route.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateNote(nip19Route.hex)
if (note != null) {
if (canPreview) {
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
} else {
ClickableRoute(nip19Route, navController)
}
} else {
ClickableRoute(nip19Route, navController)
}
} else {
ClickableRoute(nip19Route, navController)
}
}
}
@@ -340,14 +431,8 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
if (tags[index][0] == "p") {
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
if (baseUser != null) {
val userState = baseUser.live().metadata.observeAsState()
val user = userState.value?.user
if (user != null) {
ClickableUserTag(user, navController)
Text(text = "$extraCharacters ")
} else {
Text(text = "$word ")
}
ClickableUserTag(baseUser, navController)
Text(text = "$extraCharacters ")
} else {
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
@@ -62,13 +62,13 @@ fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
mutableStateOf<AsyncImagePainter.State?>(null)
}
if (imageExtensions.any { word.endsWith(it, true) }) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
@@ -171,7 +171,8 @@ fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(image
@Composable
private fun RenderImageOrVideo(imageUrl: String) {
if (imageExtensions.any { imageUrl.endsWith(it, true) }) {
val removedParamsFromUrl = imageUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object GlobalFeedFilter : FeedFilter<Note>() {
@@ -17,7 +18,7 @@ object GlobalFeedFilter : FeedFilter<Note>() {
val notes = LocalCache.notes.values
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : FeedFilter<Note>() {
@@ -15,7 +17,7 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent) &&
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is RepostEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -17,7 +18,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val notes = LocalCache.notes.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent) &&
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is PollNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
@@ -28,6 +28,11 @@ object NotificationFeedFilter : FeedFilter<Note>() {
} == true ||
loggedInUser in it.directlyCiteUsers()
}
.filter { it ->
it.event !is PollNoteEvent ||
it.replyTo?.any { it.author == account.userProfile() } == true ||
account.userProfile() in it.directlyCiteUsers()
}
.filter {
it.event !is ReactionEvent ||
it.replyTo?.lastOrNull()?.author == loggedInUser ||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
@@ -24,10 +23,8 @@ import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -45,14 +42,15 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
import nostr.postr.bechToBytes
import nostr.postr.toHex
@Composable
fun AccountSwitchBottomSheet(
@@ -82,90 +80,101 @@ fun AccountSwitchBottomSheet(
accounts.forEach { acc ->
val current = accountUser.pubkeyNpub() == acc.npub
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val baseUser = try {
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
} catch (e: Exception) {
null
}
if (baseUser != null) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
Row(
modifier = Modifier
.weight(1f)
.clickable {
accountStateViewModel.switchUser(acc.npub)
},
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.padding(16.dp, 16.dp)
.weight(1f),
.weight(1f)
.clickable {
accountStateViewModel.switchUser(acc.npub)
},
verticalAlignment = Alignment.CenterVertically
) {
Box(
Row(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
.padding(16.dp, 16.dp)
.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
RobohashAsyncImageProxy(
robot = acc.npub.bechToBytes("npub").toHex(),
model = ResizeImage(acc.profilePicture, 55.dp),
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)
Box(
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
.width(55.dp)
.padding(0.dp)
) {
if (acc.hasPrivKey) {
RobohashAsyncImageProxy(
robot = user.pubkeyHex,
model = ResizeImage(user.profilePicture(), 55.dp),
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)/*
Box(
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
) {
if (acc.hasPrivKey) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = stringResource(R.string.account_switch_has_private_key),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
} else {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.account_switch_pubkey_only),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
}
}*/
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
val npubShortHex = acc.npub.toShortenHex()
user.bestDisplayName()?.let {
Text(it)
}
Text(npubShortHex)
}
Column(modifier = Modifier.width(32.dp)) {
if (current) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = stringResource(R.string.account_switch_has_private_key),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
} else {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.account_switch_pubkey_only),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
imageVector = Icons.Default.RadioButtonChecked,
contentDescription = stringResource(R.string.account_switch_active_account),
tint = MaterialTheme.colors.secondary
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
val npubShortHex = acc.npub.toShortenHex()
if (acc.displayName != null && acc.displayName != npubShortHex) {
Text(acc.displayName)
}
Text(npubShortHex)
}
Column(modifier = Modifier.width(32.dp)) {
if (current) {
Icon(
imageVector = Icons.Default.RadioButtonChecked,
contentDescription = stringResource(R.string.account_switch_active_account),
tint = MaterialTheme.colors.secondary
)
}
}
}
}
IconButton(
onClick = { accountStateViewModel.logOff(acc.npub) }
) {
Icon(
imageVector = Icons.Default.Logout,
contentDescription = stringResource(R.string.log_out),
tint = MaterialTheme.colors.onSurface
)
IconButton(
onClick = { accountStateViewModel.logOff(acc.npub) }
) {
Icon(
imageVector = Icons.Default.Logout,
contentDescription = stringResource(R.string.log_out),
tint = MaterialTheme.colors.onSurface
)
}
}
}
}
@@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -230,17 +230,15 @@ fun ChatroomMessageCompose(
if (!innerQuote && !replyTo.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.toSet().mapIndexed { _, note ->
if (note.event != null) {
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController,
onWantsToReply = onWantsToReply
)
}
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController,
onWantsToReply = onWantsToReply
)
}
}
}
@@ -283,20 +281,20 @@ fun ChatroomMessageCompose(
!noteForReports.hasAnyReports()
if (eventContent != null) {
TranslateableRichTextViewer(
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier,
Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
navController
)
} else {
TranslateableRichTextViewer(
TranslatableRichTextViewer(
stringResource(R.string.could_not_decrypt_the_message),
true,
Modifier,
Modifier.padding(top = 5.dp),
note.event?.tags(),
backgroundBubbleColor,
accountViewModel,
@@ -51,24 +51,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
@@ -112,7 +96,7 @@ fun NoteCompose(
)
}
Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
}
@OptIn(ExperimentalFoundationApi::class)
@@ -387,6 +371,7 @@ fun NoteComposeInner(
} else {
ReplyInformation(note.replyTo, sortedMentions, account, navController)
}
Spacer(modifier = Modifier.height(5.dp))
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
@@ -396,6 +381,7 @@ fun NoteComposeInner(
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
}
Spacer(modifier = Modifier.height(5.dp))
}
if (noteEvent is ReactionEvent || noteEvent is RepostEvent) {
@@ -490,7 +476,7 @@ fun NoteComposeInner(
) {
val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
TranslateableRichTextViewer(
TranslatableRichTextViewer(
stringResource(
id = R.string.private_conversation_notification,
"@${note.author?.pubkeyNpub()}",
@@ -528,7 +514,7 @@ fun NoteComposeInner(
overflow = TextOverflow.Ellipsis
)
} else {
TranslateableRichTextViewer(
TranslatableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
@@ -540,6 +526,16 @@ fun NoteComposeInner(
DisplayUncitedHashtags(noteEvent, eventContent, navController)
}
if (noteEvent is PollNoteEvent) {
PollNote(
note,
canPreview = canPreview && !makeItShort,
backgroundColor,
accountViewModel,
navController
)
}
}
if (!makeItShort) {
@@ -0,0 +1,525 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
import kotlin.math.roundToInt
@Composable
fun PollNote(
baseNote: Note,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return
val pollViewModel = PollNoteViewModel()
pollViewModel.load(zappedNote)
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
val optionTally = pollViewModel.optionVoteTally(poll_op.key)
val color = if (
pollViewModel.consensusThreshold != null &&
optionTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
) {
if (zappedNote.author == account.userProfile() || zappedNote.isZappedBy(account.userProfile()) == true) {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
poll_op.key,
nonClickablePrepend = {
Box(
Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
color,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = optionTally
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
) {
Text(
text = "${(optionTally * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
}
}
},
clickablePrepend = {
}
)
} else {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
poll_op.key,
nonClickablePrepend = {},
clickablePrepend = {
Box(
Modifier.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
}
)
}
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZapVote(
baseNote: Note,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
pollOption: Int,
modifier: Modifier = Modifier,
nonClickablePrepend: @Composable () -> Unit,
clickablePrepend: @Composable () -> Unit
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note
var wantsToZap by remember { mutableStateOf(false) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableStateOf(0f) }
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
nonClickablePrepend()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.combinedClickable(
role = Role.Button,
// interactionSource = remember { MutableInteractionSource() },
// indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
Toast.LENGTH_SHORT
)
.show()
}
} else if (pollViewModel.isPollClosed()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.poll_is_closed),
Toast.LENGTH_SHORT
)
.show()
}
} else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
// only allow one vote per option when min==max, i.e. atomic vote amount specified
scope.launch {
Toast
.makeText(
context,
R.string.one_vote_per_user_on_atomic_votes,
Toast.LENGTH_SHORT
)
.show()
}
return@combinedClickable
} else if (account.zapAmountChoices.size == 1 && pollViewModel.isValidInputVoteAmount(account.zapAmountChoices.first())) {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
pollOption,
"",
context,
onError = {
scope.launch {
zappingProgress = 0f
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
)
}
} else {
wantsToZap = true
}
}
)
) {
if (wantsToZap) {
FilteredZapAmountChoicePopup(
baseNote,
accountViewModel,
pollViewModel,
pollOption,
onDismiss = {
wantsToZap = false
zappingProgress = 0f
},
onChangeAmount = {
wantsToZap = false
},
onError = {
scope.launch {
zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
)
}
clickablePrepend()
if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
} else {
if (zappingProgress < 0.1 || zappingProgress > 0.99) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
}
}
}
// only show tallies after a user has zapped note
if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) {
Text(
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = modifier
)
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun FilteredZapAmountChoicePopup(
baseNote: Note,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
pollOption: Int,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
val context = LocalContext.current
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapMessage = ""
val scope = rememberCoroutineScope()
val options = account.zapAmountChoices.filter { pollViewModel.isValidInputVoteAmount(it) }.toMutableList()
if (options.isEmpty()) {
pollViewModel.valueMinimum?.let { minimum ->
pollViewModel.valueMaximum?.let { maximum ->
if (minimum != maximum) {
options.add(((minimum + maximum) / 2).toLong())
}
}
}
}
pollViewModel.valueMinimum?.let { options.add(it.toLong()) }
pollViewModel.valueMaximum?.let { options.add(it.toLong()) }
val sortedOptions = options.toSet().sorted()
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -100),
onDismissRequest = { onDismiss() }
) {
FlowRow(horizontalArrangement = Arrangement.Center) {
sortedOptions.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
amountInSats * 1000,
pollOption,
zapMessage,
context,
onError,
onProgress
)
onDismiss()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(
"${showAmount(amountInSats.toBigDecimal().setScale(1))}",
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
amountInSats * 1000,
pollOption,
zapMessage,
context,
onError,
onProgress
)
onDismiss()
}
},
onLongClick = {
onChangeAmount()
}
)
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZapVoteAmountChoicePopup(
baseNote: Note,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
pollOption: Int,
onDismiss: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
val context = LocalContext.current
var inputAmountText by rememberSaveable { mutableStateOf("") }
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Dialog(
onDismissRequest = { onDismiss() },
properties = DialogProperties(
dismissOnClickOutside = true,
usePlatformDefaultWidth = false
)
) {
Surface {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(10.dp)
) {
var amount = pollViewModel.inputVoteAmountLong(inputAmountText)
// only prompt for input amount if vote is not atomic
if (!pollViewModel.isVoteAmountAtomic()) {
OutlinedTextField(
value = inputAmountText,
onValueChange = { inputAmountText = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidInputVoteAmount(amount)) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_zap_amount),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = pollViewModel.voteAmountPlaceHolderText(context.getString(R.string.sats)),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
} else { amount = pollViewModel.valueMaximum?.toLong() }
val isValidInputAmount = pollViewModel.isValidInputVoteAmount(amount)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
enabled = isValidInputAmount,
onClick = {
if (amount != null && isValidInputAmount) {
accountViewModel.zap(
baseNote,
amount * 1000,
pollOption,
"",
context,
onError,
onProgress
)
onDismiss()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(
"${showAmount(amount?.toBigDecimal()?.setScale(1))}",
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {
if (amount != null && isValidInputAmount) {
accountViewModel.zap(
baseNote,
amount * 1000,
pollOption,
"",
context,
onError,
onProgress
)
onDismiss()
}
},
onLongClick = {}
)
)
}
}
}
}
}
@@ -0,0 +1,119 @@
package com.vitorpamplona.amethyst.ui.note
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import java.math.BigDecimal
import java.util.*
class PollNoteViewModel {
var account: Account? = null
private var pollNote: Note? = null
var pollEvent: PollNoteEvent? = null
private var pollOptions: Map<Int, String>? = null
var valueMaximum: Int? = null
var valueMinimum: Int? = null
private var closedAt: Int? = null
var consensusThreshold: Float? = null
fun load(note: Note?) {
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)
closedAt = pollEvent?.getTagInt(CLOSED_AT)
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate
pollNote?.createdAt()?.plus(it * (86400 + 120))!! < Date().time / 1000
} == true
fun voteAmountPlaceHolderText(sats: String): String = if (valueMinimum == null && valueMaximum == null) {
sats
} else if (valueMinimum == null) {
"1—$valueMaximum $sats"
} else if (valueMaximum == null) {
">$valueMinimum $sats"
} else {
"$valueMinimum$valueMaximum $sats"
}
fun inputVoteAmountLong(textAmount: String) = if (textAmount.isEmpty()) { null } else {
try {
textAmount.toLong()
} catch (e: Exception) { null }
}
fun isValidInputVoteAmount(amount: Long?): Boolean {
if (amount == null) {
return false
} else if (valueMinimum == null && valueMaximum == null) {
if (amount > 0) {
return true
}
} else if (valueMinimum == null) {
if (amount > 0 && amount <= valueMaximum!!) {
return true
}
} else if (valueMaximum == null) {
if (amount >= valueMinimum!!) {
return true
}
} else {
if ((valueMinimum!! <= amount) && (amount <= valueMaximum!!)) {
return true
}
}
return false
}
fun optionVoteTally(op: Int): Float {
val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal())
return if (tally.isNaN()) { // catch div by 0
0f
} else { tally }
}
private fun zappedVoteTotal(): Float {
var total = 0f
pollOptions?.keys?.forEach {
total += zappedPollOptionAmount(it).toFloat()
}
return total
}
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
if (pollNote?.zaps?.any { it.key.author == user } == true) {
pollNote!!.zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.map {
val zappedOption = it.zappedPollOption()
if (zappedOption == option && it.zappedRequestAuthor() == user.pubkeyHex) {
return true
}
}
}
return false
}
fun zappedPollOptionAmount(option: Int): BigDecimal {
return if (pollNote != null) {
pollNote!!.zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
val zappedOption = it.zappedPollOption()
if (zappedOption == option) {
it.amount
} else { null }
}.sumOf { it }
} else {
BigDecimal(0)
}
}
}
@@ -64,7 +64,6 @@ import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -125,7 +124,7 @@ fun ReplyReaction(
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.size(20.dp),
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -348,6 +347,7 @@ fun ZapReaction(
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
null,
zapMessage,
context,
onError = {
@@ -459,7 +459,7 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
modifier = Modifier.size(20.dp),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
) {
Icon(
@@ -474,7 +474,6 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
.crossfade(true)
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
@@ -555,6 +554,7 @@ fun ZapAmountChoicePopup(
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
onError,
@@ -579,6 +579,7 @@ fun ZapAmountChoicePopup(
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
onError,
@@ -1,193 +1,194 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ZapOptionstViewModel : ViewModel() {
private var account: Account? = null
var customAmount by mutableStateOf(TextFieldValue("1000"))
var customMessage by mutableStateOf(TextFieldValue(""))
fun load(account: Account) {
this.account = account
}
fun canSend(): Boolean {
return value() != null
}
fun value(): Long? {
return try {
customAmount.text.trim().toLongOrNull()
} catch (e: Exception) {
null
}
}
fun cancel() {
}
}
@Composable
fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: ZapOptionstViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
ZapButton(
isActive = postViewModel.canSend()
) {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
postViewModel.value()!! * 1000L,
postViewModel.customMessage.text,
context,
onError = {
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
}
}
)
}
onClose()
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
// stringResource(R.string.new_amount_in_sats
label = { Text(text = stringResource(id = R.string.amount_in_sats)) },
value = postViewModel.customAmount,
onValueChange = {
postViewModel.customAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
}
Spacer(modifier = Modifier.height(5.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
// stringResource(R.string.new_amount_in_sats
label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) },
value = postViewModel.customMessage,
onValueChange = {
postViewModel.customMessage = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Text
),
placeholder = {
Text(
text = stringResource(id = R.string.custom_zaps_add_a_message_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
}
}
}
}
}
@Composable
fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
Button(
onClick = { onPost() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "⚡Zap ", color = Color.White)
}
}
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ZapOptionstViewModel : ViewModel() {
private var account: Account? = null
var customAmount by mutableStateOf(TextFieldValue("1000"))
var customMessage by mutableStateOf(TextFieldValue(""))
fun load(account: Account) {
this.account = account
}
fun canSend(): Boolean {
return value() != null
}
fun value(): Long? {
return try {
customAmount.text.trim().toLongOrNull()
} catch (e: Exception) {
null
}
}
fun cancel() {
}
}
@Composable
fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: ZapOptionstViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
ZapButton(
isActive = postViewModel.canSend()
) {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
postViewModel.value()!! * 1000L,
null,
postViewModel.customMessage.text,
context,
onError = {
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
}
}
)
}
onClose()
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
// stringResource(R.string.new_amount_in_sats
label = { Text(text = stringResource(id = R.string.amount_in_sats)) },
value = postViewModel.customAmount,
onValueChange = {
postViewModel.customAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
}
Spacer(modifier = Modifier.height(5.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
// stringResource(R.string.new_amount_in_sats
label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) },
value = postViewModel.customMessage,
onValueChange = {
postViewModel.customMessage = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Text
),
placeholder = {
Text(
text = stringResource(id = R.string.custom_zaps_add_a_message_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
}
}
}
}
}
@Composable
fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
Button(
onClick = { onPost() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "⚡Zap ", color = Color.White)
}
}
@@ -0,0 +1,109 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = zapSetCard) {
withContext(Dispatchers.IO) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt)
}
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.clickable {
navController.navigate("User/${zapSetCard.user.pubkeyHex}")
}
) {
Row(
modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp
)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(id = R.string.zaps),
tint = BitcoinOrange,
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd)
)
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, navController = navController)
}
}
}
}
@@ -66,7 +66,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.SpaceBetween
verticalArrangement = Arrangement.SpaceAround
) {
if (presenting) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -91,22 +91,22 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
fontSize = 18.sp
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 35.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkeyNpub()}")
}
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 30.dp, vertical = 10.dp)
.padding(horizontal = 35.dp)
) {
QrCodeDrawer("nostr:${user.pubkeyNpub()}")
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 30.dp)
) {
Button(
onClick = { presenting = false },
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.nip19.Nip19
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -33,18 +35,20 @@ class AccountStateViewModel() : ViewModel() {
private fun tryLoginExistingAccount() {
LocalPreferences.loadFromEncryptedStorage()?.let {
login(it)
startUI(it)
}
}
fun login(key: String) {
fun startUI(key: String) {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray()
val account =
if (key.startsWith("nsec")) {
Account(Persona(privKey = key.bechToBytes()))
} else if (key.startsWith("npub")) {
Account(Persona(pubKey = key.bechToBytes()))
} else if (pubKeyParsed != null) {
Account(Persona(pubKey = pubKeyParsed))
} else if (pattern.matcher(key).matches()) {
// Evaluate NIP-5
Account(Persona())
@@ -52,7 +56,8 @@ class AccountStateViewModel() : ViewModel() {
Account(Persona(Hex.decode(key)))
}
login(account)
LocalPreferences.updatePrefsForLogin(account)
startUI(account)
}
fun switchUser(npub: String) {
@@ -63,24 +68,22 @@ class AccountStateViewModel() : ViewModel() {
fun newKey() {
val account = Account(Persona())
login(account)
// saves to local preferences
LocalPreferences.updatePrefsForLogin(account)
startUI(account)
}
@OptIn(DelicateCoroutinesApi::class)
fun login(account: Account) {
LocalPreferences.updatePrefsForLogin(account)
fun startUI(account: Account) {
if (account.loggedIn.privKey != null) {
_accountContent.update { AccountState.LoggedIn(account) }
} else {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
ServiceManager.start(account)
}
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
abstract class Card() {
abstract fun createdAt(): Long
@@ -40,6 +41,14 @@ class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>) : Card() {
override fun id() = note.idHex + "Z" + createdAt
}
class ZapUserSetCard(val user: User, val zapEvents: Map<Note, Note>) : Card() {
val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 }
override fun createdAt(): Long {
return createdAt
}
override fun id() = user.pubkeyHex + "U" + createdAt
}
class MultiSetCard(val note: Note, val boostEvents: List<Note>, val likeEvents: List<Note>, val zapEvents: Map<Note, Note>) : Card() {
val createdAt = maxOf(
zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0,
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@@ -128,6 +129,13 @@ private fun FeedLoaded(
navController = navController,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -78,7 +79,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
// val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
val zapsPerUser = mutableMapOf<User, MutableMap<Note, Note>>()
val zapsPerEvent = mutableMapOf<Note, MutableMap<Note, Note>>()
notes
.filter { it.event is LnZapEvent }
@@ -89,6 +90,20 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
if (zapRequest != null) {
zapsPerEvent.getOrPut(zappedPost, { mutableMapOf() }).put(zapRequest, zapEvent)
}
} else {
val event = (zapEvent.event as LnZapEvent)
val author = event.zappedAuthor().mapNotNull {
LocalCache.checkGetOrCreateUser(
it
)
}.firstOrNull()
if (author != null) {
val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (zapRequest != null) {
zapsPerUser.getOrPut(author, { mutableMapOf() })
.put(zapRequest, zapEvent)
}
}
}
}
@@ -121,6 +136,13 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}.flatten()
val userZaps = zapsPerUser.map {
ZapUserSetCard(
it.key,
it.value
)
}
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map {
if (it.event is PrivateDmEvent) {
MessageSetCard(it)
@@ -131,7 +153,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}
return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
return (multiCards + textNoteCards + userZaps).sortedBy { it.createdAt() }.reversed()
}
private fun updateFeed(notes: List<Card>) {
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -21,6 +22,7 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.SnackbarDefaults.backgroundColor
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
@@ -53,8 +55,10 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.*
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DisplayFollowingHashtagsInPost
@@ -293,8 +297,9 @@ fun NoteMaster(
}
}
Spacer(modifier = Modifier.height(10.dp))
if (noteEvent is BadgeDefinitionEvent) {
Spacer(modifier = Modifier.padding(top = 10.dp))
BadgeDisplay(baseNote = note)
} else if (noteEvent is LongTextNoteEvent) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) {
@@ -350,7 +355,7 @@ fun NoteMaster(
!noteForReports.hasAnyReports()
if (eventContent != null) {
TranslateableRichTextViewer(
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier.fillMaxWidth(),
@@ -361,6 +366,16 @@ fun NoteMaster(
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
if (noteEvent is PollNoteEvent) {
PollNote(
note,
canPreview,
backgroundColor,
accountViewModel,
navController
)
}
}
ReactionsRow(note, accountViewModel)
@@ -52,7 +52,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
suspend fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
@@ -60,7 +60,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
return
}
val zapRequest = account.createZapRequestFor(note, message)
val zapRequest = account.createZapRequestFor(note, pollOption, message)
onProgress(0.10f)
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
@@ -213,7 +214,9 @@ fun ChannelScreen(
trailingIcon = {
PostButton(
onPost = {
account.sendChannelMessage(channelScreenModel.message.text, channel.idHex, replyTo.value, null)
val tagger = NewMessageTagger(channel, listOfNotNull(replyTo.value?.author), listOfNotNull(replyTo.value), channelScreenModel.message.text)
tagger.run()
account.sendChannelMessage(tagger.message, channel.idHex, tagger.replyTos, tagger.mentions)
channelScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
@@ -177,7 +177,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
trailingIcon = {
PostButton(
onPost = {
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value)
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value, null)
chatRoomScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.*
import androidx.compose.material.DrawerValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
@@ -22,7 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.buttons.NewChannelButton
import com.vitorpamplona.amethyst.buttons.NewNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
import com.vitorpamplona.amethyst.ui.navigation.*
import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
@@ -64,7 +66,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
},
floatingActionButton = {
FloatingButton(navController, accountStateViewModel)
FloatingButtons(navController, accountStateViewModel)
},
scaffoldState = scaffoldState
) {
@@ -76,7 +78,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
}
@Composable
fun FloatingButton(navController: NavHostController, accountViewModel: AccountStateViewModel) {
fun FloatingButtons(navController: NavHostController, accountViewModel: AccountStateViewModel) {
val accountState by accountViewModel.accountContent.collectAsState()
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
@@ -27,6 +29,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
@@ -39,6 +42,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -64,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
@@ -73,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.FeedView
@@ -420,6 +425,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Row(verticalAlignment = Alignment.Bottom) {
user.bestDisplayName()?.let {
@@ -450,16 +456,41 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
IconButton(
modifier = Modifier
.size(30.dp)
.size(25.dp)
.padding(start = 5.dp),
onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); }
) {
Icon(
imageVector = Icons.Default.ContentCopy,
null,
modifier = Modifier
.padding(end = 5.dp)
.size(15.dp),
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
var dialogOpen by remember {
mutableStateOf(false)
}
if (dialogOpen) {
ShowQRDialog(
user,
onScan = {
dialogOpen = false
navController.navigate(it)
},
onClose = { dialogOpen = false }
)
}
IconButton(
modifier = Modifier.size(25.dp),
onClick = { dialogOpen = true }
) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@@ -529,9 +560,25 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
if (zapExpanded) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(lud16, baseUser.pubkeyHex, account) {
zapExpanded = false
}
InvoiceRequest(
lud16,
baseUser.pubkeyHex,
account,
onSuccess = {
// pay directly
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(it)
} else {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
ContextCompat.startActivity(context, intent, null)
}
}
},
onClose = {
zapExpanded = false
}
)
}
}
}
@@ -563,7 +610,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
TranslateableRichTextViewer(
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = null,
@@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import java.util.*
@@ -51,6 +52,9 @@ fun LoginPage(
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
val uri = LocalUriHandler.current
val context = LocalContext.current
var dialogOpen by remember {
mutableStateOf(false)
}
Column(
modifier = Modifier
@@ -117,16 +121,36 @@ fun LoginPage(
)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
Row {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
}
)
}
}
},
leadingIcon = {
if (dialogOpen) {
SimpleQrCodeScanner {
dialogOpen = false
if (!it.isNullOrEmpty()) {
key.value = TextFieldValue(it)
}
}
}
IconButton(onClick = { dialogOpen = true }) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
},
@@ -134,7 +158,7 @@ fun LoginPage(
keyboardActions = KeyboardActions(
onGo = {
try {
accountViewModel.login(key.value.text)
accountViewModel.startUI(key.value.text)
} catch (e: Exception) {
errorMessage = context.getString(R.string.invalid_key)
}
@@ -213,7 +237,7 @@ fun LoginPage(
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
accountViewModel.startUI(key.value.text)
} catch (e: Exception) {
errorMessage = context.getString(R.string.invalid_key)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+18
View File
@@ -267,9 +267,27 @@
<string name="wallet_connect_service_secret_placeholder">nsec / hex private key</string>
<string name="pledge_amount_in_sats">Pledge Amount in Sats</string>
<string name="post_poll">Post Poll</string>
<string name="poll_heading_required">Required fields:</string>
<string name="poll_zap_recipients">Zap recipients</string>
<string name="poll_primary_description">Primary poll description…</string>
<string name="poll_option_index">Option %s</string>
<string name="poll_option_description">Poll option description</string>
<string name="poll_heading_optional">Optional fields:</string>
<string name="poll_zap_value_min">Zap minimum</string>
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Close after</string>
<string name="poll_closing_time_days">days</string>
<string name="poll_is_closed">Poll is closed to new votes</string>
<string name="poll_zap_amount">Zap amount</string>
<string name="one_vote_per_user_on_atomic_votes">Only one vote per user is allowed on this type of poll</string>
<string name="looking_for_event">"Looking for Event %1$s"</string>
<string name="custom_zaps_add_a_message">Add a public message</string>
<string name="custom_zaps_add_a_message_example">Thank you for all your work!</string>
<string name="lightning_create_and_add_invoice">Create and Add</string>
</resources>
@@ -38,7 +38,7 @@ import kotlinx.coroutines.withContext
import java.util.Locale
@Composable
fun TranslateableRichTextViewer(
fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
@@ -79,7 +79,7 @@ fun TranslateableRichTextViewer(
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
Column(modifier = Modifier.padding(top = 5.dp)) {
Column() {
ExpandableRichTextViewer(
toBeViewed,
canPreview,