diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e6e2910b6..e7ef182e0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Cache gradle uses: actions/cache@v1 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 853a6ad2b1..e788e06bfc 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -12,10 +12,10 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Cache gradle uses: actions/cache@v1 diff --git a/.gitignore b/.gitignore index d67bb1a415..a167f9389e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/androidTestResultsUserPreferences.xml +/.idea/deploymentTargetDropDown.xml .DS_Store /build /captures diff --git a/README.md b/README.md index e31320ea18..7d2eb1ce50 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting. +[Get it on F-Droid](https://f-droid.org/packages/com.vitorpamplona.amethyst/) +[Get it on Google Play](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 @@ -75,7 +85,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro ## Setup Make sure to have the following pre-requisites installed: -1. Java 11 +1. Java 17 2. Android Studio 3. Android 8.0+ Phone or Emulation setup @@ -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 @@ -150,6 +167,12 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th [Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome. +## Contributors + + + + + # MIT License Copyright (c) 2023 Vitor Pamplona diff --git a/app/build.gradle b/app/build.gradle index 3f269ebef3..c31919d7da 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 113 - versionName "0.31.3" + versionCode 118 + versionName "0.32.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -57,12 +57,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '11' + jvmTarget = '17' } buildFeatures { @@ -78,6 +78,10 @@ android { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } + + lintOptions { + disable 'MissingTranslation' + } } dependencies { diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt similarity index 91% rename from app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt rename to app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 920b0b0775..5f4659d445 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -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>?, - 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>?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) = ExpandableRichTextViewer( + content, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + navController +) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 518e6a5397..3735c465f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 1726474863..40b6d817ea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -17,6 +17,8 @@ import nostr.postr.Persona import nostr.postr.toHex import nostr.postr.toNpub import java.io.File +import java.net.InetSocketAddress +import java.net.Proxy import java.util.Locale // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences @@ -27,10 +29,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 +37,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" @@ -51,6 +48,7 @@ private object PrefKeys { const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" + const val USE_PROXY = "use_proxy" val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } } @@ -59,53 +57,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 - get() = encryptedPreferences() - .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf() + private var _savedAccounts: List? = null + + private fun savedAccounts(): List { + if (_savedAccounts == null) { + _savedAccounts = encryptedPreferences() + .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf() + } + return _savedAccounts!! + } + + private fun updateSavedAccounts(accounts: List) { + 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 +163,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 +177,8 @@ object LocalPreferences { } fun allSavedAccounts(): List { - 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 +198,13 @@ 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()) + println(account.proxy != null) + putBoolean(PrefKeys.USE_PROXY, account.proxy != null) }.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() @@ -246,8 +255,10 @@ object LocalPreferences { val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) + val useProxy = getBoolean(PrefKeys.USE_PROXY, false) + var proxy = if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) else null - return Account( + val a = Account( Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels, hiddenUsers, @@ -259,25 +270,28 @@ object LocalPreferences { zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, - latestContactList + latestContactList, + proxy ) + + 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 +328,6 @@ object LocalPreferences { encryptedPreferences().edit().clear().apply() addAccount(npub) - currentAccount = npub + updateCurrentAccount(npub) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7c121402c1..fadefa7902 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1,810 +1,852 @@ -package com.vitorpamplona.amethyst.model - -import android.content.res.Resources -import androidx.core.os.ConfigurationCompat -import androidx.lifecycle.LiveData -import com.vitorpamplona.amethyst.service.model.BookmarkListEvent -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.Contact -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.DeletionEvent -import com.vitorpamplona.amethyst.service.model.IdentityClaim -import com.vitorpamplona.amethyst.service.model.LnZapPaymentRequestEvent -import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent -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.ReportEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent -import com.vitorpamplona.amethyst.service.relays.Client -import com.vitorpamplona.amethyst.service.relays.Constants -import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.Relay -import com.vitorpamplona.amethyst.service.relays.RelayPool -import com.vitorpamplona.amethyst.ui.components.BundledUpdate -import com.vitorpamplona.amethyst.ui.note.Nip47URI -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import nostr.postr.Persona -import java.util.Locale - -val DefaultChannels = setOf( - "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr - "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group -) - -fun getLanguagesSpokenByUser(): Set { - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) - val codedList = mutableSetOf() - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { codedList.add(it.language) } - } - return codedList -} - -@OptIn(DelicateCoroutinesApi::class) -class Account( - val loggedIn: Persona, - var followingChannels: Set = DefaultChannels, - var hiddenUsers: Set = setOf(), - var localRelays: Set = Constants.defaultRelays.toSet(), - var dontTranslateFrom: Set = getLanguagesSpokenByUser(), - var languagePreferences: Map = mapOf(), - var translateTo: String = Locale.getDefault().language, - var zapAmountChoices: List = listOf(500L, 1000L, 5000L), - var zapPaymentRequest: Nip47URI? = null, - var hideDeleteRequestDialog: Boolean = false, - var hideBlockAlertDialog: Boolean = false, - var backupContactList: ContactListEvent? = null -) { - var transientHiddenUsers: Set = setOf() - - // Observers line up here. - val live: AccountLiveData = AccountLiveData(this) - val liveLanguages: AccountLiveData = AccountLiveData(this) - val saveable: AccountLiveData = AccountLiveData(this) - - fun userProfile(): User { - return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) - } - - fun followingChannels(): List { - return followingChannels.map { LocalCache.getOrCreateChannel(it) } - } - - fun hiddenUsers(): List { - return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } - } - - fun isWriteable(): Boolean { - return loggedIn.privKey != null - } - - fun sendNewRelayList(relays: Map) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val follows = contactList?.follows() ?: emptyList() - val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - if (contactList != null && follows.isNotEmpty()) { - val event = ContactListEvent.create( - follows, - followsTags, - relays, - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } else { - val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!) - - // Keep this local to avoid erasing a good contact list. - // Client.send(event) - LocalCache.consume(event) - } - } - - fun sendNewUserMetadata(toString: String, identities: List) { - if (!isWriteable()) return - - loggedIn.privKey?.let { - val event = MetadataEvent.create(toString, identities, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun reactionTo(note: Note): List { - return note.reactedBy(userProfile(), "+") - } - - fun hasBoosted(note: Note): Boolean { - return boostsTo(note).isNotEmpty() - } - - fun boostsTo(note: Note): List { - return note.boostedBy(userProfile()) - } - - fun hasReacted(note: Note): Boolean { - return note.hasReacted(userProfile(), "+") - } - - fun reactTo(note: Note) { - if (!isWriteable()) return - - if (hasReacted(note)) { - // has already liked this note - return - } - - note.event?.let { - val event = ReactionEvent.createLike(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun createZapRequestFor(note: Note, message: String = ""): LnZapRequestEvent? { - if (!isWriteable()) return null - - note.event?.let { - return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message) - } - - return null - } - - fun hasWalletConnectSetup(): Boolean { - return zapPaymentRequest != null - } - - fun sendZapPaymentRequestFor(lnInvoice: String) { - if (!isWriteable()) return - - zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) - - Client.send(event, it.relayUri) - } - } - - fun createZapRequestFor(user: User): LnZapRequestEvent? { - return createZapRequestFor(user.pubkeyHex) - } - - fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? { - if (!isWriteable()) return null - - return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message) - } - - fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { - if (!isWriteable()) return - - if (note.hasReacted(userProfile(), "⚠️")) { - // has already liked this note - return - } - - note.event?.let { - val event = ReactionEvent.createWarning(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - - note.event?.let { - val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content) - Client.send(event) - LocalCache.consume(event, null) - } - } - - fun report(user: User, type: ReportEvent.ReportType) { - if (!isWriteable()) return - - if (user.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event, null) - } - - fun delete(note: Note) { - delete(listOf(note)) - } - - fun delete(notes: List) { - if (!isWriteable()) return - - val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex } - - if (myNotes.isNotEmpty()) { - val event = DeletionEvent.create(myNotes, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun boost(note: Note) { - if (!isWriteable()) return - - if (note.hasBoostedInTheLast5Minutes(userProfile())) { - // has already bosted in the past 5mins - return - } - - note.event?.let { - val event = RepostEvent.create(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun broadcast(note: Note) { - note.event?.let { - Client.send(it) - } - } - - fun follow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val followingUsers = contactList?.follows() ?: emptyList() - val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - val event = if (contactList != null) { - ContactListEvent.create( - followingUsers.plus(Contact(user.pubkeyHex, null)), - followingTags, - contactList.relays(), - loggedIn.privKey!! - ) - } else { - val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } - ContactListEvent.create( - listOf(Contact(user.pubkeyHex, null)), - followingTags, - relays, - loggedIn.privKey!! - ) - } - - Client.send(event) - LocalCache.consume(event) - } - - fun follow(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val followingUsers = contactList?.follows() ?: emptyList() - val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - val event = if (contactList != null) { - ContactListEvent.create( - followingUsers, - followingTags.plus(tag), - contactList.relays(), - loggedIn.privKey!! - ) - } else { - val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } - ContactListEvent.create( - followingUsers, - followingTags.plus(tag), - relays, - loggedIn.privKey!! - ) - } - - Client.send(event) - LocalCache.consume(event) - } - - fun unfollow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val followingUsers = contactList?.follows() ?: emptyList() - val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) { - val event = ContactListEvent.create( - followingUsers.filter { it.pubKeyHex != user.pubkeyHex }, - followingTags, - contactList.relays(), - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - } - - fun unfollow(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val followingUsers = contactList?.follows() ?: emptyList() - val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) { - val event = ContactListEvent.create( - followingUsers, - followingTags.filter { !it.equals(tag, ignoreCase = true) }, - contactList.relays(), - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - } - - fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - val signedEvent = TextNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - extraTags = tags, - privateKey = loggedIn.privKey!! - ) - - Client.send(signedEvent) - LocalCache.consume(signedEvent) - } - - fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List?) { - if (!isWriteable()) return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } - - val signedEvent = ChannelMessageEvent.create( - message = message, - channel = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - privateKey = loggedIn.privKey!! - ) - Client.send(signedEvent) - LocalCache.consume(signedEvent, null) - } - - fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) { - if (!isWriteable()) return - val user = LocalCache.users[toUser] ?: return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = emptyList() - - val signedEvent = PrivateDmEvent.create( - recipientPubKey = user.pubkey(), - publishedRecipientPubKey = user.pubkey(), - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - privateKey = loggedIn.privKey!!, - advertiseNip18 = false - ) - Client.send(signedEvent) - LocalCache.consume(signedEvent, null) - } - - fun sendCreateNewChannel(name: String, about: String, picture: String) { - if (!isWriteable()) return - - val metadata = ChannelCreateEvent.ChannelData( - name, - about, - picture - ) - - val event = ChannelCreateEvent.create( - channelInfo = metadata, - privateKey = loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - - joinChannel(event.id) - } - - fun addPrivateBookmark(note: Note) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList - - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents() ?: emptyList(), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), - - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - - fun addPublicBookmark(note: Note) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList - - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), - - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - - fun removePrivateBookmark(note: Note) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList - - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents() ?: emptyList(), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), - - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - - fun removePublicBookmark(note: Note) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList - - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents()?.minus(note.idHex), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), - - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - - fun isInPrivateBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!) - ?.contains(note.address) == true - } else { - return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!) - ?.contains(note.idHex) == true - } - } - - fun isInPublicBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true - } else { - return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true - } - } - - fun joinChannel(idHex: String) { - followingChannels = followingChannels + idHex - live.invalidateData() - - saveable.invalidateData() - } - - fun leaveChannel(idHex: String) { - followingChannels = followingChannels - idHex - live.invalidateData() - - saveable.invalidateData() - } - - fun hideUser(pubkeyHex: String) { - hiddenUsers = hiddenUsers + pubkeyHex - live.invalidateData() - saveable.invalidateData() - } - - fun showUser(pubkeyHex: String) { - hiddenUsers = hiddenUsers - pubkeyHex - transientHiddenUsers = transientHiddenUsers - pubkeyHex - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapAmounts(newAmounts: List) { - zapAmountChoices = newAmounts - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapPaymentRequest(newServer: Nip47URI?) { - zapPaymentRequest = newServer - live.invalidateData() - saveable.invalidateData() - } - - fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { - if (!isWriteable()) return - - val metadata = ChannelCreateEvent.ChannelData( - name, - about, - picture - ) - - val event = ChannelMetadataEvent.create( - newChannelInfo = metadata, - originalChannelIdHex = channel.idHex, - privateKey = loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - - joinChannel(event.id) - } - - fun decryptContent(note: Note): String? { - val event = note.event - return if (event is PrivateDmEvent && loggedIn.privKey != null) { - var pubkeyToUse = event.pubKey - - val recepientPK = event.recipientPubKey() - - if (note.author == userProfile() && recepientPK != null) { - pubkeyToUse = recepientPK - } - - event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) - } else { - event?.content() - } - } - - fun addDontTranslateFrom(languageCode: String) { - dontTranslateFrom = dontTranslateFrom.plus(languageCode) - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun updateTranslateTo(languageCode: String) { - translateTo = languageCode - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun prefer(source: String, target: String, preference: String) { - languagePreferences = languagePreferences + Pair("$source,$target", preference) - saveable.invalidateData() - } - - fun preferenceBetween(source: String, target: String): String? { - return languagePreferences.get("$source,$target") - } - - private fun updateContactListTo(newContactList: ContactListEvent?) { - if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return - - // Events might be different objects, we have to compare their ids. - if (backupContactList?.id != newContactList?.id) { - backupContactList = newContactList - saveable.invalidateData() - } - } - - // Takes a User's relay list and adds the types of feeds they are active for. - fun activeRelays(): Array? { - var usersRelayList = userProfile().latestContactList?.relays()?.map { - val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet() - Relay(it.key, it.value.read, it.value.write, localFeedTypes) - } ?: return null - - // Ugly, but forces nostr.band as the only search-supporting relay today. - // TODO: Remove when search becomes more available. - if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) { - usersRelayList = usersRelayList + Relay( - Constants.forcedRelayForSearch.url, - Constants.forcedRelayForSearch.read, - Constants.forcedRelayForSearch.write, - Constants.forcedRelayForSearch.feedTypes - ) - } - - return usersRelayList.toTypedArray() - } - - fun convertLocalRelays(): Array { - return localRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) - }.toTypedArray() - } - - fun reconnectIfRelaysHaveChanged() { - val newRelaySet = activeRelays() ?: convertLocalRelays() - if (!Client.isSameRelaySetConfig(newRelaySet)) { - Client.disconnect() - Client.connect(newRelaySet) - RelayPool.requestAndWatch() - } - } - - fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers - - fun followingKeySet(): Set { - return userProfile().cachedFollowingKeySet() ?: emptySet() - } - - fun followingTagSet(): Set { - return userProfile().cachedFollowingTagSet() ?: emptySet() - } - - fun isAcceptable(user: User): Boolean { - return !isHidden(user) && // if user hasn't hided this author - user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post - user.countReportAuthorsBy(followingKeySet()) < 5 - } - - fun isAcceptableDirect(note: Note): Boolean { - return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post - note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users - } - - fun isFollowing(user: User): Boolean { - return user.pubkeyHex in followingKeySet() - } - - fun isAcceptable(note: Note): Boolean { - return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author - isAcceptableDirect(note) && - ( - note.event !is RepostEvent || - (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) - ) // is not a reaction about a blocked post - } - - fun getRelevantReports(note: Note): Set { - val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() - - val innerReports = if (note.event is RepostEvent) { - note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() - } else { - emptyList() - } - - return ( - note.reportsBy(followsPlusMe) + - ( - note.author?.reportsBy(followsPlusMe) ?: emptyList() - ) + innerReports - ).toSet() - } - - fun saveRelayList(value: List) { - localRelays = value.toSet() - sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }) - - saveable.invalidateData() - } - - fun setHideDeleteRequestDialog() { - hideDeleteRequestDialog = true - saveable.invalidateData() - } - - fun setHideBlockAlertDialog() { - hideBlockAlertDialog = true - saveable.invalidateData() - } - - init { - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") - if (userProfile().latestContactList == null) { - LocalCache.consume(it) - } - } - - // Observes relays to restart connections - userProfile().live().relays.observeForever { - GlobalScope.launch(Dispatchers.IO) { - reconnectIfRelaysHaveChanged() - } - } - - // saves contact list for the next time. - userProfile().live().follows.observeForever { - updateContactListTo(userProfile().latestContactList) - } - - // imports transient blocks due to spam. - LocalCache.antiSpam.liveSpam.observeForever { - GlobalScope.launch(Dispatchers.IO) { - it.cache.spamMessages.snapshot().values.forEach { - if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { - val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex) - if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) { - transientHiddenUsers = transientHiddenUsers + it.pubkeyHex - } - } - } - } - } - } -} - -class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) { - if (hasActiveObservers()) { - refresh() - } - } - - fun invalidateData() { - bundler.invalidate() - } - - fun refresh() { - postValue(AccountState(account)) - } -} - -class AccountState(val account: Account) +package com.vitorpamplona.amethyst.model + +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat +import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.relays.Client +import com.vitorpamplona.amethyst.service.relays.Constants +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.Relay +import com.vitorpamplona.amethyst.service.relays.RelayPool +import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.note.Nip47URI +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import nostr.postr.Persona +import java.net.Proxy +import java.util.Locale + +val DefaultChannels = setOf( + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group +) + +fun getLanguagesSpokenByUser(): Set { + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) + val codedList = mutableSetOf() + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { codedList.add(it.language) } + } + return codedList +} + +@OptIn(DelicateCoroutinesApi::class) +class Account( + val loggedIn: Persona, + var followingChannels: Set = DefaultChannels, + var hiddenUsers: Set = setOf(), + var localRelays: Set = Constants.defaultRelays.toSet(), + var dontTranslateFrom: Set = getLanguagesSpokenByUser(), + var languagePreferences: Map = mapOf(), + var translateTo: String = Locale.getDefault().language, + var zapAmountChoices: List = listOf(500L, 1000L, 5000L), + var zapPaymentRequest: Nip47URI? = null, + var hideDeleteRequestDialog: Boolean = false, + var hideBlockAlertDialog: Boolean = false, + var backupContactList: ContactListEvent? = null, + var proxy: Proxy? +) { + var transientHiddenUsers: Set = setOf() + + // Observers line up here. + val live: AccountLiveData = AccountLiveData(this) + val liveLanguages: AccountLiveData = AccountLiveData(this) + val saveable: AccountLiveData = AccountLiveData(this) + + var userProfileCache: User? = null + + fun userProfile(): User { + return userProfileCache ?: run { + val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) + userProfileCache = myUser + myUser + } + } + + fun followingChannels(): List { + return followingChannels.map { LocalCache.getOrCreateChannel(it) } + } + + fun hiddenUsers(): List { + return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } + } + + fun isWriteable(): Boolean { + return loggedIn.privKey != null + } + + fun sendNewRelayList(relays: Map) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val follows = contactList?.follows() ?: emptyList() + val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + if (contactList != null && follows.isNotEmpty()) { + val event = ContactListEvent.create( + follows, + followsTags, + relays, + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } else { + val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!) + + // Keep this local to avoid erasing a good contact list. + // Client.send(event) + LocalCache.consume(event) + } + } + + fun sendNewUserMetadata(toString: String, identities: List) { + if (!isWriteable()) return + + loggedIn.privKey?.let { + val event = MetadataEvent.create(toString, identities, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun reactionTo(note: Note): List { + return note.reactedBy(userProfile(), "+") + } + + fun hasBoosted(note: Note): Boolean { + return boostsTo(note).isNotEmpty() + } + + fun boostsTo(note: Note): List { + return note.boostedBy(userProfile()) + } + + fun hasReacted(note: Note): Boolean { + return note.hasReacted(userProfile(), "+") + } + + fun reactTo(note: Note) { + if (!isWriteable()) return + + if (hasReacted(note)) { + // has already liked this note + return + } + + note.event?.let { + val event = ReactionEvent.createLike(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun createZapRequestFor(note: Note, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? { + if (!isWriteable()) return null + + note.event?.let { event -> + return LnZapRequestEvent.create( + event, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } + ?: localRelays.map { it.url }.toSet(), + loggedIn.privKey!!, + pollOption, + message, + zapType + ) + } + return null + } + + fun hasWalletConnectSetup(): Boolean { + return zapPaymentRequest != null + } + + fun sendZapPaymentRequestFor(lnInvoice: String) { + if (!isWriteable()) return + + zapPaymentRequest?.let { + val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) + + Client.send(event, it.relayUri) + } + } + + fun createZapRequestFor(user: User): LnZapRequestEvent? { + return createZapRequestFor(user) + } + + fun createZapRequestFor(userPubKeyHex: String, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? { + if (!isWriteable()) return null + + return LnZapRequestEvent.create( + userPubKeyHex, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), + loggedIn.privKey!!, + message, + zapType + ) + } + + fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { + if (!isWriteable()) return + + if (note.hasReacted(userProfile(), "⚠️")) { + // has already liked this note + return + } + + note.event?.let { + val event = ReactionEvent.createWarning(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + + note.event?.let { + val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content) + Client.send(event) + LocalCache.consume(event, null) + } + } + + fun report(user: User, type: ReportEvent.ReportType) { + if (!isWriteable()) return + + if (user.hasReport(userProfile(), type)) { + // has already reported this note + return + } + + val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event, null) + } + + fun delete(note: Note) { + delete(listOf(note)) + } + + fun delete(notes: List) { + if (!isWriteable()) return + + val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex } + + if (myNotes.isNotEmpty()) { + val event = DeletionEvent.create(myNotes, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun boost(note: Note) { + if (!isWriteable()) return + + if (note.hasBoostedInTheLast5Minutes(userProfile())) { + // has already bosted in the past 5mins + return + } + + note.event?.let { + val event = RepostEvent.create(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun broadcast(note: Note) { + note.event?.let { + Client.send(it) + } + } + + fun follow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val followingUsers = contactList?.follows() ?: emptyList() + val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + val event = if (contactList != null) { + ContactListEvent.create( + followingUsers.plus(Contact(user.pubkeyHex, null)), + followingTags, + contactList.relays(), + loggedIn.privKey!! + ) + } else { + val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } + ContactListEvent.create( + listOf(Contact(user.pubkeyHex, null)), + followingTags, + relays, + loggedIn.privKey!! + ) + } + + Client.send(event) + LocalCache.consume(event) + } + + fun follow(tag: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val followingUsers = contactList?.follows() ?: emptyList() + val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + val event = if (contactList != null) { + ContactListEvent.create( + followingUsers, + followingTags.plus(tag), + contactList.relays(), + loggedIn.privKey!! + ) + } else { + val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } + ContactListEvent.create( + followingUsers, + followingTags.plus(tag), + relays, + loggedIn.privKey!! + ) + } + + Client.send(event) + LocalCache.consume(event) + } + + fun unfollow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val followingUsers = contactList?.follows() ?: emptyList() + val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) { + val event = ContactListEvent.create( + followingUsers.filter { it.pubKeyHex != user.pubkeyHex }, + followingTags, + contactList.relays(), + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + } + + fun unfollow(tag: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val followingUsers = contactList?.follows() ?: emptyList() + val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) { + val event = ContactListEvent.create( + followingUsers, + followingTags.filter { !it.equals(tag, ignoreCase = true) }, + contactList.relays(), + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + } + + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + val signedEvent = TextNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + extraTags = tags, + privateKey = loggedIn.privKey!! + ) + + Client.send(signedEvent) + LocalCache.consume(signedEvent) + } + + fun sendPoll( + message: String, + replyTo: List?, + mentions: List?, + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int? + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + val signedEvent = PollNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + privateKey = loggedIn.privKey!!, + pollOptions = pollOptions, + valueMaximum = valueMaximum, + valueMinimum = valueMinimum, + consensusThreshold = consensusThreshold, + closedAt = closedAt + ) + // println("Sending new PollNoteEvent: %s".format(signedEvent.toJson())) + Client.send(signedEvent) + LocalCache.consume(signedEvent) + } + + fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?) { + if (!isWriteable()) return + + // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + + val signedEvent = ChannelMessageEvent.create( + message = message, + channel = toChannel, + replyTos = repliesToHex, + mentions = mentionsHex, + privateKey = loggedIn.privKey!! + ) + Client.send(signedEvent) + LocalCache.consume(signedEvent, null) + } + + fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?) { + if (!isWriteable()) return + val user = LocalCache.users[toUser] ?: return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = mentions?.map { it.pubkeyHex } + + val signedEvent = PrivateDmEvent.create( + recipientPubKey = user.pubkey(), + publishedRecipientPubKey = user.pubkey(), + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + privateKey = loggedIn.privKey!!, + advertiseNip18 = false + ) + Client.send(signedEvent) + LocalCache.consume(signedEvent, null) + } + + fun sendCreateNewChannel(name: String, about: String, picture: String) { + if (!isWriteable()) return + + val metadata = ChannelCreateEvent.ChannelData( + name, + about, + picture + ) + + val event = ChannelCreateEvent.create( + channelInfo = metadata, + privateKey = loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + + joinChannel(event.id) + } + + fun addPrivateBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun addPublicBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun removePrivateBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun removePublicBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents()?.minus(note.idHex), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun isInPrivateBookmarks(note: Note): Boolean { + if (!isWriteable()) return false + + if (note is AddressableNote) { + return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!) + ?.contains(note.address) == true + } else { + return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!) + ?.contains(note.idHex) == true + } + } + + fun isInPublicBookmarks(note: Note): Boolean { + if (!isWriteable()) return false + + if (note is AddressableNote) { + return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true + } else { + return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true + } + } + + fun joinChannel(idHex: String) { + followingChannels = followingChannels + idHex + live.invalidateData() + + saveable.invalidateData() + } + + fun leaveChannel(idHex: String) { + followingChannels = followingChannels - idHex + live.invalidateData() + + saveable.invalidateData() + } + + fun hideUser(pubkeyHex: String) { + hiddenUsers = hiddenUsers + pubkeyHex + live.invalidateData() + saveable.invalidateData() + } + + fun showUser(pubkeyHex: String) { + hiddenUsers = hiddenUsers - pubkeyHex + transientHiddenUsers = transientHiddenUsers - pubkeyHex + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapAmounts(newAmounts: List) { + zapAmountChoices = newAmounts + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapPaymentRequest(newServer: Nip47URI?) { + zapPaymentRequest = newServer + live.invalidateData() + saveable.invalidateData() + } + + fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { + if (!isWriteable()) return + + val metadata = ChannelCreateEvent.ChannelData( + name, + about, + picture + ) + + val event = ChannelMetadataEvent.create( + newChannelInfo = metadata, + originalChannelIdHex = channel.idHex, + privateKey = loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + + joinChannel(event.id) + } + + fun decryptContent(note: Note): String? { + val event = note.event + return if (event is PrivateDmEvent && loggedIn.privKey != null) { + var pubkeyToUse = event.pubKey + + val recepientPK = event.recipientPubKey() + + if (note.author == userProfile() && recepientPK != null) { + pubkeyToUse = recepientPK + } + + event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) + } else { + event?.content() + } + } + + fun addDontTranslateFrom(languageCode: String) { + dontTranslateFrom = dontTranslateFrom.plus(languageCode) + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun updateTranslateTo(languageCode: String) { + translateTo = languageCode + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun prefer(source: String, target: String, preference: String) { + languagePreferences = languagePreferences + Pair("$source,$target", preference) + saveable.invalidateData() + } + + fun preferenceBetween(source: String, target: String): String? { + return languagePreferences.get("$source,$target") + } + + private fun updateContactListTo(newContactList: ContactListEvent?) { + if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupContactList?.id != newContactList?.id) { + backupContactList = newContactList + saveable.invalidateData() + } + } + + // Takes a User's relay list and adds the types of feeds they are active for. + fun activeRelays(): Array? { + var usersRelayList = userProfile().latestContactList?.relays()?.map { + val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet() + Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy) + } ?: return null + + // Ugly, but forces nostr.band as the only search-supporting relay today. + // TODO: Remove when search becomes more available. + if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) { + usersRelayList = usersRelayList + Relay( + Constants.forcedRelayForSearch.url, + Constants.forcedRelayForSearch.read, + Constants.forcedRelayForSearch.write, + Constants.forcedRelayForSearch.feedTypes, + proxy + ) + } + + return usersRelayList.toTypedArray() + } + + fun convertLocalRelays(): Array { + return localRelays.map { + Relay(it.url, it.read, it.write, it.feedTypes, proxy) + }.toTypedArray() + } + + fun reconnectIfRelaysHaveChanged() { + val newRelaySet = activeRelays() ?: convertLocalRelays() + if (!Client.isSameRelaySetConfig(newRelaySet)) { + Client.disconnect() + Client.connect(newRelaySet) + RelayPool.requestAndWatch() + } + } + + fun isHidden(user: User) = isHidden(user.pubkeyHex) + fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers + + fun followingKeySet(): Set { + return userProfile().cachedFollowingKeySet() ?: emptySet() + } + + fun followingTagSet(): Set { + return userProfile().cachedFollowingTagSet() ?: emptySet() + } + + fun isAcceptable(user: User): Boolean { + return !isHidden(user) && // if user hasn't hided this author + user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + user.countReportAuthorsBy(followingKeySet()) < 5 + } + + fun isAcceptableDirect(note: Note): Boolean { + return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users + } + + fun isFollowing(user: User): Boolean { + return user.pubkeyHex in followingKeySet() + } + + fun isAcceptable(note: Note): Boolean { + return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author + isAcceptableDirect(note) && + ( + note.event !is RepostEvent || + (note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) + ) // is not a reaction about a blocked post + } + + fun getRelevantReports(note: Note): Set { + val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() + + val innerReports = if (note.event is RepostEvent) { + note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() + } else { + emptyList() + } + + return ( + note.reportsBy(followsPlusMe) + + ( + note.author?.reportsBy(followsPlusMe) ?: emptyList() + ) + innerReports + ).toSet() + } + + fun saveRelayList(value: List) { + localRelays = value.toSet() + sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }) + + saveable.invalidateData() + } + + fun setHideDeleteRequestDialog() { + hideDeleteRequestDialog = true + saveable.invalidateData() + } + + fun setHideBlockAlertDialog() { + hideBlockAlertDialog = true + saveable.invalidateData() + } + + init { + backupContactList?.let { + println("Loading saved contacts ${it.toJson()}") + if (userProfile().latestContactList == null) { + LocalCache.consume(it) + } + } + + // Observes relays to restart connections + userProfile().live().relays.observeForever { + GlobalScope.launch(Dispatchers.IO) { + reconnectIfRelaysHaveChanged() + } + } + + // saves contact list for the next time. + userProfile().live().follows.observeForever { + updateContactListTo(userProfile().latestContactList) + } + + // imports transient blocks due to spam. + LocalCache.antiSpam.liveSpam.observeForever { + GlobalScope.launch(Dispatchers.IO) { + it.cache.spamMessages.snapshot().values.forEach { + if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { + val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex) + if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) { + transientHiddenUsers = transientHiddenUsers + it.pubkeyHex + } + } + } + } + } + } +} + +class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.Default) { + if (hasActiveObservers()) { + refresh() + } + } + + fun invalidateData() { + bundler.invalidate() + } + + fun refresh() { + postValue(AccountState(account)) + } +} + +class AccountState(val account: Account) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt index c50ff57816..7e16f77d80 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt @@ -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) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 90e260cd27..ee46362889 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -4,30 +4,9 @@ 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 com.vitorpamplona.amethyst.ui.components.BundledInsert import fr.acinq.secp256k1.Hex import kotlinx.coroutines.* import nostr.postr.toNpub @@ -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() - val notes = ConcurrentHashMap() + val users = ConcurrentHashMap(5000) + val notes = ConcurrentHashMap(5000) val channels = ConcurrentHashMap() - val addressables = ConcurrentHashMap() + val addressables = ConcurrentHashMap(100) fun checkGetOrCreateUser(key: String): User? { if (isValidHexNpub(key)) { @@ -208,7 +189,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } fun consume(event: LongTextNoteEvent, relay: Relay?) { @@ -237,10 +218,46 @@ object LocalCache { author.addNote(note) - refreshObservers() + refreshObservers(note) } } + 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(note) + } + fun consume(event: BadgeDefinitionEvent) { val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) @@ -251,7 +268,7 @@ object LocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } @@ -269,8 +286,6 @@ object LocalCache { note.loadEvent(event, author, replyTo) author.updateAcceptedBadges(note) - - refreshObservers() } } @@ -292,7 +307,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } @Suppress("UNUSED_PARAMETER") @@ -336,7 +351,7 @@ object LocalCache { recipient.addMessage(author, note) } - refreshObservers() + refreshObservers(note) } fun consume(event: DeletionEvent) { @@ -384,7 +399,7 @@ object LocalCache { } if (deletedAtLeastOne) { - live.invalidateData() + // refreshObservers() } } @@ -410,7 +425,7 @@ object LocalCache { it.addBoost(note) } - refreshObservers() + refreshObservers(note) } fun consume(event: ReactionEvent) { @@ -448,6 +463,8 @@ object LocalCache { it.addReport(note) } } + + refreshObservers(note) } fun consume(event: ReportEvent, relay: Relay?) { @@ -478,6 +495,8 @@ object LocalCache { repliesTo.forEach { it.addReport(note) } + + refreshObservers(note) } fun consume(event: ChannelCreateEvent) { @@ -494,7 +513,7 @@ object LocalCache { oldChannel.addNote(note) note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } @@ -514,7 +533,7 @@ object LocalCache { oldChannel.addNote(note) note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } else { // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") @@ -548,9 +567,9 @@ object LocalCache { 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) @@ -561,7 +580,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } @Suppress("UNUSED_PARAMETER") @@ -578,15 +597,14 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) } + val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) } val author = getOrCreateUser(event.pubKey) val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + ( - (zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses() - ?.map { getOrCreateAddressableNote(it) } ?: emptySet() + (zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet() ) note.loadEvent(event, author, repliesTo) @@ -604,6 +622,8 @@ object LocalCache { mentions.forEach { it.addZap(zapRequest, note) } + + refreshObservers(note) } fun consume(event: LnZapRequestEvent) { @@ -627,6 +647,8 @@ object LocalCache { mentions.forEach { it.addZap(note, null) } + + refreshObservers(note) } fun findUsersStartingWith(username: String): List { @@ -640,6 +662,7 @@ object LocalCache { fun findNotesStartingWith(text: String): List { 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) @@ -732,30 +755,23 @@ object LocalCache { } // Observers line up here. - val live: LocalCacheLiveData = LocalCacheLiveData(this) + val live: LocalCacheLiveData = LocalCacheLiveData() - private fun refreshObservers() { - live.invalidateData() + private fun refreshObservers(newNote: Note) { + live.invalidateData(newNote) } } -class LocalCacheLiveData(val cache: LocalCache) : - LiveData(LocalCacheState(cache)) { +class LocalCacheLiveData : LiveData>(setOf()) { // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Main) { - if (hasActiveObservers()) { - refresh() + private val bundler = BundledInsert(300, Dispatchers.Main) + + fun invalidateData(newNote: Note) { + bundler.invalidateList(newNote) { bundledNewNotes -> + if (hasActiveObservers()) { + postValue(bundledNewNotes) + } } } - - fun invalidateData() { - bundler.invalidate() - } - - private fun refresh() { - postValue(LocalCacheState(cache)) - } } - -class LocalCacheState(val cache: LocalCache) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index d8f633116d..7bf4537f7e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -53,13 +53,14 @@ open class Note(val idHex: String) { open fun idNote() = id().toNote() open fun idDisplayNote() = idNote().toShortenHex() - fun channel(): Channel? { - val channelHex = - (event as? ChannelMessageEvent)?.channel() - ?: (event as? ChannelMetadataEvent)?.channel() - ?: (event as? ChannelCreateEvent)?.let { it.id } + fun channelHex(): HexKey? { + return (event as? ChannelMessageEvent)?.channel() + ?: (event as? ChannelMetadataEvent)?.channel() + ?: (event as? ChannelCreateEvent)?.id + } - return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) } + fun channel(): Channel? { + return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) } } open fun address(): ATag? = null @@ -194,15 +195,15 @@ open class Note(val idHex: String) { fun isZappedBy(user: User): Boolean { // Zaps who the requester was the user - return zaps.any { it.key.author == user } + return zaps.any { it.key.author === user } } fun isReactedBy(user: User): Boolean { - return reactions.any { it.author == user } + return reactions.any { it.author === user } } fun isBoostedBy(user: User): Boolean { - return boosts.any { it.author == user } + return boosts.any { it.author === user } } fun reportsBy(user: User): Set { @@ -270,45 +271,6 @@ open class Note(val idHex: String) { ) } - fun directlyCiteUsersHex(): Set { - val matcher = tagSearch.matcher(event?.content() ?: "") - val returningList = mutableSetOf() - while (matcher.find()) { - try { - val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) } - if (tag != null && tag[0] == "p") { - returningList.add(tag[1]) - } - } catch (e: Exception) { - } - } - return returningList - } - - fun directlyCiteUsers(): Set { - val matcher = tagSearch.matcher(event?.content() ?: "") - val returningList = mutableSetOf() - while (matcher.find()) { - try { - val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) } - if (tag != null && tag[0] == "p") { - LocalCache.checkGetOrCreateUser(tag[1])?.let { - returningList.add(it) - } - } - } catch (e: Exception) { - } - } - return returningList - } - - fun directlyCites(userProfile: User): Boolean { - return author == userProfile || - (userProfile in directlyCiteUsers()) || - (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) || - (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) - } - fun isNewThread(): Boolean { return event is RepostEvent || replyTo == null || replyTo?.size == 0 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 909a4d7050..476f4ef7d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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(user)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 9317680eb3..cb867066c0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index b2b4e851f8..6d4fbadf99 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -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 @@ -75,7 +76,7 @@ abstract class NostrDataSource(val debugName: String) { is DeletionEvent -> LocalCache.consume(event) is LnZapEvent -> { - event.containedPost()?.let { onEvent(it, subscriptionId, relay) } + event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) } is LnZapRequestEvent -> LocalCache.consume(event) @@ -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()) } @@ -152,7 +154,7 @@ abstract class NostrDataSource(val debugName: String) { // Refreshes observers in batches. private val bundler = BundledUpdate(250, Dispatchers.IO) { - println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt index 4a8830deae..dbcb987e63 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -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 ) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 7b3428a687..b07eab060b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index c481131cde..fda096ec5e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -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 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index d4dcccc88c..29f6cf38cd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -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, PrivateDmEvent.kind ), ids = interestedEvents.toList() ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 7ce4626eab..a4ad8a5fcc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -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 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index 4583e39d82..031c2b1cf7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -13,7 +13,27 @@ 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) } + + private var citedUsersCache: Set? = null + + fun citedUsers(): Set { + citedUsersCache?.let { return it } + + val matcher = tagSearch.matcher(content) + val returningList = mutableSetOf() + while (matcher.find()) { + try { + val tag = matcher.group(1)?.let { tags[it.toInt()] } + if (tag != null && tag.size > 1 && tag[0] == "p") { + returningList.add(tag[1]) + } + } catch (e: Exception) { + } + } + citedUsersCache = returningList + return returningList + } fun findCitations(): Set { var citations = mutableSetOf() @@ -22,10 +42,10 @@ open class BaseTextNoteEvent( while (matcher.find()) { try { val tag = matcher.group(1)?.let { tags[it.toInt()] } - if (tag != null && tag[0] == "e") { + if (tag != null && tag.size > 1 && tag[0] == "e") { citations.add(tag[1]) } - if (tag != null && tag[0] == "a") { + if (tag != null && tag.size > 1 && tag[0] == "a") { citations.add(tag[1]) } } catch (e: Exception) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt index 76a05c6cd5..e2db58fbdf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt @@ -12,11 +12,10 @@ class ChannelMessageEvent( tags: List>, 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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 9b7e232aec..818a2d3b24 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -38,23 +38,23 @@ open class Event( override fun toJson(): String = gson.toJson(this) - fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } - fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { - val aTagValue = it.getOrNull(1) + fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { + val aTagValue = it[1] val relay = it.getOrNull(2) - if (aTagValue != null) ATag.parse(aTagValue, relay) else null + ATag.parse(aTagValue, relay) } - override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } + override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] } - override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex } + override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex } - override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) } - override fun isTaggedHashes(hashtags: Set) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags } - override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1) + override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } + override fun isTaggedHashes(hashtags: Set) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags } + override fun firstIsTaggedHashes(hashtags: Set) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1) override fun getPoWRank(): Int { var rank = 0 @@ -79,7 +79,7 @@ open class Event( override fun getReward(): BigDecimal? { return try { - tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull() + tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull() } catch (e: Exception) { null } @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt index 07194a0bf5..709a7dacd1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -24,11 +24,11 @@ interface EventInterface { fun hasValidSignature(): Boolean - fun isTaggedUser(loggedInUser: String): Boolean + fun isTaggedUser(idHex: String): Boolean fun isTaggedHash(hashtag: String): Boolean - fun isTaggedHashes(hashtag: Set): Boolean - fun firstIsTaggedHashes(hashtag: Set): String? + fun isTaggedHashes(hashtags: Set): Boolean + fun firstIsTaggedHashes(hashtags: Set): String? fun hashtags(): List fun getReward(): BigDecimal? diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index f1796fe9a7..5f08c6a6e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -1,62 +1,75 @@ -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>, - 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()?.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 - } -} +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 + +class LnZapEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { + // This event is also kept in LocalCache (same object) + @Transient val zapRequest: LnZapRequestEvent? + + private fun containedPost(): LnZapRequestEvent? = try { + description()?.ifBlank { null }?.let { + fromJson(it, Client.lenient) + } as? LnZapRequestEvent + } catch (e: Exception) { + Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) + null + } + + init { + zapRequest = containedPost() + } + + override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + + override fun zappedPollOption(): Int? = try { + zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt() + } catch (e: Exception) { + Log.e("LnZapEvent", "ZappedPollOption failed to parse", e) + null + } + + override fun zappedRequestAuthor(): String? = zapRequest?.pubKey() + + override fun amount() = 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 content + } + + private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) + + private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) + + companion object { + const val kind = 9735 + } + + enum class ZapType() { + PUBLIC, + PRIVATE, // not yet implemented + ANONYMOUS, + NONZAP + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt index cc95d34b32..ada0d609d3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt @@ -6,11 +6,15 @@ interface LnZapEventInterface : EventInterface { fun zappedPost(): List + fun zappedPollOption(): Int? + fun zappedAuthor(): List + fun zappedRequestAuthor(): String? + fun taggedAddresses(): List fun amount(): BigDecimal? - fun containedPost(): Event? + fun message(): String } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 23dc9d6b68..eba7a18a9f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -1,97 +1,121 @@ -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>, - 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, - 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, - 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>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + + companion object { + const val kind = 9734 + + fun create( + originalNote: EventInterface, + relays: Set, + privateKey: ByteArray, + pollOption: Int?, + message: String, + zapType: LnZapEvent.ZapType, + createdAt: Long = Date().time / 1000 + ): LnZapRequestEvent { + val content = message + var privkey = privateKey + var 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())) + } + if (zapType == LnZapEvent.ZapType.ANONYMOUS) { + tags = tags + listOf(listOf("anon", "")) + privkey = Utils.privkeyCreate() + pubKey = Utils.pubkeyCreate(privkey).toHexKey() + } + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privkey) + return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + + fun create( + userHex: String, + relays: Set, + privateKey: ByteArray, + message: String, + zapType: LnZapEvent.ZapType, + createdAt: Long = Date().time / 1000 + ): LnZapRequestEvent { + val content = message + var privkey = privateKey + var pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + var tags = listOf( + listOf("p", userHex), + listOf("relays") + relays + ) + if (zapType == LnZapEvent.ZapType.ANONYMOUS) { + tags = tags + listOf(listOf("anon", "")) + privkey = Utils.privkeyCreate() + pubKey = Utils.pubkeyCreate(privkey).toHexKey() + } + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privkey) + 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": // TODO +} +*/ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt new file mode 100644 index 0000000000..c91cd4c7a3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt @@ -0,0 +1,99 @@ +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>, + // 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() = + tags.filter { it.size > 2 && it[0] == POLL_OPTION } + .associate { it[1].toInt() to it[2] } + + fun getTagInt(property: String): Int? { + val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1) + + return if (number.isNullOrBlank() || number == "null") { + null + } else { + number.toInt() + } + } + + companion object { + const val kind = 6969 + + fun create( + msg: String, + replyTos: List?, + mentions: List?, + addresses: List?, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000, + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int? + ): PollNoteEvent { + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = mutableListOf>() + 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": , + "kind": 6969, + "tags": [ + ["e", <32-bytes hex of the id of the poll event>, ], + ["p", <32-bytes hex of the key>, ], + ["poll_option", "0", "poll option 0 description string"], + ["poll_option", "1", "poll option 1 description string"], + ["poll_option", "n", "poll option 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": + "content": , + "sig": <64-bytes hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field> +} + */ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index 89e1d98e67..c820d0ca2f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -21,7 +21,7 @@ class PrivateDmEvent( * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * for initial messages. */ - fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one + fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one /** * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. @@ -29,7 +29,7 @@ class PrivateDmEvent( * Nip-18 messages should refer to other events by inline references in the content like * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). */ - fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) + fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? { return try { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index f89bb29775..13188520c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -14,8 +14,8 @@ class ReactionEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } companion object { const val kind = 7 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt index 42f33c3c52..d3bd9dd97a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt @@ -30,7 +30,7 @@ class ReportEvent( } fun reportedPost() = tags - .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } + .filter { it.size > 1 && it[0] == "e" } .map { ReportedKey( it[1], @@ -39,7 +39,7 @@ class ReportEvent( } fun reportedAuthor() = tags - .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } + .filter { it.size > 1 && it[0] == "p" } .map { ReportedKey( it[1], diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt index b4bdda2039..87be07093c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index 6f841deb65..b1ef135a9b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -76,7 +76,8 @@ object Client : RelayPool.Listener { } } else { /** temporary connection */ - Relay(relay, false, true, emptySet()).requestAndWatch() { + /** TODO: set the proxy for this temporary connection */ + Relay(relay, false, true, emptySet(), null).requestAndWatch() { it.send(signedEvent) it.disconnect() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt index a167a2d99f..9f5477687a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt @@ -10,7 +10,8 @@ object Constants { fun convertDefaultRelays(): Array { return defaultRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) + /** TODO: set the proxy */ + Relay(it.url, it.read, it.write, it.feedTypes, null) }.toTypedArray() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 87407844b2..1084f21c95 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -10,6 +10,7 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.net.Proxy import java.util.Date enum class FeedType { @@ -20,9 +21,11 @@ class Relay( var url: String, var read: Boolean = true, var write: Boolean = true, - var activeTypes: Set = FeedType.values().toSet() + var activeTypes: Set = FeedType.values().toSet(), + proxy: Proxy? ) { private val httpClient = OkHttpClient.Builder() + .proxy(proxy) .followRedirects(true) .followSslRedirects(true) .build() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt new file mode 100644 index 0000000000..516ac6e52e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -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?, var replyTos: List?, 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") + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt new file mode 100644 index 0000000000..c6d1d1f3e5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt @@ -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()) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt new file mode 100644 index 0000000000..d4e4d22bbb --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt @@ -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()) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt new file mode 100644 index 0000000000..876134cff8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -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) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt new file mode 100644 index 0000000000..685eb2e8ff --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt @@ -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) + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt new file mode 100644 index 0000000000..b84ed0480d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt @@ -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) + ) + } + + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt new file mode 100644 index 0000000000..12977d096a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt @@ -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())) +}*/ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt new file mode 100644 index 0000000000..ee6a3f4fec --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt @@ -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()) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 8ef31d6983..213573b225 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -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.CurrencyBitcoin 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 @@ -40,6 +45,7 @@ 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 com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.delay @OptIn(ExperimentalComposeUiApi::class) @@ -104,8 +110,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,6 +166,48 @@ 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)) { @@ -217,11 +264,25 @@ 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) { + val hashtag = stringResource(R.string.poll_hashtag) + AddPollButton(postViewModel.wantsPoll) { + postViewModel.wantsPoll = !postViewModel.wantsPoll + postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag) + } + } + + if (postViewModel.canAddInvoice) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } } } } @@ -229,6 +290,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.CurrencyBitcoin, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.onBackground + ) + } else { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + null, + modifier = Modifier.size(20.dp), + tint = BitcoinOrange + ) + } + } +} + @Composable fun CloseButton(onCancel: () -> Unit) { Button( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 739ca540d0..8be28a4913 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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?>(null) var replyTos by mutableStateOf?>(null) @@ -34,7 +37,27 @@ class NewPostViewModel : ViewModel() { var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null - fun load(account: Account, replyingTo: Note?, quote: Note?) { + // Polls + var canUsePoll by mutableStateOf(false) + var wantsPoll by mutableStateOf(false) + var zapRecipients = mutableStateListOf() + 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 canAddInvoice by mutableStateOf(false) + 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) @@ -49,7 +72,7 @@ class NewPostViewModel : ViewModel() { this.mentions = currentMentions.plus(replyUser) } } - } ?: { + } ?: run { replyTos = null mentions = null } @@ -58,88 +81,27 @@ class NewPostViewModel : ViewModel() { message = TextFieldValue(message.text + "\n\n@${it.idNote()}") } + canAddInvoice = account.userProfile().info?.lnAddress() != null + canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null + 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 +128,24 @@ class NewPostViewModel : ViewModel() { ) } - fun cancel() { + open fun cancel() { message = TextFieldValue("") urlPreview = null isUploadingImage = false mentions = null + + wantsPoll = false + zapRecipients = mutableStateListOf() + 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 +153,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 +172,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 +186,26 @@ class NewPostViewModel : ViewModel() { userSuggestions = emptyList() } } + + private fun newStateMapPollOptions(): SnapshotStateMap { + return mutableStateMapOf(Pair(0, ""), Pair(1, "")) + } + + fun canPost(): Boolean { + return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && + (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) + } + + fun includePollHashtagInMessage(include: Boolean, hashtag: String) { + if (include) { + updateMessage(TextFieldValue(message.text + " $hashtag")) + } else { + updateMessage( + TextFieldValue( + message.text.replace(" $hashtag", "") + .replace(hashtag, "") + ) + ) + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt new file mode 100644 index 0000000000..1a0715ef29 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt @@ -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) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt index b16534b28f..ece9c89e9b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index d2172e560d..ea6dff88f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference /** * This class is designed to have a waiting time between two calls of invalidate @@ -44,3 +45,37 @@ class BundledUpdate( } } } + +/** + * This class is designed to have a waiting time between two calls of invalidate + */ +class BundledInsert( + val delay: Long, + val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { + private var onlyOneInBlock = AtomicBoolean() + private var atomicSet = AtomicReference>(setOf()) + + fun invalidateList(newObject: T, onUpdate: (Set) -> Unit) { + atomicSet.updateAndGet() { + it + newObject + } + + if (onlyOneInBlock.getAndSet(true)) { + return + } + + val scope = CoroutineScope(Job() + dispatcher) + scope.launch { + try { + onUpdate(atomicSet.getAndSet(emptySet())) + delay(delay) + onUpdate(atomicSet.getAndSet(emptySet())) + } finally { + withContext(NonCancellable) { + onlyOneInBlock.set(false) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt index bb3c99d0a3..8962fc219d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableNoteTag.kt @@ -11,12 +11,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex @Composable fun ClickableNoteTag( - baesNote: Note, + baseNote: Note, navController: NavController ) { ClickableText( - text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"), - onClick = { navController.navigate("Note/${baesNote.idHex}") }, + text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"), + onClick = { navController.navigate("Note/${baseNote.idHex}") }, style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 418e25b45a..51a335c3e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -5,10 +5,34 @@ import android.net.Uri import androidx.compose.foundation.text.ClickableText import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable +import androidx.compose.material.Text +import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextDirection import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun MayBeWithdrawal(lnurlWord: String) { + var lnWithdrawal by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = lnurlWord) { + withContext(Dispatchers.IO) { + lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord) + } + } + + lnWithdrawal?.let { + ClickableWithdrawal(withdrawalString = it) + } + ?: Text( + text = "$lnurlWord ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) +} @Composable fun ClickableWithdrawal(withdrawalString: String) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index f945164000..d86f81c55b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -9,13 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.material.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,22 +19,48 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDirection 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.service.lnurl.LnInvoiceUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigDecimal import java.text.NumberFormat @Composable -fun InvoicePreview(lnInvoice: String) { - val amount = try { - LnInvoiceUtil.getAmountInSats(lnInvoice) - } catch (e: Exception) { - e.printStackTrace() - null +fun MayBeInvoicePreview(lnbcWord: String) { + var lnInvoice by remember { mutableStateOf?>(null) } + + LaunchedEffect(key1 = lnbcWord) { + withContext(Dispatchers.IO) { + val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) + if (myInvoice != null) { + val myInvoiceAmount = try { + LnInvoiceUtil.getAmountInSats(myInvoice) + } catch (e: Exception) { + e.printStackTrace() + null + } + + lnInvoice = Pair(myInvoice, myInvoiceAmount) + } + } } + lnInvoice?.let { + InvoicePreview(it.first, it.second) + } + ?: Text( + text = "$lnbcWord ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) +} + +@Composable +fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) { val context = LocalContext.current Column( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index 2fff33f834..06e38c773c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -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,22 @@ 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 com.vitorpamplona.amethyst.service.model.LnZapEvent 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 +79,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) @@ -130,20 +136,14 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC Button( modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), onClick = { - val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message) + val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, LnZapEvent.ZapType.PUBLIC) LightningAddressResolver().lnAddressInvoice( lud16, 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 +159,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) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 1dc0ce1548..474a1fea70 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.components import android.util.Log import android.util.Patterns -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* @@ -14,7 +13,7 @@ import androidx.compose.material.Icon import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -34,12 +33,15 @@ import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material.MaterialRichText import com.halilibo.richtext.ui.resolveDefaults import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon -import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL @@ -80,47 +82,47 @@ fun RichTextViewer( accountViewModel: AccountViewModel, navController: NavController ) { - val myMarkDownStyle = richTextDefaults.copy( - codeBlockStyle = richTextDefaults.codeBlockStyle?.copy( - textStyle = TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp - ), - modifier = Modifier - .padding(0.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ) - .background( - MaterialTheme.colors.onSurface - .copy(alpha = 0.05f) - .compositeOver(backgroundColor) - ) - ), - stringStyle = richTextDefaults.stringStyle?.copy( - linkStyle = SpanStyle( - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colors.primary - ), - codeStyle = SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor) - ) - ) - ) - - Column(modifier = modifier.animateContentSize()) { + Column(modifier = modifier) { if (content.startsWith("# ") || content.contains("##") || content.contains("**") || content.contains("__") || content.contains("```") ) { + val myMarkDownStyle = richTextDefaults.copy( + codeBlockStyle = richTextDefaults.codeBlockStyle?.copy( + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ), + modifier = Modifier + .padding(0.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ) + .background( + MaterialTheme.colors.onSurface + .copy(alpha = 0.05f) + .compositeOver(backgroundColor) + ) + ), + stringStyle = richTextDefaults.stringStyle?.copy( + linkStyle = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colors.primary + ), + codeStyle = SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor) + ) + ) + ) + MaterialRichText( style = myMarkDownStyle ) { @@ -164,31 +166,21 @@ fun RichTextViewer( UrlPreview(word, "$word ") } } 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) - ) - } + MayBeInvoicePreview(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) - ) - } + MayBeWithdrawal(word) } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { ClickableEmail(word) } 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( @@ -239,7 +231,13 @@ fun RichTextViewer( } 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( @@ -285,45 +283,95 @@ private fun isArabic(text: String): Boolean { } fun isBechLink(word: String): Boolean { - val cleaned = word.removePrefix("@").removePrefix("nostr:").removePrefix("@").take(7).lowercase() + val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } } @Composable -fun BechLink(word: String, navController: NavController) { - val nip19Route = Nip19.uriToRoute(word) +fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) { + var nip19Route by remember { mutableStateOf(null) } + var baseNotePair by remember { mutableStateOf?>(null) } - if (nip19Route == null) { - Text(text = "$word ") + LaunchedEffect(key1 = word) { + withContext(Dispatchers.IO) { + Nip19.uriToRoute(word)?.let { + if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { + LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> + baseNotePair = Pair(note, it.additionalChars) + } + } else { + nip19Route = it + } + } + } + } + + if (canPreview) { + baseNotePair?.let { + NoteCompose( + baseNote = it.first, + 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 + ) + Text( + "${it.second} " + ) + } ?: nip19Route?.let { + ClickableRoute(it, navController) + } ?: Text(text = "$word ") } else { - ClickableRoute(nip19Route, navController) + nip19Route?.let { + ClickableRoute(it, navController) + } ?: Text(text = "$word ") } } @Composable fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) { - val hashtagMatcher = hashTagsPattern.matcher(word) + var tagSuffixPair by remember { mutableStateOf?>(null) } - val (tag, suffix) = try { - hashtagMatcher.find() - Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) - } catch (e: Exception) { - Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) - Pair(null, null) + LaunchedEffect(key1 = word) { + withContext(Dispatchers.IO) { + val hashtagMatcher = hashTagsPattern.matcher(word) + + val (myTag, mySuffix) = try { + hashtagMatcher.find() + Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) + } catch (e: Exception) { + Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) + Pair(null, null) + } + + if (myTag != null) { + tagSuffixPair = Pair(myTag, mySuffix) + } + } } - if (tag != null) { - val hashtagIcon = checkForHashtagWithIcon(tag) + tagSuffixPair?.let { tagPair -> + val hashtagIcon = checkForHashtagWithIcon(tagPair.first) ClickableText( text = buildAnnotatedString { withStyle( LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() ) { - append("#$tag") + append("#${tagPair.first}") } }, - onClick = { navController.navigate("Hashtag/$tag") } + onClick = { navController.navigate("Hashtag/${tagPair.first}") } ) if (hashtagIcon != null) { @@ -346,14 +394,12 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav placeholderVerticalAlign = PlaceholderVerticalAlign.Center ) ) { - if (hashtagIcon != null) { - Icon( - painter = painterResource(hashtagIcon.icon), - contentDescription = hashtagIcon.description, - tint = hashtagIcon.color, - modifier = hashtagIcon.modifier - ) - } + Icon( + painter = painterResource(hashtagIcon.icon), + contentDescription = hashtagIcon.description, + tint = hashtagIcon.color, + modifier = hashtagIcon.modifier + ) } ) ) @@ -364,69 +410,80 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav inlineContent = inlineContent ) } - Text(text = "$suffix ") - } else { - Text(text = "$word ") - } + tagPair.second?.ifBlank { "" }?.let { + Text(text = "$it ") + } + } ?: Text(text = "$word ") } @Composable fun TagLink(word: String, tags: List>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) { - val matcher = tagIndex.matcher(word) + var baseUserPair by remember { mutableStateOf?>(null) } + var baseNotePair by remember { mutableStateOf?>(null) } - val (index, extraCharacters) = try { - matcher.find() - Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") - } catch (e: Exception) { - Log.w("Tag Parser", "Couldn't link tag $word", e) - Pair(null, null) - } - - if (index == null) { - return Text(text = "$word ") - } - - if (index >= 0 && index < tags.size) { - if (tags[index][0] == "p") { - val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1]) - if (baseUser != null) { - ClickableUserTag(baseUser, navController) - Text(text = "$extraCharacters ") - } else { - // if here the tag is not a valid Nostr Hex - Text(text = "$word ") + LaunchedEffect(key1 = word) { + withContext(Dispatchers.IO) { + val matcher = tagIndex.matcher(word) + val (index, suffix) = try { + matcher.find() + Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") + } catch (e: Exception) { + Log.w("Tag Parser", "Couldn't link tag $word", e) + Pair(null, null) } - } else if (tags[index][0] == "e") { - val note = LocalCache.checkGetOrCreateNote(tags[index][1]) - 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 { - ClickableNoteTag(note, navController) - Text(text = "$extraCharacters ") + + if (index != null && index >= 0 && index < tags.size) { + val tag = tags[index] + + if (tag.size > 1) { + if (tag[0] == "p") { + LocalCache.checkGetOrCreateUser(tag[1])?.let { + baseUserPair = Pair(it, suffix) + } + } else if (tag[0] == "e" || tag[0] == "a") { + LocalCache.checkGetOrCreateNote(tag[1])?.let { + baseNotePair = Pair(it, suffix) + } + } } - } else { - // if here the tag is not a valid Nostr Hex - Text(text = "$word ") } - } else { - Text(text = "$word ") } } + + baseUserPair?.let { + ClickableUserTag(it.first, navController) + Text(text = "${it.second} ") + } + + baseNotePair?.let { + if (canPreview) { + NoteCompose( + baseNote = it.first, + 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 + ) + it.second?.ifBlank { null }?.let { + Text(text = "$it ") + } + } else { + ClickableNoteTag(it.first, navController) + Text(text = "${it.second} ") + } + } + + if (baseNotePair == null && baseUserPair == null) { + Text(text = "$word ") + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt index 19b3b2a245..e547da6bd5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt @@ -69,7 +69,6 @@ fun ZoomableImageView(word: String, images: List = listOf(word)) { contentDescription = word, contentScale = ContentScale.FillWidth, modifier = Modifier - .padding(top = 4.dp) .fillMaxWidth() .clip(shape = RoundedCornerShape(15.dp)) .border( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 6e19217dd7..aa2921dceb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -object ChannelFeedFilter : FeedFilter() { +object ChannelFeedFilter : AdditiveFeedFilter() { lateinit var account: Account lateinit var channel: Channel @@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter() { .sortedBy { it.createdAt() } .reversed() } + + override fun applyFilter(collection: Set): Set { + return collection + .filter { it.idHex in channel.notes.keys && account.isAcceptable(it) } + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 9d77e1ac41..f96c686817 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -object ChatroomFeedFilter : FeedFilter() { +object ChatroomFeedFilter : AdditiveFeedFilter() { var account: Account? = null var withUser: User? = null @@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter() { .sortedBy { it.createdAt() } .reversed() } + + override fun applyFilter(collection: Set): Set { + val myAccount = account + val myUser = withUser + + if (myAccount == null || myUser == null) return emptySet() + + val messages = myAccount + .userProfile() + .privateChatrooms[myUser] ?: return emptySet() + + return collection + .filter { it in messages.roomMessages && account?.isAcceptable(it) == true } + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index e5f78f4e26..505a959ef9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -4,16 +4,37 @@ import android.util.Log import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue -abstract class FeedFilter() { +abstract class FeedFilter { @OptIn(ExperimentalTime::class) fun loadTop(): List { val (feed, elapsed) = measureTimedValue { - feed().take(1000) + feed() + } + + Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects") + return feed.take(1000) + } + + abstract fun feed(): List +} + +abstract class AdditiveFeedFilter : FeedFilter() { + abstract fun applyFilter(collection: Set): Set + abstract fun sort(collection: Set): List + + @OptIn(ExperimentalTime::class) + fun updateListWith(oldList: List, newItems: Set): List { + val (feed, elapsed) = measureTimedValue { + val newItemsToBeAdded = applyFilter(newItems) + if (newItemsToBeAdded.isNotEmpty()) { + val newList = oldList.toSet() + newItemsToBeAdded + sort(newList).take(1000) + } else { + oldList + } } Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects") return feed } - - abstract fun feed(): List } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index ffed429cc3..f17d5ade58 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -3,56 +3,48 @@ 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.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.model.* -object GlobalFeedFilter : FeedFilter() { +object GlobalFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { - val followChannels = account.followingChannels() + val notes = innerApplyFilter(LocalCache.notes.values) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values) + + return sort(notes + longFormNotes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + val followChannels = account.followingChannels val followUsers = account.followingKeySet() + val now = System.currentTimeMillis() / 1000 - val notes = LocalCache.notes.values + return collection .asSequence() .filter { - (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) && - it.replyTo.isNullOrEmpty() + it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty() } .filter { + val channel = it.channelHex() // does not show events already in the public chat list - (it.channel() == null || it.channel() !in followChannels) && + (channel == null || channel !in followChannels) && // does not show people the user already follows (it.author?.pubkeyHex !in followUsers) } .filter { account.isAcceptable(it) } .filter { // Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. - it.createdAt()!! <= System.currentTimeMillis() / 1000 + it.createdAt()!! <= now } - .toList() + .toSet() + } - val longFormNotes = LocalCache.addressables.values - .asSequence() - .filter { - (it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty() - } - .filter { - // does not show events already in the public chat list - (it.channel() == null || it.channel() !in followChannels) && - // does not show people the user already follows - (it.author?.pubkeyHex !in followUsers) - } - .filter { account.isAcceptable(it) } - .filter { - // Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. - it.createdAt()!! <= System.currentTimeMillis() / 1000 - } - .toList() - - return (notes + longFormNotes) - .sortedBy { it.createdAt() } - .reversed() + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index 844505ff3f..d4fd4765e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -8,14 +8,27 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HashtagFeedFilter : FeedFilter() { +object HashtagFeedFilter : AdditiveFeedFilter() { lateinit var account: Account var tag: String? = null - override fun feed(): List { - val myTag = tag ?: return emptyList() + fun loadHashtag(account: Account, tag: String?) { + this.account = account + this.tag = tag + } - return LocalCache.notes.values + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): Set { + return applyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + val myTag = tag ?: return emptySet() + + return collection .asSequence() .filter { ( @@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter() { it.event?.isTaggedHash(myTag) == true } .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } - .toList() - .reversed() + .toSet() } - fun loadHashtag(account: Account, tag: String?) { - this.account = account - this.tag = tag + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 50385acfd7..6026e2669b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -3,25 +3,38 @@ 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.TextNoteEvent -object HomeConversationsFeedFilter : FeedFilter() { +object HomeConversationsFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() - return LocalCache.notes.values + return collection + .asSequence() .filter { - (it.event is TextNoteEvent) && + (it.event is TextNoteEvent || 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 && !it.isNewThread() } - .sortedBy { it.createdAt() } - .reversed() + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index ca88aaa96f..d31a45c682 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -4,37 +4,42 @@ 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 -object HomeNewThreadFeedFilter : FeedFilter() { +object HomeNewThreadFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values) + + return sort(notes + longFormNotes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() - val notes = LocalCache.notes.values + return collection + .asSequence() .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || 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 && + it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true && it.isNewThread() } + .toSet() + } - val longFormNotes = LocalCache.addressables.values - .filter { it -> - (it.event is LongTextNoteEvent) && - (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 && - it.isNewThread() - } - - return (notes + longFormNotes) - .sortedBy { it.createdAt() } - .reversed() + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 1372f166d5..ba746ebbad 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -3,47 +3,56 @@ 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.model.User import com.vitorpamplona.amethyst.service.model.* -object NotificationFeedFilter : FeedFilter() { +object NotificationFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { - val loggedInUser = account.userProfile() - return LocalCache.notes.values - .asSequence() - .filter { - it.event !is ChannelCreateEvent && - it.event !is ChannelMetadataEvent && - it.event !is LnZapRequestEvent && - it.event !is BadgeDefinitionEvent && - it.event !is BadgeProfilesEvent && - it.event?.isTaggedUser(loggedInUser.pubkeyHex) ?: false && - (it.author == null || (!account.isHidden(it.author!!) && it.author != loggedInUser)) - } - .filter { it -> - it.event !is TextNoteEvent || - (it.event as? TextNoteEvent)?.taggedEvents()?.any { - LocalCache.checkGetOrCreateNote(it)?.author == loggedInUser - } == true || - loggedInUser in it.directlyCiteUsers() - } - .filter { - it.event !is ReactionEvent || - it.replyTo?.lastOrNull()?.author == loggedInUser || - loggedInUser in it.directlyCiteUsers() - } - .filter { - it.event !is RepostEvent || - it.replyTo?.lastOrNull()?.author == loggedInUser || - loggedInUser in it.directlyCiteUsers() - } - .sortedBy { it.createdAt() } - .toList() - .reversed() + return sort(innerApplyFilter(LocalCache.notes.values)) } - fun isDifferentAccount(account: Account): Boolean { - return this::account.isInitialized && this.account != account + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + val loggedInUser = account.userProfile() + val loggedInUserHex = loggedInUser.pubkeyHex + + return collection.filter { + it.event !is ChannelCreateEvent && + it.event !is ChannelMetadataEvent && + it.event !is LnZapRequestEvent && + it.event !is BadgeDefinitionEvent && + it.event !is BadgeProfilesEvent && + it.author !== loggedInUser && + it.event?.isTaggedUser(loggedInUserHex) ?: false && + (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && + tagsAnEventByUser(it, loggedInUser) + }.toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() + } + + fun tagsAnEventByUser(note: Note, author: User): Boolean { + val event = note.event + + if (event is BaseTextNoteEvent) { + return (event.citedUsers().contains(author.pubkeyHex) || note.replyTo?.any { it.author === author } == true) + } + + if (event is ReactionEvent) { + return note.replyTo?.lastOrNull()?.author === author + } + + if (event is RepostEvent) { + return note.replyTo?.lastOrNull()?.author === author + } + + return true } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index ceb6631922..d50ce32bcf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -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 + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index defddc0c90..36e1ac835f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -46,6 +46,7 @@ import androidx.navigation.NavHostController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ResizeImage @@ -53,6 +54,8 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.launch +import java.net.InetSocketAddress +import java.net.Proxy @OptIn(ExperimentalMaterialApi::class) @Composable @@ -100,7 +103,12 @@ fun DrawerContent( } @Composable -fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) { +fun ProfileContent( + baseAccountUser: User, + modifier: Modifier = Modifier, + scaffoldState: ScaffoldState, + navController: NavController +) { val coroutineScope = rememberCoroutineScope() val accountUserState by baseAccountUser.live().metadata.observeAsState() @@ -197,11 +205,17 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol }) ) { Row() { - Text("${accountUserFollows.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold) + Text( + "${accountUserFollows.cachedFollowCount() ?: "--"}", + fontWeight = FontWeight.Bold + ) Text(stringResource(R.string.following)) } Row(modifier = Modifier.padding(start = 10.dp)) { - Text("${accountUserFollows.cachedFollowerCount() ?: "--"}", fontWeight = FontWeight.Bold) + Text( + "${accountUserFollows.cachedFollowerCount() ?: "--"}", + fontWeight = FontWeight.Bold + ) Text(stringResource(R.string.followers)) } } @@ -221,6 +235,7 @@ fun ListContent( ) { val coroutineScope = rememberCoroutineScope() var backupDialogOpen by remember { mutableStateOf(false) } + var checked by remember { mutableStateOf(account.proxy != null) } Column(modifier = modifier.fillMaxHeight()) { if (accountUser != null) { @@ -259,6 +274,19 @@ fun ListContent( onClick = { backupDialogOpen = true } ) + IconRow( + title = "Enable Tor", + icon = R.drawable.ic_topics, + tint = MaterialTheme.colors.onBackground, + onClick = { + checked = !checked + println("changed tor to $checked") + account.proxy = if (checked) Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) else null + ServiceManager.pause() + ServiceManager.start() + } + ) + Spacer(modifier = Modifier.weight(1f)) IconRow( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 36e6423c5d..bee9980155 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -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 + ) } } } @@ -248,30 +246,32 @@ fun ChatroomMessageCompose( Row(verticalAlignment = Alignment.CenterVertically) { val event = note.event if (event is ChannelCreateEvent) { + val channelInfo = event.channelInfo() Text( text = note.author?.toBestDisplayName() .toString() + " ${stringResource(R.string.created)} " + ( - event.channelInfo().name + channelInfo.name ?: "" ) + " ${stringResource(R.string.with_description_of)} '" + ( - event.channelInfo().about + channelInfo.about ?: "" ) + "', ${stringResource(R.string.and_picture)} '" + ( - event.channelInfo().picture + channelInfo.picture ?: "" ) + "'" ) } else if (event is ChannelMetadataEvent) { + val channelInfo = event.channelInfo() Text( text = note.author?.toBestDisplayName() .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( - event.channelInfo().name + channelInfo.name ?: "" ) + "$', {stringResource(R.string.description_to)} '" + ( - event.channelInfo().about + channelInfo.about ?: "" ) + "', ${stringResource(R.string.and_picture_to)} '" + ( - event.channelInfo().picture + channelInfo.picture ?: "" ) + "'" ) @@ -283,20 +283,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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index bf0fa2cdb8..8deb5bcb1d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -26,7 +26,9 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.screen.MessageSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers @@ -46,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal } else { var isNew by remember { mutableStateOf(false) } - LaunchedEffect(key1 = messageSetCard) { + LaunchedEffect(key1 = messageSetCard.createdAt()) { withContext(Dispatchers.IO) { isNew = messageSetCard.createdAt() > NotificationCache.load(routeForLastRead) @@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { + if (noteEvent is ChannelMessageEvent) { note.channel()?.let { navController.navigate("Channel/${it.idHex}") } + } else if (noteEvent is PrivateDmEvent) { + val replyAuthorBase = + (note.event as? PrivateDmEvent) + ?.recipientPubKey() + ?.let { LocalCache.getOrCreateUser(it) } + + var userToComposeOn = note.author!! + + if (replyAuthorBase != null) { + if (note.author == accountViewModel.userProfile()) { + userToComposeOn = replyAuthorBase + } + } + + navController.navigate("Room/${userToComposeOn.pubkeyHex}") + } else { + navController.navigate("Note/${note.idHex}") { + launchSingleTop = true + } } }, onLongClick = { popupExpanded = true } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 2f262e9d4b..5376499053 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -33,9 +33,11 @@ import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R 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.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -59,7 +61,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } else { var isNew by remember { mutableStateOf(false) } - LaunchedEffect(key1 = multiSetCard) { + LaunchedEffect(key1 = multiSetCard.createdAt()) { withContext(Dispatchers.IO) { isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead) @@ -78,14 +80,29 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun .background(backgroundColor) .combinedClickable( onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { + if (noteEvent is ChannelMessageEvent) { note.channel()?.let { navController.navigate("Channel/${it.idHex}") } + } else if (noteEvent is PrivateDmEvent) { + val replyAuthorBase = + (note.event as? PrivateDmEvent) + ?.recipientPubKey() + ?.let { LocalCache.getOrCreateUser(it) } + + var userToComposeOn = note.author!! + + if (replyAuthorBase != null) { + if (note.author == accountViewModel.userProfile()) { + userToComposeOn = replyAuthorBase + } + } + + navController.navigate("Room/${userToComposeOn.pubkeyHex}") + } else { + navController.navigate("Note/${note.idHex}") { + launchSingleTop = true + } } }, onLongClick = { popupExpanded = true } @@ -227,7 +244,7 @@ fun FastNoteAuthorPicture( val userState by author.live().metadata.observeAsState() val user = userState?.user ?: return - val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount + val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount UserPicture( userHex = user.pubkeyHex, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 53c5702141..cebfe1fdde 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie user.nip05()?.let { nip05 -> if (nip05.split("@").size == 2) { - val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) Column(modifier = columnModifier) { Row(verticalAlignment = Alignment.CenterVertically) { if (nip05.split("@")[0] != "_") { @@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie ) } + val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) if (nip05Verified == null) { Icon( tint = Color.Yellow, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index d7d18be4be..8400d174b3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 @@ -81,7 +65,7 @@ import kotlin.math.ceil import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue -@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) +@OptIn(ExperimentalTime::class) @Composable fun NoteCompose( baseNote: Note, @@ -112,7 +96,7 @@ fun NoteCompose( ) } - Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}") + Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}") } @OptIn(ExperimentalFoundationApi::class) @@ -132,6 +116,7 @@ fun NoteComposeInner( ) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return + val loggedIn = account.userProfile() val noteState by baseNote.live().metadata.observeAsState() val note = noteState?.note @@ -146,6 +131,19 @@ fun NoteComposeInner( var moreActionsExpanded by remember { mutableStateOf(false) } + var isAcceptable by remember { mutableStateOf(true) } + var canPreview by remember { mutableStateOf(true) } + + LaunchedEffect(key1 = noteReportsState) { + withContext(Dispatchers.IO) { + canPreview = note?.author === loggedIn || + (note?.author?.let { loggedIn.isFollowingCached(it) } ?: true) || + !noteForReports.hasAnyReports() + + isAcceptable = account.isAcceptable(noteForReports) + } + } + val noteEvent = note?.event val baseChannel = note?.channel() @@ -157,11 +155,11 @@ fun NoteComposeInner( ), isBoostedNote ) - } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { + } else if (!isAcceptable && !showHiddenNote) { if (!account.isHidden(noteForReports.author!!)) { HiddenNote( account.getRelevantReports(noteForReports), - account.userProfile(), + loggedIn, modifier, isBoostedNote, navController, @@ -209,7 +207,20 @@ fun NoteComposeInner( navController.navigate("Channel/${it.idHex}") } } else if (noteEvent is PrivateDmEvent) { - navController.navigate("Room/${note.author?.pubkeyHex}") + val replyAuthorBase = + (note.event as? PrivateDmEvent) + ?.recipientPubKey() + ?.let { LocalCache.getOrCreateUser(it) } + + var userToComposeOn = note.author!! + + if (replyAuthorBase != null) { + if (note.author == accountViewModel.userProfile()) { + userToComposeOn = replyAuthorBase + } + } + + navController.navigate("Room/${userToComposeOn.pubkeyHex}") } else { navController.navigate("Note/${note.idHex}") } @@ -234,7 +245,7 @@ fun NoteComposeInner( .width(55.dp) .padding(0.dp) ) { - NoteAuthorPicture(note, navController, account.userProfile(), 55.dp) + NoteAuthorPicture(note, navController, loggedIn, 55.dp) if (noteEvent is RepostEvent) { note.replyTo?.lastOrNull()?.let { @@ -247,7 +258,7 @@ fun NoteComposeInner( NoteAuthorPicture( it, navController, - account.userProfile(), + loggedIn, 35.dp, pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape) ) @@ -302,7 +313,7 @@ fun NoteComposeInner( ) { Row(verticalAlignment = Alignment.CenterVertically) { if (isQuotedNote) { - NoteAuthorPicture(note, navController, account.userProfile(), 25.dp) + NoteAuthorPicture(note, navController, loggedIn, 25.dp) Spacer(Modifier.padding(horizontal = 5.dp)) NoteUsernameDisplay(note, Modifier.weight(1f)) } else { @@ -326,7 +337,7 @@ fun NoteComposeInner( ) IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), + modifier = Modifier.size(24.dp), onClick = { moreActionsExpanded = true } ) { Icon( @@ -359,11 +370,6 @@ fun NoteComposeInner( Spacer(modifier = Modifier.height(3.dp)) if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) { - val sortedMentions = noteEvent.mentions() - .mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { account.userProfile().isFollowingCached(it) } - val replyingDirectlyTo = note.replyTo?.lastOrNull() if (replyingDirectlyTo != null && unPackReply) { NoteCompose( @@ -385,17 +391,19 @@ fun NoteComposeInner( navController = navController ) } else { - ReplyInformation(note.replyTo, sortedMentions, account, navController) + ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController) } - } else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) { + Spacer(modifier = Modifier.height(5.dp)) + } else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) { val sortedMentions = noteEvent.mentions() .mapNotNull { LocalCache.checkGetOrCreateUser(it) } .toSet() - .sortedBy { account.userProfile().isFollowingCached(it) } + .sortedBy { loggedIn.isFollowingCached(it) } note.channel()?.let { ReplyInformationChannel(note.replyTo, sortedMentions, it, navController) } + Spacer(modifier = Modifier.height(5.dp)) } if (noteEvent is ReactionEvent || noteEvent is RepostEvent) { @@ -441,7 +449,7 @@ fun NoteComposeInner( thickness = 0.25.dp ) } else if (noteEvent is LongTextNoteEvent) { - LongFormHeader(noteEvent, note, account.userProfile()) + LongFormHeader(noteEvent, note, loggedIn) ReactionsRow(note, accountViewModel) @@ -459,7 +467,7 @@ fun NoteComposeInner( UserPicture( user = it, navController = navController, - userAccount = account.userProfile(), + userAccount = loggedIn, size = 35.dp ) } @@ -485,12 +493,12 @@ fun NoteComposeInner( thickness = 0.25.dp ) } else if (noteEvent is PrivateDmEvent && - noteEvent.recipientPubKey() != account.userProfile().pubkeyHex && - note.author != account.userProfile() + noteEvent.recipientPubKey() != loggedIn.pubkeyHex && + note.author !== loggedIn ) { val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } - TranslateableRichTextViewer( + TranslatableRichTextViewer( stringResource( id = R.string.private_conversation_notification, "@${note.author?.pubkeyNpub()}", @@ -515,12 +523,8 @@ fun NoteComposeInner( } else { val eventContent = accountViewModel.decrypt(note) - val canPreview = note.author == account.userProfile() || - (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - if (eventContent != null) { - if (makeItShort && note.author == account.userProfile()) { + if (makeItShort && note.author == loggedIn) { Text( text = eventContent, color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), @@ -528,7 +532,7 @@ fun NoteComposeInner( overflow = TextOverflow.Ellipsis ) } else { - TranslateableRichTextViewer( + TranslatableRichTextViewer( eventContent, canPreview = canPreview && !makeItShort, Modifier.fillMaxWidth(), @@ -538,7 +542,17 @@ fun NoteComposeInner( navController ) - DisplayUncitedHashtags(noteEvent, eventContent, navController) + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + } + + if (noteEvent is PollNoteEvent) { + PollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + navController + ) } } @@ -565,11 +579,17 @@ fun DisplayFollowingHashtagsInPost( account: Account, navController: NavController ) { + var firstTag by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = noteEvent) { + withContext(Dispatchers.IO) { + firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet()) + } + } + Column() { Row(verticalAlignment = Alignment.CenterVertically) { - val firstTag = - noteEvent.firstIsTaggedHashes(account.followingTagSet()) - if (firstTag != null) { + firstTag?.let { ClickableText( text = AnnotatedString(" #$firstTag"), onClick = { navController.navigate("Hashtag/$firstTag") }, @@ -586,11 +606,10 @@ fun DisplayFollowingHashtagsInPost( @Composable fun DisplayUncitedHashtags( - noteEvent: EventInterface, + hashtags: List, eventContent: String, navController: NavController ) { - val hashtags = noteEvent.hashtags() if (hashtags.isNotEmpty()) { FlowRow( modifier = Modifier.padding(top = 5.dp) @@ -872,7 +891,11 @@ private fun RelayBadges(baseNote: Note) { items(relaysToDisplay.size) { val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://") - Box(Modifier.padding(1.dp).size(15.dp)) { + Box( + Modifier + .padding(1.dp) + .size(15.dp) + ) { RobohashFallbackAsyncImage( robot = "https://$url/favicon.ico", model = "https://$url/favicon.ico", @@ -1066,6 +1089,13 @@ fun UserPicture( } } +data class DropDownParams( + val isFollowingAuthor: Boolean, + val isPrivateBookmarkNote: Boolean, + val isPublicBookmarkNote: Boolean, + val isLoggedUser: Boolean +) + @Composable fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) { val clipboardManager = LocalClipboardManager.current @@ -1073,11 +1103,28 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, val actContext = LocalContext.current var reportDialogShowing by remember { mutableStateOf(false) } + var state by remember { + mutableStateOf( + DropDownParams(false, false, false, false) + ) + } + + LaunchedEffect(key1 = note) { + withContext(Dispatchers.IO) { + state = DropDownParams( + accountViewModel.isFollowing(note.author), + accountViewModel.isInPrivateBookmarks(note), + accountViewModel.isInPublicBookmarks(note), + accountViewModel.isLoggedUser(note.author) + ) + } + } + DropdownMenu( expanded = popupExpanded, onDismissRequest = onDismiss ) { - if (!accountViewModel.isFollowing(note.author)) { + if (!state.isFollowingAuthor) { DropdownMenuItem(onClick = { accountViewModel.follow( note.author ?: return@DropdownMenuItem @@ -1114,7 +1161,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, Text(stringResource(R.string.quick_action_share)) } Divider() - if (accountViewModel.isInPrivateBookmarks(note)) { + if (state.isPrivateBookmarkNote) { DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) { Text(stringResource(R.string.remove_from_private_bookmarks)) } @@ -1123,7 +1170,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, Text(stringResource(R.string.add_to_private_bookmarks)) } } - if (accountViewModel.isInPublicBookmarks(note)) { + if (state.isPublicBookmarkNote) { DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) { Text(stringResource(R.string.remove_from_public_bookmarks)) } @@ -1137,7 +1184,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, Text(stringResource(R.string.broadcast)) } Divider() - if (accountViewModel.isLoggedUser(note.author)) { + if (state.isLoggedUser) { DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) { Text(stringResource(R.string.request_deletion)) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index b6a8a54964..ca98d7048b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -109,8 +109,6 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni var showDeleteAlertDialog by remember { mutableStateOf(false) } var showBlockAlertDialog by remember { mutableStateOf(false) } var showReportDialog by remember { mutableStateOf(false) } - val isOwnNote = accountViewModel.isLoggedUser(note.author) - val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!) val backgroundColor = if (MaterialTheme.colors.isLight) { MaterialTheme.colors.primary @@ -129,6 +127,9 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni } if (popupExpanded) { + val isOwnNote = accountViewModel.isLoggedUser(note.author) + val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!) + Popup(onDismissRequest = onDismiss) { Card( modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt new file mode 100644 index 0000000000..26e83cf916 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -0,0 +1,538 @@ +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.service.model.LnZapEvent +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 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 (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) { + 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.toFloat() + ) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.padding(horizontal = 10.dp).width(40.dp) + ) { + Text( + text = "${(optionTally.toFloat() * 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 (accountViewModel.isLoggedUser(zappedNote?.author)) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.poll_author_no_vote), + 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 + } + }, + zapType = LnZapEvent.ZapType.PUBLIC + ) + } + } 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, + LnZapEvent.ZapType.PUBLIC + ) + 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, + LnZapEvent.ZapType.PUBLIC + ) + 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, + LnZapEvent.ZapType.PUBLIC + ) + 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, + LnZapEvent.ZapType.PUBLIC + ) + onDismiss() + } + }, + onLongClick = {} + ) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt new file mode 100644 index 0000000000..4ce21c3723 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -0,0 +1,116 @@ +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.math.RoundingMode +import java.util.* + +class PollNoteViewModel { + var account: Account? = null + private var pollNote: Note? = null + + var pollEvent: PollNoteEvent? = null + private var pollOptions: Map? = null + var valueMaximum: Int? = null + var valueMinimum: Int? = null + private var closedAt: Int? = null + var consensusThreshold: BigDecimal? = null + + var totalZapped: BigDecimal = BigDecimal.ZERO + + 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)?.toBigDecimal() + closedAt = pollEvent?.getTagInt(CLOSED_AT) + + totalZapped = totalZapped() + } + + 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): BigDecimal { + return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { + zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP) + } else { + BigDecimal.ZERO + } + } + + fun isPollOptionZappedBy(option: Int, user: User): Boolean { + if (pollNote?.zaps?.any { it.key.author === user } == true) { + pollNote!!.zaps + .any { + val event = it.value?.event as? LnZapEvent + event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex + } + } + return false + } + + fun zappedPollOptionAmount(option: Int): BigDecimal { + return pollNote?.zaps?.values?.sumOf { + val event = it?.event as? LnZapEvent + if (event?.zappedPollOption() == option) { + event.amount ?: BigDecimal(0) + } else { + BigDecimal(0) + } + } ?: BigDecimal(0) + } + + fun totalZapped(): BigDecimal { + return pollNote?.zaps?.values?.sumOf { + (it?.event as? LnZapEvent)?.amount ?: BigDecimal(0) + } ?: BigDecimal(0) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 991bdc6683..5a0d7a3b47 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -55,6 +55,7 @@ import coil.request.CachePolicy import coil.request.ImageRequest import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -63,8 +64,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.math.BigDecimal import java.math.RoundingMode +import kotlin.math.roundToInt -@OptIn(ExperimentalFoundationApi::class) @Composable fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -125,7 +126,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 +349,7 @@ fun ZapReaction( accountViewModel.zap( baseNote, account.zapAmountChoices.first() * 1000, + null, zapMessage, context, onError = { @@ -362,7 +364,8 @@ fun ZapReaction( scope.launch(Dispatchers.Main) { zappingProgress = it } - } + }, + zapType = LnZapEvent.ZapType.PUBLIC ) } } else if (account.zapAmountChoices.size > 1) { @@ -459,7 +462,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 +477,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,10 +557,12 @@ fun ZapAmountChoicePopup( accountViewModel.zap( baseNote, amountInSats * 1000, + null, zapMessage, context, onError, - onProgress + onProgress, + LnZapEvent.ZapType.PUBLIC ) onDismiss() } @@ -579,10 +583,12 @@ fun ZapAmountChoicePopup( accountViewModel.zap( baseNote, amountInSats * 1000, + null, zapMessage, context, onError, - onProgress + onProgress, + LnZapEvent.ZapType.PUBLIC ) onDismiss() } @@ -603,9 +609,9 @@ fun showCount(count: Int?): String { if (count == 0) return "" return when { - count >= 1000000000 -> "${Math.round(count / 1000000000f)}G" - count >= 1000000 -> "${Math.round(count / 1000000f)}M" - count >= 1000 -> "${Math.round(count / 1000f)}k" + count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G" + count >= 1000000 -> "${(count / 1000000f).roundToInt()}M" + count >= 1000 -> "${(count / 1000f).roundToInt()}k" else -> "$count" } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 72c92184e8..e755a8df17 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -16,14 +16,15 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.* @Composable -fun ReplyInformation(replyTo: List?, mentions: List?, account: Account, navController: NavController) { - ReplyInformation(replyTo, mentions, account) { +fun ReplyInformation(replyTo: List?, mentions: List, account: Account, navController: NavController) { + val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } + .toSet() + .sortedBy { account.userProfile().isFollowingCached(it) } + + ReplyInformation(replyTo, sortedMentions, account) { navController.navigate("User/${it.pubkeyHex}") } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 9cbb35b50d..5fe601a6ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -1,193 +1,212 @@ -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.* +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.service.model.LnZapEvent +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ZapOptionstViewModel : ViewModel() { + private var account: Account? = null + var customAmount by mutableStateOf(TextFieldValue("21")) + 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) + } + + var zappingProgress by remember { mutableStateOf(0f) } + + val zapTypes = listOf( + Pair(LnZapEvent.ZapType.PUBLIC, "Public"), + Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"), + Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap") + ) + + val zapOptions = zapTypes.map { it.second } + var selectedZapType by remember { mutableStateOf(zapTypes[0]) } + + 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 = { + zappingProgress = 0f + scope.launch { + Toast + .makeText(context, it, Toast.LENGTH_SHORT).show() + } + }, + onProgress = { + scope.launch(Dispatchers.Main) { + zappingProgress = it + } + }, + zapType = selectedZapType.first + ) + } + 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) + ) + } + TextSpinner( + label = "Zap Type", + placeholder = "Public", + options = zapOptions, + onSelect = { + selectedZapType = zapTypes[it] + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@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) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt new file mode 100644 index 0000000000..7d00942951 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -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(false) } + + LaunchedEffect(key1 = zapSetCard.createdAt()) { + 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) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index b78c33323e..30a7ba6b9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -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 }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 0cc9cb31f1..d6872f808c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -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 @@ -16,6 +18,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import nostr.postr.Persona import nostr.postr.bechToBytes +import java.net.InetSocketAddress +import java.net.Proxy import java.util.regex.Pattern class AccountStateViewModel() : ViewModel() { @@ -33,26 +37,30 @@ class AccountStateViewModel() : ViewModel() { private fun tryLoginExistingAccount() { LocalPreferences.loadFromEncryptedStorage()?.let { - login(it) + startUI(it) } } - fun login(key: String) { + fun startUI(key: String, useProxy: Boolean) { val pattern = Pattern.compile(".+@.+\\.[a-z]+") + val parsed = Nip19.uriToRoute(key) + val pubKeyParsed = parsed?.hex?.toByteArray() + var proxy = if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) else null val account = if (key.startsWith("nsec")) { - Account(Persona(privKey = key.bechToBytes())) - } else if (key.startsWith("npub")) { - Account(Persona(pubKey = key.bechToBytes())) + Account(Persona(privKey = key.bechToBytes()), proxy = proxy) + } else if (pubKeyParsed != null) { + Account(Persona(pubKey = pubKeyParsed), proxy = proxy) } else if (pattern.matcher(key).matches()) { // Evaluate NIP-5 - Account(Persona()) + Account(Persona(), proxy = proxy) } else { - Account(Persona(Hex.decode(key))) + Account(Persona(Hex.decode(key)), proxy = proxy) } - login(account) + LocalPreferences.updatePrefsForLogin(account) + startUI(account) } fun switchUser(npub: String) { @@ -61,26 +69,25 @@ class AccountStateViewModel() : ViewModel() { tryLoginExistingAccount() } - fun newKey() { - val account = Account(Persona()) - login(account) + fun newKey(useProxy: Boolean) { + var proxy = if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 9050)) else null + val account = Account(Persona(), proxy = proxy) + // 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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index bdacb26395..3f374730c2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -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) : Card() { override fun id() = note.idHex + "Z" + createdAt } +class ZapUserSetCard(val user: User, val zapEvents: Map) : 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, val likeEvents: List, val zapEvents: Map) : Card() { val createdAt = maxOf( zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index ed5e46c629..fb37977595 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 4e547522d3..f7e1a24c0f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -5,8 +5,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel 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 @@ -14,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import kotlinx.coroutines.CoroutineScope @@ -29,7 +31,7 @@ import kotlin.time.measureTimedValue class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter) -open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { +open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(CardFeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -45,28 +47,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { @Synchronized private fun refreshSuspended() { - val notes = dataSource.loadTop() + val notes = localFilter.loadTop() - val thisAccount = (dataSource as? NotificationFeedFilter)?.account + val thisAccount = (localFilter as? NotificationFeedFilter)?.account val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { val newCards = convertToCard(notes.minus(lastNotesCopy)) if (newCards.isNotEmpty()) { lastNotes = notes - lastAccount = (dataSource as? NotificationFeedFilter)?.account + lastAccount = (localFilter as? NotificationFeedFilter)?.account updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) } } else { val cards = convertToCard(notes) lastNotes = notes - lastAccount = (dataSource as? NotificationFeedFilter)?.account + lastAccount = (localFilter as? NotificationFeedFilter)?.account updateFeed(cards) } } - private fun convertToCard(notes: List): List { + private fun convertToCard(notes: Collection): List { val reactionsPerEvent = mutableMapOf>() notes .filter { it.event is ReactionEvent } @@ -78,7 +80,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } // val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) } - + val zapsPerUser = mutableMapOf>() val zapsPerEvent = mutableMapOf>() notes .filter { it.event is LnZapEvent } @@ -89,6 +91,20 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : 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 +137,13 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : 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,13 +154,13 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } } - return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed() + return (multiCards + textNoteCards + userZaps).sortedBy { it.createdAt() }.reversed() } private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { CardFeedState.Empty } @@ -150,6 +173,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } } + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + + val thisAccount = (localFilter as? NotificationFeedFilter)?.account + val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null + + if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) { + val filteredNewList = localFilter.applyFilter(newItems) + val actuallyNew = filteredNewList.minus(lastNotesCopy) + + val newCards = convertToCard(actuallyNew) + if (newCards.isNotEmpty()) { + lastNotes = lastNotesCopy + newItems + lastAccount = (localFilter as? NotificationFeedFilter)?.account + updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) + } + } else { + // Refresh Everything + refreshSuspended() + } + } + @OptIn(ExperimentalTime::class) private val bundler = BundledUpdate(250, Dispatchers.IO) { // adds the time to perform the refresh into this delay @@ -159,13 +204,29 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") } + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) fun invalidateData() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { - invalidateData() + @OptIn(ExperimentalTime::class) + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { + val (value, elapsed) = measureTimedValue { + refreshFromOldState(it.flatten().toSet()) + } + Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed") + } + } + + private val cacheListener: (Set) -> Unit = { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } } init { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index a6b2fcdb13..60daf10722 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter @@ -65,7 +66,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { fun refreshSuspended() { val notes = newListFromDataSource() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is FeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -79,7 +80,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { FeedState.Empty } } else if (currentState is FeedState.Loaded) { @@ -91,18 +92,41 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) { + val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()) + updateFeed(newList) + } else { + // Refresh Everything + refreshSuspended() + } + } + private val bundler = BundledUpdate(250, Dispatchers.IO) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. refreshSuspended() } + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) fun invalidateData() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { - invalidateData() + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { + refreshFromOldState(it.flatten().toSet()) + } + } + + private val cacheListener: (Set) -> Unit = { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } } init { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 9973afab81..8c6806de81 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter @@ -32,7 +31,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi private fun refreshSuspended() { val notes = dataSource.loadTop() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is LnZapFeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -46,7 +45,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi private fun updateFeed(notes: List>) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { LnZapFeedState.Empty } } else if (currentState is LnZapFeedState.Loaded) { @@ -68,7 +67,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { + private val cacheListener: (Set) -> Unit = { newNotes -> invalidateData() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 813bdadf5a..26d0448be4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -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(), @@ -360,7 +365,17 @@ fun NoteMaster( navController ) - DisplayUncitedHashtags(noteEvent, eventContent, navController) + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + + if (noteEvent is PollNoteEvent) { + PollNote( + note, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + } } ReactionsRow(note, accountViewModel) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 500320035a..aae7fd3581 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel 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.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter @@ -36,7 +36,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun refreshSuspended() { val notes = dataSource.loadTop() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is UserFeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -50,7 +50,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { UserFeedState.Empty } } else if (currentState is UserFeedState.Loaded) { @@ -72,7 +72,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { + private val cacheListener: (Set) -> Unit = { newNotes -> invalidateData() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index ce1372dfe0..c88352b6c7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -52,7 +53,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, zapType: LnZapEvent.ZapType) { val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() if (lud16.isNullOrBlank()) { @@ -60,7 +61,14 @@ class AccountViewModel(private val account: Account) : ViewModel() { return } - val zapRequest = account.createZapRequestFor(note, message) + var zapRequestJson = "" + + if (zapType != LnZapEvent.ZapType.NONZAP) { + val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType) + if (zapRequest != null) { + zapRequestJson = zapRequest.toJson() + } + } onProgress(0.10f) @@ -68,7 +76,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { lud16, amount, message, - zapRequest?.toJson(), + zapRequestJson, onSuccess = { onProgress(0.7f) if (account.hasWalletConnectSetup()) { @@ -170,7 +178,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { } fun isLoggedUser(user: User?): Boolean { - return account.userProfile() == user + return account.userProfile() === user } fun isFollowing(user: User?): Boolean { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 547b3c447d..32c382803e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index e03561acc4..0f26c18bcd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index e2e22b469e..49486609d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween 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 @@ -18,11 +20,13 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope 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 @@ -32,10 +36,12 @@ import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.currentRoute import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterialApi::class) @Composable fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { + val coroutineScope = rememberCoroutineScope() val navController = rememberNavController() val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val sheetState = rememberModalBottomSheetState( @@ -62,9 +68,12 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun }, drawerContent = { DrawerContent(navController, scaffoldState, sheetState, accountViewModel) + BackHandler(enabled = scaffoldState.drawerState.isOpen) { + coroutineScope.launch { scaffoldState.drawerState.close() } + } }, floatingActionButton = { - FloatingButton(navController, accountStateViewModel) + FloatingButtons(navController, accountStateViewModel) }, scaffoldState = scaffoldState ) { @@ -76,7 +85,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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 88159e4d5c..6c832ceac1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 132c997d0f..6533d1c906 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -55,6 +55,7 @@ fun LoginPage( var dialogOpen by remember { mutableStateOf(false) } + val useProxy = remember { mutableStateOf(false) } Column( modifier = Modifier @@ -134,29 +135,31 @@ fun LoginPage( } ) } - if (dialogOpen) { - SimpleQrCodeScanner { - dialogOpen = false - if (!it.isNullOrEmpty()) { - key.value = TextFieldValue(it) - } + } + }, + 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 - ) - } + } + IconButton(onClick = { dialogOpen = true }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary + ) } }, visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), keyboardActions = KeyboardActions( onGo = { try { - accountViewModel.login(key.value.text) + accountViewModel.startUI(key.value.text, useProxy.value) } catch (e: Exception) { errorMessage = context.getString(R.string.invalid_key) } @@ -219,6 +222,15 @@ fun LoginPage( } } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { useProxy.value = it } + ) + + Text("Enable Tor") + } + Spacer(modifier = Modifier.height(20.dp)) Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { @@ -235,7 +247,7 @@ fun LoginPage( if (acceptedTerms.value && key.value.text.isNotBlank()) { try { - accountViewModel.login(key.value.text) + accountViewModel.startUI(key.value.text, useProxy.value) } catch (e: Exception) { errorMessage = context.getString(R.string.invalid_key) } @@ -263,7 +275,7 @@ fun LoginPage( .fillMaxWidth(), onClick = { if (acceptedTerms.value) { - accountViewModel.newKey() + accountViewModel.newKey(useProxy.value) } else { termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) diff --git a/app/src/main/res/drawable-hdpi/ic_poll.png b/app/src/main/res/drawable-hdpi/ic_poll.png new file mode 100644 index 0000000000..560b632f28 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_poll.png b/app/src/main/res/drawable-mdpi/ic_poll.png new file mode 100644 index 0000000000..6a27ba8a78 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_poll.png b/app/src/main/res/drawable-xhdpi/ic_poll.png new file mode 100644 index 0000000000..02383e0b9b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_poll.png b/app/src/main/res/drawable-xxhdpi/ic_poll.png new file mode 100644 index 0000000000..3dae6049dc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_poll.png b/app/src/main/res/drawable-xxxhdpi/ic_poll.png new file mode 100644 index 0000000000..b0a5d10341 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/coffee.xml b/app/src/main/res/drawable/coffee.xml similarity index 100% rename from app/src/main/res/drawable-xxxhdpi/coffee.xml rename to app/src/main/res/drawable/coffee.xml diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 81296d2437..dc5073f2a0 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -263,9 +263,30 @@ nsec / hex privát kulcs Hozzájárulás összege sats-ban + Szavazás Létrehozása + Szükséges mezők: + Zap-et kapják + Szavazás elsődleges leírása… + Szavazás %s megoszlása + Szavazás opcióinak leírása + Kiegészítő mezők: + Zap minimum + Zap maximum + Konszenzus + (0–100)% + Szavazás lezárása + napok + A szavazásra nem lehet már új szavazatot leadni + Zap összege + Az ilyen típusú szavazásokon felhasználónként csak egy szavazat engedélyezett + "%1$s esemény keresése" Nyilvános üzenet hozzáadása Köszönöm a kemény munkát! + + Létrehoz és Hozzáad + A szavazás létrehozója sajátjára nem szavazhat. + #zappoll diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ded1ed4ae4..f1878907d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,9 +267,29 @@ nsec / hex private key Pledge Amount in Sats + Post Poll + Required fields: + Zap recipients + Primary poll description… + Option %s + Poll option description + Optional fields: + Zap minimum + Zap maximum + Consensus + (0–100)% + Close after + days + Poll is closed to new votes + Zap amount + Only one vote per user is allowed on this type of poll "Looking for Event %1$s" Add a public message Thank you for all your work! + + Create and Add + Poll authors can\'t vote in their own polls. + #zappoll diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt similarity index 99% rename from app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt rename to app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 0ab0016d14..8100a3e9b1 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -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, diff --git a/build.gradle b/build.gradle index ee67b5b0cc..b44afca7a8 100644 --- a/build.gradle +++ b/build.gradle @@ -10,8 +10,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.4.2' apply false - id 'com.android.library' version '7.4.2' apply false + id 'com.android.application' version '8.0.0' apply false + id 'com.android.library' version '8.0.0' apply false id 'org.jetbrains.kotlin.android' version '1.8.10' apply false id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false } diff --git a/gradle.properties b/gradle.properties index c9ecf2f958..e4e68fdb17 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,6 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.enableR8.fullMode=true \ No newline at end of file +android.enableR8.fullMode=true +android.defaults.buildfeatures.buildconfig=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c1070955d4..289cef2848 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jan 04 09:23:50 EST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME