diff --git a/PRIVACY.md b/PRIVACY.md index 7c481a0c4d..50504e609e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,7 +4,7 @@ Effective as of Jan 20, 2023 for the distributed applications in the Play Store The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them. -Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app. +The app may collect a per-device token, your public key and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app. You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling. diff --git a/README.md b/README.md index 4ecf0974d7..f5374fa5ea 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo # Current Features -- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15) +- [x] Event Builders / WebSocket Subscriptions (NIP-01) - [x] Home Feed - [x] Notifications Feed - [x] Global Feed @@ -34,7 +34,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Lightning Tips - [x] Zaps (private, public, anon, non-zap) (NIP-57) - [x] URI Support (NIP-21) -- [x] Event Deletion (NIP-09: like, boost, text notes and reports) +- [x] Created_at Limits (NIP-22) +- [x] Event Deletion (NIP-09) - [x] Identity Verification (NIP-05) - [x] Long-form Content (NIP-23) - [x] Parameterized Replaceable Events (NIP-33) @@ -54,13 +55,18 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Markdown Support - [x] Relay Authentication (NIP-42) - [x] Content stored in relays themselves (NIP-95) +- [x] Custom Emoji (NIP-30) +- [x] Text Note References (NIP-27) +- [x] Audio Tracks (zapstr.live) (NIP-TBD) +- [x] Push Notifications (Zaps and Messages) +- [x] Generic Tags (NIP-12) +- [ ] Marketplace (NIP-15) - [ ] Image/Video Capture in the app - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) - [ ] Sensitive Content (NIP-36) - [ ] Relay Pages (NIP-11) -- [ ] Generic Tags (NIP-12) - [ ] Proof of Work in the Phone (NIP-13, NIP-20) - [ ] Events with a Subject (NIP-14) - [ ] Workspaces @@ -69,8 +75,10 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [ ] Relay List Metadata (NIP-65) - [ ] Signing Requests (NIP-46) - [ ] Delegated Event Signing (NIP-26) -- [ ] Account Creation / Backup Guidance (NIP-06) +- [ ] Account Creation / Backup Guidance +- [ ] Mnemonic seed phrase (NIP-06) - [ ] Message Sent feedback (NIP-20) +- [ ] OpenTimestamps Attestations (NIP-03) # Development Overview diff --git a/app/build.gradle b/app/build.gradle index a4d21bc8b1..8ba1e99073 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'org.jlleitschuh.gradle.ktlint' version "11.3.1" + id 'com.google.gms.google-services' } android { @@ -12,8 +13,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 151 - versionName "0.44.0" + versionCode 174 + versionName "0.50.6" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -85,8 +86,8 @@ android { } dependencies { - implementation 'androidx.core:core-ktx:1.10.0' - implementation 'androidx.activity:activity-compose:1.7.0' + implementation 'androidx.core:core-ktx:1.10.1' + implementation 'androidx.activity:activity-compose:1.7.1' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" @@ -114,7 +115,7 @@ dependencies { implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" // Bitcoin secp256k1 bindings to Android - implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0' + implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0' // Nostr Base Protocol implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') { @@ -127,7 +128,7 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11' // Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both) - implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.1' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' // link preview @@ -137,7 +138,7 @@ dependencies { implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06' // view videos - implementation 'com.google.android.exoplayer:exoplayer:2.18.6' + implementation 'com.google.android.exoplayer:exoplayer:2.18.7' // Load images from the web. implementation "io.coil-kt:coil-compose:$coil_version" @@ -172,8 +173,18 @@ dependencies { // Google services model the translate text playImplementation 'com.google.mlkit:translate:17.0.1' + // PushNotifications + playImplementation platform('com.google.firebase:firebase-bom:32.0.0') + playImplementation 'com.google.firebase:firebase-messaging-ktx' + + // Charts + implementation "com.patrykandpatrick.vico:core:${vico_version}" + implementation "com.patrykandpatrick.vico:compose:${vico_version}" + implementation "com.patrykandpatrick.vico:views:${vico_version}" + implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}" + // Automatic memory leak detection - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11' testImplementation 'junit:junit:4.13.2' testImplementation 'io.mockk:mockk:1.13.5' diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000000..4a9fa85f5e --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,68 @@ +{ + "project_info": { + "project_number": "768341258853", + "project_id": "amethyst-3057a", + "storage_bucket": "amethyst-3057a.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c", + "android_client_info": { + "package_name": "com.vitorpamplona.amethyst" + } + }, + "oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c", + "android_client_info": { + "package_name": "com.vitorpamplona.amethyst.debug" + } + }, + "oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt new file mode 100644 index 0000000000..af98585d05 --- /dev/null +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -0,0 +1,8 @@ +package com.vitorpamplona.amethyst.service.notifications + +import com.vitorpamplona.amethyst.AccountInfo + +class PushNotificationUtils { + fun init(accounts: List) { + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fc406f2c5..7ba5daf746 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ - + + + @@ -70,6 +72,7 @@ android:name="com.journeyapps.barcodescanner.CaptureActivity" android:screenOrientation="fullSensor" tools:replace="screenOrientation" /> + \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index eee89bf058..6984ff9a27 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.hexToByteArray +import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent @@ -55,6 +56,8 @@ 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" + const val PROXY_PORT = "proxy_port" val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } } @@ -209,11 +212,19 @@ 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) + putBoolean(PrefKeys.USE_PROXY, account.proxy != null) + putInt(PrefKeys.PROXY_PORT, account.proxyPort) }.apply() } fun loadFromEncryptedStorage(): Account? { - encryptedPreferences(currentAccount()).apply { + val acc = loadFromEncryptedStorage(currentAccount()) + acc?.registerObservers() + return acc + } + + fun loadFromEncryptedStorage(npub: String?): Account? { + encryptedPreferences(npub).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() @@ -240,9 +251,9 @@ object LocalPreferences { ) ?: LnZapEvent.ZapType.PUBLIC val defaultFileServer = gson.fromJson( - getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"), + getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"), object : TypeToken() {}.type - ) ?: ServersAvailable.IMGUR + ) ?: ServersAvailable.NOSTR_BUILD val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { @@ -277,6 +288,9 @@ 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) + val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050) + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) val a = Account( Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()), @@ -295,7 +309,9 @@ object LocalPreferences { zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, - latestContactList + latestContactList, + proxy, + proxyPort ) return a diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 480097a681..f283fa8126 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource @@ -26,7 +27,7 @@ object ServiceManager { fun start() { val myAccount = account - + HttpClient.start(account) if (myAccount != null) { Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays()) 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 2796ed79a0..89c2e96a82 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.launch import nostr.postr.Persona import nostr.postr.Utils import java.math.BigDecimal +import java.net.Proxy import java.util.Locale val DefaultChannels = setOf( @@ -52,14 +53,16 @@ class Account( var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, - var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, + var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD, var defaultHomeFollowList: String = KIND3_FOLLOWS, var defaultStoriesFollowList: String = GLOBAL_FOLLOWS, var defaultNotificationFollowList: String = GLOBAL_FOLLOWS, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, - var backupContactList: ContactListEvent? = null + var backupContactList: ContactListEvent? = null, + var proxy: Proxy?, + var proxyPort: Int ) { var transientHiddenUsers: Set = setOf() @@ -214,7 +217,12 @@ class Account( zapPaymentRequest?.let { nip47 -> val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!) - val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id) + val wcListener = NostrLnZapPaymentResponseDataSource( + fromServiceHex = nip47.pubKeyHex, + toUserHex = event.pubKey, + replyingToHex = event.id, + authSigningKey = nip47.secret?.hexToByteArray() ?: loggedIn.privKey!! + ) wcListener.start() LocalCache.consume(event, zappedNote) { @@ -590,18 +598,33 @@ class Account( val bookmarks = userProfile().latestBookmarkList - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents() ?: emptyList(), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), + val event = if (note is AddressableNote) { + 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(), + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.plus(note.address) ?: listOf(note.address), - loggedIn.privKey!! - ) + loggedIn.privKey!! + ) + } else { + 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) @@ -612,18 +635,33 @@ class Account( val bookmarks = userProfile().latestBookmarkList - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), + val event = if (note is AddressableNote) { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses()?.plus(note.address) ?: listOf(note.address), - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - loggedIn.privKey!! - ) + loggedIn.privKey!! + ) + } else { + 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) @@ -634,18 +672,33 @@ class Account( val bookmarks = userProfile().latestBookmarkList - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents() ?: emptyList(), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), + val event = if (note is AddressableNote) { + 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(), + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.minus(note.address) ?: listOf(), - loggedIn.privKey!! - ) + loggedIn.privKey!! + ) + } else { + 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) @@ -662,18 +715,33 @@ class Account( val bookmarks = userProfile().latestBookmarkList - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents()?.minus(note.idHex), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), + val event = if (note is AddressableNote) { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses()?.minus(note.address), - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - loggedIn.privKey!! - ) + loggedIn.privKey!! + ) + } else { + 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) @@ -874,23 +942,28 @@ class Account( null } - if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey() + try { + if (altPrivateKeyToUse != null && altPubkeyToUse != null) { + val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey() - if (altPubKeyFromPrivate == event.pubKey) { - val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) + if (altPubKeyFromPrivate == event.pubKey) { + val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) - if (result == null) { - Log.w( - "Private ZAP Decrypt", - "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}" - ) + if (result == null) { + Log.w( + "Private ZAP Decrypt", + "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}" + ) + } + result + } else { + null } - result } else { null } - } else { + } catch (e: Exception) { + Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) null } } @@ -936,7 +1009,7 @@ class Account( 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) + 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. @@ -946,7 +1019,8 @@ class Account( Constants.forcedRelayForSearch.url, Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write, - Constants.forcedRelayForSearch.feedTypes + Constants.forcedRelayForSearch.feedTypes, + proxy ) } @@ -955,7 +1029,7 @@ class Account( fun convertLocalRelays(): Array { return localRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) + Relay(it.url, it.read, it.write, it.feedTypes, proxy) }.toTypedArray() } @@ -1037,14 +1111,7 @@ class Account( saveable.invalidateData() } - init { - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") - if (userProfile().latestContactList == null) { - LocalCache.consume(it) - } - } - + fun registerObservers() { // Observes relays to restart connections userProfile().live().relays.observeForever { GlobalScope.launch(Dispatchers.IO) { @@ -1071,6 +1138,15 @@ class Account( } } } + + init { + backupContactList?.let { + println("Loading saved contacts ${it.toJson()}") + if (userProfile().latestContactList == null) { + LocalCache.consume(it) + } + } + } } class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 7716d04cc5..ceae0d7297 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -57,7 +57,7 @@ class Channel(val idHex: String) { fun pruneOldAndHiddenMessages(account: Account): Set { val important = notes.values .filter { it.author?.let { it1 -> account.isHidden(it1) } == false } - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() .take(1000) .toSet() 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 a9ab40817d..612f1fdb58 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt @@ -50,6 +50,23 @@ fun decodePublicKey(key: String): ByteArray { } } +fun decodePublicKeyAsHexOrNull(key: String): HexKey? { + return try { + val parsed = Nip19.uriToRoute(key) + val pubKeyParsed = parsed?.hex + + if (key.startsWith("nsec")) { + Persona(privKey = key.bechToBytes()).pubKey.toHexKey() + } else if (pubKeyParsed != null) { + pubKeyParsed + } else { + Hex.decode(key).toHexKey() + } + } catch (e: Exception) { + null + } +} + data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String) fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { 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 dc7177f984..eea2d78515 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1,16 +1,17 @@ package com.vitorpamplona.amethyst.model import android.util.Log -import androidx.core.net.toUri -import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledInsert import fr.acinq.secp256k1.Hex import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import nostr.postr.toNpub import java.io.ByteArrayInputStream import java.io.File @@ -280,6 +281,30 @@ object LocalCache { refreshObservers(note) } + private fun consume(event: PinListEvent) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + private fun consume(event: AudioTrackEvent) { + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + fun consume(event: BadgeDefinitionEvent) { val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) @@ -347,7 +372,7 @@ object LocalCache { } } - fun consume(event: PrivateDmEvent, relay: Relay?) { + fun consume(event: PrivateDmEvent, relay: Relay?): Note { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -357,7 +382,7 @@ object LocalCache { } // Already processed this event. - if (note.event != null) return + if (note.event != null) return note val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } @@ -374,6 +399,8 @@ object LocalCache { } refreshObservers(note) + + return note } fun consume(event: DeletionEvent) { @@ -526,17 +553,20 @@ object LocalCache { // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") val oldChannel = getOrCreateChannel(event.id) val author = getOrCreateUser(event.pubKey) + + val note = getOrCreateNote(event.id) + if (note.event == null) { + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + if (event.createdAt <= oldChannel.updatedMetadataAt) { return // older data, does nothing } if (oldChannel.creator == null || oldChannel.creator == author) { oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - - val note = getOrCreateNote(event.id) - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) } } @@ -551,16 +581,18 @@ object LocalCache { if (event.createdAt > oldChannel.updatedMetadataAt) { if (oldChannel.creator == null || oldChannel.creator == author) { oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - - val note = getOrCreateNote(event.id) - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) } } else { // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") } + + val note = getOrCreateNote(event.id) + if (note.event == null) { + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } } fun consume(event: ChannelMessageEvent, relay: Relay?) { @@ -736,19 +768,18 @@ object LocalCache { note.addRelay(relay) } - val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95") - - if (!file.exists()) { - try { - val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95") - cachePath.mkdirs() - val stream = FileOutputStream(File(cachePath, event.id)) + try { + val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95") + cachePath.mkdirs() + val file = File(cachePath, event.id) + if (!file.exists()) { + val stream = FileOutputStream(file) stream.write(event.decode()) stream.close() - Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}") - } catch (e: IOException) { - Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e) + Log.i("FileStorageEvent", "NIP95 File received from ${relay?.url} and saved to disk as $file") } + } catch (e: IOException) { + Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e) } // Already processed this event. @@ -808,6 +839,12 @@ object LocalCache { } fun findUsersStartingWith(username: String): List { + val key = decodePublicKeyAsHexOrNull(username) + + if (key != null && users[key] != null) { + return listOfNotNull(users[key]) + } + return users.values.filter { (it.anyNameStartsWith(username)) || it.pubkeyHex.startsWith(username, true) || @@ -816,6 +853,16 @@ object LocalCache { } fun findNotesStartingWith(text: String): List { + val key = try { + Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() + } catch (e: Exception) { + null + } + + if (key != null && (notes[key] ?: addressables[key]) != null) { + return listOfNotNull(notes[key] ?: addressables[key]) + } + 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) || @@ -831,6 +878,16 @@ object LocalCache { } fun findChannelsStartingWith(text: String): List { + val key = try { + Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey() + } catch (e: Exception) { + null + } + + if (key != null && channels[key] != null) { + return listOfNotNull(channels[key]) + } + return channels.values.filter { it.anyNameStartsWith(text) || it.idHex.startsWith(text, true) || @@ -911,18 +968,74 @@ object LocalCache { private fun refreshObservers(newNote: Note) { live.invalidateData(newNote) } + + fun consume(event: Event, relay: Relay?) { + if (!event.hasValidSignature()) return + + try { + when (event) { + is AudioTrackEvent -> consume(event) + is BadgeAwardEvent -> consume(event) + is BadgeDefinitionEvent -> consume(event) + is BadgeProfilesEvent -> consume(event) + is BookmarkListEvent -> consume(event) + is ChannelCreateEvent -> consume(event) + is ChannelHideMessageEvent -> consume(event) + is ChannelMessageEvent -> consume(event, relay) + is ChannelMetadataEvent -> consume(event) + is ChannelMuteUserEvent -> consume(event) + is ContactListEvent -> consume(event) + is DeletionEvent -> consume(event) + + is FileHeaderEvent -> consume(event, relay) + is FileStorageEvent -> consume(event, relay) + is FileStorageHeaderEvent -> consume(event, relay) + is HighlightEvent -> consume(event, relay) + is LnZapEvent -> { + event.zapRequest?.let { + consume(it, relay) + } + consume(event) + } + is LnZapRequestEvent -> consume(event) + is LnZapPaymentRequestEvent -> consume(event) + is LnZapPaymentResponseEvent -> consume(event) + is LongTextNoteEvent -> consume(event, relay) + is MetadataEvent -> consume(event) + is PrivateDmEvent -> consume(event, relay) + is PinListEvent -> consume(event) + is PeopleListEvent -> consume(event) + is ReactionEvent -> consume(event) + is RecommendRelayEvent -> consume(event) + is ReportEvent -> consume(event, relay) + is RepostEvent -> { + event.containedPost()?.let { + consume(it, relay) + } + consume(event) + } + is TextNoteEvent -> consume(event, relay) + is PollNoteEvent -> consume(event, relay) + else -> { + Log.w("Event Not Supported", event.toJson()) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } } -class LocalCacheLiveData : LiveData>(setOf()) { +class LocalCacheLiveData { + private val _newEventBundles = MutableSharedFlow>() + val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view // Refreshes observers in batches. private val bundler = BundledInsert(300, Dispatchers.IO) fun invalidateData(newNote: Note) { bundler.invalidateList(newNote) { bundledNewNotes -> - if (hasActiveObservers()) { - postValue(bundledNewNotes) - } + _newEventBundles.emit(bundledNewNotes) } } } 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 c391efeb60..b012aabc2b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.model +import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil @@ -20,6 +21,7 @@ import java.util.regex.Pattern val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]") +@Stable class AddressableNote(val address: ATag) : Note(address.toTag()) { override fun idNote() = address.toNAddr() override fun toNEvent() = address.toNAddr() @@ -28,6 +30,7 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) { override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt() } +@Stable open class Note(val idHex: String) { // These fields are only available after the Text Note event is received. // They are immutable after that. @@ -411,9 +414,9 @@ class NoteLiveSet(u: Note) { class NoteLiveData(val note: Note) : LiveData(NoteState(note)) { // Refreshes observers in batches. private val bundler = BundledUpdate(300, Dispatchers.IO) { - if (hasActiveObservers()) { - postValue(NoteState(note)) - } + // if (hasObservers()) { + postValue(NoteState(note)) + // } } fun invalidateData() { 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 7d5877952e..afc4460040 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.model +import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.model.BookmarkListEvent @@ -20,6 +21,7 @@ import java.util.regex.Pattern val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)") +@Stable class User(val pubkeyHex: String) { var info: UserMetadata? = null @@ -358,6 +360,7 @@ data class RelayInfo( data class Chatroom(var roomMessages: Set) +@Stable class UserMetadata { var name: String? = null var username: String? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt new file mode 100644 index 0000000000..bf2448ece3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt @@ -0,0 +1,34 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.Account +import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import java.time.Duration + +object HttpClient { + private var proxy: Proxy? = null + + fun start(account: Account?) { + this.proxy = account?.proxy + } + + fun getHttpClient(): OkHttpClient { + val seconds = if (proxy != null) 20L else 10L + val duration = Duration.ofSeconds(seconds) + return OkHttpClient.Builder() + .proxy(proxy) + .readTimeout(duration) + .connectTimeout(duration) + .writeTimeout(duration) + .build() + } + + fun getProxy(): Proxy? { + return proxy + } + + fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? { + return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NIP30Parser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NIP30Parser.kt new file mode 100644 index 0000000000..0bc3157568 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NIP30Parser.kt @@ -0,0 +1,32 @@ +package com.vitorpamplona.amethyst.service + +import java.util.regex.Pattern + +class NIP30Parser { + val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE) + + fun buildArray(input: String): List { + val matcher = customEmojiPattern.matcher(input) + val list = mutableListOf() + while (matcher.find()) { + list.add(matcher.group()) + } + + if (list.isEmpty()) { + return listOf(input) + } + + val regularChars = input.split(customEmojiPattern.toRegex()) + + var finalList = mutableListOf() + var index = 0 + for (e in regularChars) { + finalList.add(e) + if (index < list.size) { + finalList.add(list[index]) + } + index++ + } + return finalList + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt index 2a0ee3240d..e86a856331 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt @@ -9,12 +9,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.Call import okhttp3.Callback -import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -class Nip05Verifier { - val client = OkHttpClient.Builder().build() +class Nip05Verifier() { + val client = HttpClient.getHttpClient() fun assembleUrl(nip05address: String): String? { val parts = nip05address.trim().split("@") 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 aba906e2a7..8a2ceeceef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -95,7 +95,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { BadgeAwardEvent.kind ), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 400, + limit = 4000, since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList ) ) 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 5de8ef2bfb..085a6767ec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -3,28 +3,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 -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.Event -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.relays.Client import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Subscription @@ -52,8 +31,6 @@ abstract class NostrDataSource(val debugName: String) { private val clientListener = object : Client.Listener() { override fun onEvent(event: Event, subscriptionId: String, relay: Relay) { if (subscriptionId in subscriptions.keys) { - if (!event.hasValidSignature()) return - val key = "$debugName $subscriptionId ${event.kind}" val keyValue = eventCounter.get(key) if (keyValue != null) { @@ -62,51 +39,7 @@ abstract class NostrDataSource(val debugName: String) { eventCounter = eventCounter + Pair(key, Counter(1)) } - try { - when (event) { - is BadgeAwardEvent -> LocalCache.consume(event) - is BadgeDefinitionEvent -> LocalCache.consume(event) - is BadgeProfilesEvent -> LocalCache.consume(event) - is BookmarkListEvent -> LocalCache.consume(event) - is ChannelCreateEvent -> LocalCache.consume(event) - is ChannelHideMessageEvent -> LocalCache.consume(event) - is ChannelMessageEvent -> LocalCache.consume(event, relay) - is ChannelMetadataEvent -> LocalCache.consume(event) - is ChannelMuteUserEvent -> LocalCache.consume(event) - is ContactListEvent -> LocalCache.consume(event) - is DeletionEvent -> LocalCache.consume(event) - - is FileHeaderEvent -> LocalCache.consume(event, relay) - is FileStorageEvent -> LocalCache.consume(event, relay) - is FileStorageHeaderEvent -> LocalCache.consume(event, relay) - is HighlightEvent -> LocalCache.consume(event, relay) - is LnZapEvent -> { - event.zapRequest?.let { onEvent(it, subscriptionId, relay) } - LocalCache.consume(event) - } - is LnZapRequestEvent -> LocalCache.consume(event) - is LnZapPaymentRequestEvent -> LocalCache.consume(event) - is LnZapPaymentResponseEvent -> LocalCache.consume(event) - is LongTextNoteEvent -> LocalCache.consume(event, relay) - is MetadataEvent -> LocalCache.consume(event) - is PrivateDmEvent -> LocalCache.consume(event, relay) - is PeopleListEvent -> LocalCache.consume(event) - is ReactionEvent -> LocalCache.consume(event) - is RecommendRelayEvent -> LocalCache.consume(event) - is ReportEvent -> LocalCache.consume(event, relay) - is RepostEvent -> { - event.containedPost()?.let { onEvent(it, subscriptionId, relay) } - LocalCache.consume(event) - } - is TextNoteEvent -> LocalCache.consume(event, relay) - is PollNoteEvent -> LocalCache.consume(event, relay) - else -> { - Log.w("Event Not Supported", event.toJson()) - } - } - } catch (e: Exception) { - e.printStackTrace() - } + LocalCache.consume(event, relay) } } @@ -170,7 +103,7 @@ abstract class NostrDataSource(val debugName: String) { } // Refreshes observers in batches. - private val bundler = BundledUpdate(250, Dispatchers.IO) { + private val bundler = BundledUpdate(300, Dispatchers.IO) { // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") // adds the time to perform the refresh into this delay 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 ebf3425dbb..cec80961fb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -1,8 +1,10 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PinListEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.FeedType @@ -13,7 +15,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") { fun createGlobalFilter() = TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), + kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), limit = 200 ) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index e4233c1067..9b278e09c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service import androidx.compose.ui.text.capitalize 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.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter @@ -25,7 +26,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { hashToLoad.capitalize() ) ), - kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.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 d831512057..850b37069f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -2,8 +2,10 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PinListEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.EOSEAccount @@ -56,7 +58,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), authors = followSet, limit = 400, since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList @@ -72,7 +74,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind), tags = mapOf( "t" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt index 57de8be6ed..7993306f54 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -1,14 +1,18 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent +import com.vitorpamplona.amethyst.service.model.RelayAuthEvent +import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.TypedFilter class NostrLnZapPaymentResponseDataSource( - private var fromServiceHex: String, - private var toUserHex: String, - private var replyingToHex: String + private val fromServiceHex: String, + private val toUserHex: String, + private val replyingToHex: String, + private val authSigningKey: ByteArray ) : NostrDataSource("LnZapPaymentResponseFeed") { val feedTypes = setOf(FeedType.WALLET_CONNECT) @@ -36,4 +40,14 @@ class NostrLnZapPaymentResponseDataSource( channel.typedFilters = listOfNotNull(wc).ifEmpty { null } } + + override fun auth(relay: Relay, challenge: String) { + super.auth(relay, challenge) + + val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey) + Client.send( + event, + relay.url + ) + } } 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 f2656f9497..12325a438a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -1,13 +1,13 @@ package com.vitorpamplona.amethyst.service -import com.vitorpamplona.amethyst.model.decodePublicKey +import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter -import nostr.postr.bechToBytes -import nostr.postr.toHex +import fr.acinq.secp256k1.Hex object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { private var searchString: String? = null @@ -19,15 +19,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { } val hexToWatch = try { - if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) { - decodePublicKey(mySearchString).toHex() - } else if (mySearchString.startsWith("note")) { - mySearchString.bechToBytes().toHex() - } else { - mySearchString - } + Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey() } catch (e: Exception) { - // Usually when people add an incomplete npub or note. null } 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 2e3123c313..3fce56a25d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -29,7 +29,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind, - PollNoteEvent.kind + PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind ), tags = mapOf("a" to listOf(aTag.toTag())), since = it.lastReactionsDownloadTime @@ -80,7 +80,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { LnZapEvent.kind, LnZapRequestEvent.kind, PollNoteEvent.kind, - HighlightEvent.kind + HighlightEvent.kind, + AudioTrackEvent.kind, + PinListEvent.kind ), tags = mapOf("e" to listOf(it.idHex)), since = it.lastReactionsDownloadTime @@ -119,7 +121,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, PrivateDmEvent.kind, FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind, - HighlightEvent.kind + HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind ), ids = interestedEvents.toList() ) @@ -152,22 +154,30 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { } fun add(eventId: Note) { - eventsToWatch = eventsToWatch.plus(eventId) - invalidateFilters() + if (!eventsToWatch.contains(eventId)) { + eventsToWatch = eventsToWatch.plus(eventId) + invalidateFilters() + } } fun remove(eventId: Note) { - eventsToWatch = eventsToWatch.minus(eventId) - invalidateFilters() + if (eventsToWatch.contains(eventId)) { + eventsToWatch = eventsToWatch.minus(eventId) + invalidateFilters() + } } fun addAddress(aTag: Note) { - addressesToWatch = addressesToWatch.plus(aTag) - invalidateFilters() + if (!addressesToWatch.contains(aTag)) { + addressesToWatch = addressesToWatch.plus(aTag) + invalidateFilters() + } } fun removeAddress(aTag: Note) { - addressesToWatch = addressesToWatch.minus(aTag) - invalidateFilters() + if (addressesToWatch.contains(aTag)) { + addressesToWatch = addressesToWatch.minus(aTag) + invalidateFilters() + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index ba54572801..d6991c53fe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -64,12 +64,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { } fun add(user: User) { - usersToWatch = usersToWatch.plus(user) - invalidateFilters() + if (!usersToWatch.contains(user)) { + usersToWatch = usersToWatch.plus(user) + invalidateFilters() + } } fun remove(user: User) { - usersToWatch = usersToWatch.minus(user) - invalidateFilters() + if (usersToWatch.contains(user)) { + usersToWatch = usersToWatch.minus(user) + invalidateFilters() + } } } 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 a0e216f6ea..5b9df58264 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -11,7 +11,6 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun loadUserProfile(user: User?) { this.user = user - resetFilters() } fun createUserInfoFilter() = user?.let { @@ -29,7 +28,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), + kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = listOf(it.pubkeyHex), limit = 200 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index a9459b21c9..78ea520189 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.lnurl import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.service.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -10,14 +11,13 @@ import kotlinx.coroutines.withContext import nostr.postr.Bech32 import okhttp3.Call import okhttp3.Callback -import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import java.math.BigDecimal import java.net.URLEncoder -class LightningAddressResolver { - val client = OkHttpClient.Builder().build() +class LightningAddressResolver() { + val client = HttpClient.getHttpClient() fun assembleUrl(lnaddress: String): String? { val parts = lnaddress.split("@") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt index bc5d600838..47183238e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt @@ -4,7 +4,7 @@ import java.util.regex.Pattern object LnWithdrawalUtil { private val withdrawalPattern = Pattern.compile( - "lnurl.*", + "lnurl.+", Pattern.CASE_INSENSITIVE ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt index 24d7058358..10749bc4c0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.hexToByteArray import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.service.nip19.Tlv @@ -9,6 +10,7 @@ import nostr.postr.Bech32 import nostr.postr.bechToBytes import nostr.postr.toByteArray +@Immutable data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) { fun toTag() = "$kind:$pubKeyHex:$dTag" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt new file mode 100644 index 0000000000..e7c543dd27 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt @@ -0,0 +1,63 @@ +package com.vitorpamplona.amethyst.service.model + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +@Immutable +class AudioTrackEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) + + fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) + fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1) + fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1) + fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1) + fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1) + + companion object { + const val kind = 31337 + + private const val TYPE = "c" + private const val PRICE = "price" + private const val COVER = "cover" + private const val SUBJECT = "subject" + private const val MEDIA = "media" + + fun create( + type: String, + media: String, + price: String? = null, + cover: String? = null, + subject: String? = null, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): AudioTrackEvent { + val tags = listOfNotNull( + listOf(MEDIA, media), + listOf(TYPE, type), + price?.let { listOf(PRICE, it) }, + cover?.let { listOf(COVER, it) }, + subject?.let { listOf(SUBJECT, it) } + ) + + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, "") + val sig = Utils.sign(id, privateKey) + return AudioTrackEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey()) + } + } +} + +data class Participant(val key: String, val role: String?) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt index c16abc20e1..082322b110 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeAwardEvent.kt @@ -1,7 +1,9 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey +@Immutable class BadgeAwardEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt index 42f5598775..5a4d25c16e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeDefinitionEvent.kt @@ -1,7 +1,9 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey +@Immutable class BadgeDefinitionEvent( id: HexKey, pubKey: HexKey, @@ -9,9 +11,9 @@ class BadgeDefinitionEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt index 4995c413ba..3acb994f77 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BadgeProfilesEvent.kt @@ -1,7 +1,9 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey +@Immutable class BadgeProfilesEvent( id: HexKey, pubKey: HexKey, @@ -9,7 +11,7 @@ class BadgeProfilesEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { val aTagValue = it.getOrNull(1) @@ -18,8 +20,8 @@ class BadgeProfilesEvent( if (aTagValue != null) ATag.parse(aTagValue, relay) else null } - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) companion object { const val kind = 30008 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 93f576f058..201622544d 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 @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.tagSearch import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex +@Immutable open class BaseTextNoteEvent( id: HexKey, pubKey: HexKey, @@ -19,6 +21,7 @@ open class BaseTextNoteEvent( open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } private var citedUsersCache: Set? = null + private var citedNotesCache: Set? = null fun citedUsers(): Set { citedUsersCache?.let { return it } @@ -61,8 +64,10 @@ open class BaseTextNoteEvent( return returningList } - fun findCitations(): Set { - var citations = mutableSetOf() + fun findCitations(): Set { + citedNotesCache?.let { return it } + + val citations = mutableSetOf() // Removes citations from replies: val matcher = tagSearch.matcher(content) while (matcher.find()) { @@ -102,6 +107,7 @@ open class BaseTextNoteEvent( } } + citedNotesCache = citations return citations } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt index 71f1ad7e6d..d11b079a13 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class BookmarkListEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt index f9205b61a1..8123144b72 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class ChannelCreateEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt index 5de80f0b6e..b2ed4c4b42 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class ChannelHideMessageEvent( id: HexKey, pubKey: HexKey, 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 5fb98d1e39..471419f222 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 @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class ChannelMessageEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt index 6382e588cd..b3db7553e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class ChannelMetadataEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt index 79988b4de8..626dbf5295 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class ChannelMuteUserEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt index d141c3fd83..b100990737 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.decodePublicKey @@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable data class Contact(val pubKeyHex: String, val relayUri: String?) class ContactListEvent( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/DeletionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/DeletionEvent.kt index 4d7170f61b..9c3613569c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/DeletionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/DeletionEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class DeletionEvent( id: HexKey, pubKey: HexKey, 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 8accaaf8a0..dcc0ad35d9 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 @@ -1,5 +1,7 @@ package com.vitorpamplona.amethyst.service.model +import android.util.Log +import androidx.compose.runtime.Immutable import com.google.gson.* import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey @@ -13,6 +15,7 @@ import java.math.BigDecimal import java.security.MessageDigest import java.util.* +@Immutable open class Event( val id: HexKey, @SerializedName("pubkey") val pubKey: HexKey, @@ -110,11 +113,12 @@ open class Event( } override fun hasValidSignature(): Boolean { - if (!id.contentEquals(generateId())) { - return false + return try { + id.contentEquals(generateId()) && secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) + } catch (e: Exception) { + Log.e("Event", "Fail checking if event $id has a valid signature", e) + false } - - return secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) } private fun generateId(): String { @@ -218,6 +222,7 @@ open class Event( fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient) fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) { + AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) @@ -241,6 +246,7 @@ open class Event( LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) + PinListEvent.kind -> PinListEvent(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) @@ -280,3 +286,9 @@ open class Event( } } } + +@Immutable +interface AddressableEvent { + fun dTag(): String + fun address(): ATag +} 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 5a4e1db57e..de9275d7bf 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 @@ -1,8 +1,10 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import java.math.BigDecimal +@Immutable interface EventInterface { fun id(): HexKey diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt index a33344af66..ca72864f97 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class FileHeaderEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt index af25b41524..13fc5d03d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Base64 import java.util.Date +@Immutable class FileStorageEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt index f2ba729bb1..c84de54ada 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class FileStorageHeaderEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt index 6b1940e47f..2594c74f56 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.hexToByteArray import nostr.postr.Utils +@Immutable abstract class GeneralListEvent( id: HexKey, pubKey: HexKey, @@ -14,21 +16,23 @@ abstract class GeneralListEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) fun category() = dTag() fun bookmarkedPosts() = taggedEvents() fun bookmarkedPeople() = taggedUsers() fun plainContent(privKey: ByteArray): String? { + if (content.isBlank()) return null + return try { val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray()) return Utils.decrypt(content, sharedSecret) } catch (e: Exception) { - Log.w("GeneralList", "Error decrypting the message ${e.message}") + Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}") null } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt index 1d3d1acf67..6bb49cb085 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class HighlightEvent( id: HexKey, pubKey: HexKey, 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 a326f5795d..59e61c309a 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,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.relays.Client +@Immutable class LnZapEvent( id: HexKey, pubKey: HexKey, 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 934dba55b2..13921deee6 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 @@ -1,7 +1,9 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import java.math.BigDecimal +@Immutable interface LnZapEventInterface : EventInterface { fun zappedPost(): List diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt index 4946fb4c99..9975ae8142 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement @@ -12,6 +13,7 @@ import nostr.postr.Utils import java.lang.reflect.Type import java.util.Date +@Immutable class LnZapPaymentRequestEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index 5d8ec2e95d..4e4c41a90e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement @@ -10,6 +11,7 @@ import com.vitorpamplona.amethyst.model.HexKey import nostr.postr.Utils import java.lang.reflect.Type +@Immutable class LnZapPaymentResponseEvent( id: HexKey, pubKey: HexKey, 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 8586849a6a..aa60dcb6f4 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,5 +1,6 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.* import nostr.postr.Bech32 import nostr.postr.Utils @@ -11,6 +12,7 @@ import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +@Immutable class LnZapRequestEvent( id: HexKey, pubKey: HexKey, @@ -71,7 +73,7 @@ class LnZapRequestEvent( listOf("p", originalNote.pubKey()), listOf("relays") + relays ) - if (originalNote is LongTextNoteEvent) { + if (originalNote is AddressableEvent) { tags = tags + listOf(listOf("a", originalNote.address().toTag())) } if (pollOption != null && pollOption >= 0) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt index 46f136248b..f2fe3de388 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class LongTextNoteEvent( id: HexKey, pubKey: HexKey, @@ -12,9 +14,9 @@ class LongTextNoteEvent( tags: List>, content: String, sig: HexKey -) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) +) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt index 7cec19bcaf..766d1e79d1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.google.gson.Gson import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.HexKey @@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable data class ContactMetaData( val name: String, val picture: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt index a9f639a367..afd65cfb77 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MuteListEvent.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.hexToByteArray @@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class MuteListEvent( id: HexKey, pubKey: HexKey, @@ -15,9 +17,9 @@ class MuteListEvent( tags: List>, content: String, sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" - fun address() = ATag(kind, pubKey, dTag(), null) +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) fun plainContent(privKey: ByteArray): String? { return try { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt index 583f534b5f..51d9fa305b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class PeopleListEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PinListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PinListEvent.kt new file mode 100644 index 0000000000..e5f6e69a21 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PinListEvent.kt @@ -0,0 +1,43 @@ +package com.vitorpamplona.amethyst.service.model + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +@Immutable +class PinListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent { + + override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" + override fun address() = ATag(kind, pubKey, dTag(), null) + + fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] } + + companion object { + const val kind = 33888 + + fun create( + pins: List, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): PinListEvent { + val tags = mutableListOf>() + pins.forEach { + tags.add(listOf("pin", it)) + } + + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, "") + val sig = Utils.sign(id, privateKey) + return PinListEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey()) + } + } +} 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 index 0cb71f8054..10f1541938 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils @@ -11,6 +12,7 @@ const val VALUE_MINIMUM = "value_minimum" const val CONSENSUS_THRESHOLD = "consensus_threshold" const val CLOSED_AT = "closed_at" +@Immutable class PollNoteEvent( id: HexKey, pubKey: HexKey, 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 c10837f3ac..435e3b3023 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 @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import fr.acinq.secp256k1.Hex @@ -8,6 +9,7 @@ import nostr.postr.Utils import nostr.postr.toHex import java.util.Date +@Immutable class PrivateDmEvent( id: HexKey, pubKey: HexKey, @@ -21,7 +23,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.size > 1 && it[0] == "p" } + private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" } fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull() 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 13188520c4..e2a23ecb7f 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 @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class ReactionEvent( id: HexKey, pubKey: HexKey, @@ -32,7 +34,7 @@ class ReactionEvent( val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey())) - if (originalNote is LongTextNoteEvent) { + if (originalNote is AddressableEvent) { tags = tags + listOf(listOf("a", originalNote.address().toTag())) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RecommendRelayEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RecommendRelayEvent.kt index 117931b999..8cdc833dcf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RecommendRelayEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RecommendRelayEvent.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.net.URI import java.util.Date +@Immutable class RecommendRelayEvent( id: HexKey, pubKey: HexKey, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt index 6a7e042287..388daefdcb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable class RelayAuthEvent( id: HexKey, pubKey: HexKey, 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 d3bd9dd97a..cdb075f3ab 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 @@ -1,13 +1,16 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import nostr.postr.Utils import java.util.Date +@Immutable data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType) // NIP 56 event. +@Immutable class ReportEvent( id: HexKey, pubKey: HexKey, @@ -63,7 +66,7 @@ class ReportEvent( val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags: List> = listOf(reportPostTag, reportAuthorTag) - if (reportedPost is LongTextNoteEvent) { + if (reportedPost is AddressableEvent) { tags = tags + listOf(listOf("a", reportedPost.address().toTag())) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt index d5e0f1eb91..6b2053afd7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.service.relays.Client import nostr.postr.Utils import java.util.Date +@Immutable class RepostEvent( id: HexKey, pubKey: HexKey, @@ -36,7 +38,7 @@ class RepostEvent( val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags: List> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor)) - if (boostedPost is LongTextNoteEvent) { + if (boostedPost is AddressableEvent) { tags = tags + listOf(listOf("a", boostedPost.address().toTag())) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index b9bc6ab558..8f7b5bb56d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.service.model +import androidx.compose.runtime.Immutable import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.model.HexKey @@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags import nostr.postr.Utils import java.util.Date +@Immutable class TextNoteEvent( id: HexKey, pubKey: HexKey, @@ -43,6 +45,7 @@ class TextNoteEvent( } findHashtags(msg).forEach { tags.add(listOf("t", it)) + tags.add(listOf("t", it.lowercase())) } extraTags?.forEach { tags.add(listOf("t", it)) 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 460f1488e8..6bfc27e69f 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 @@ -20,7 +20,7 @@ object Nip19 { val hex: String, val relay: String? = null, val author: String? = null, - val kind: Long? = null, + val kind: Int? = null, val additionalChars: String = "" ) @@ -109,7 +109,7 @@ object Nip19 { val kind = tlv.get(Tlv.Type.KIND.id) ?.get(0) - ?.let { Tlv.toInt32(it) }?.toLong() + ?.let { Tlv.toInt32(it) } return Return(Type.EVENT, hex, relay, author, kind) } @@ -140,7 +140,7 @@ object Nip19 { val kind = tlv.get(Tlv.Type.KIND.id) ?.get(0) - ?.let { Tlv.toInt32(it) }?.toLong() + ?.let { Tlv.toInt32(it) } return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt new file mode 100644 index 0000000000..03be8b972a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -0,0 +1,99 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.NotificationManager +import android.content.Context +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification +import com.vitorpamplona.amethyst.ui.note.showAmount + +class EventNotificationConsumer(private val applicationContext: Context) { + fun consume(event: Event) { + // adds to database + LocalCache.consume(event, null) + + when (event) { + is PrivateDmEvent -> notify(event) + is LnZapEvent -> notify(event) + } + } + + private fun notify(event: PrivateDmEvent) { + val note = LocalCache.notes[event.id] ?: return + + LocalPreferences.allSavedAccounts().forEach { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + + if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { + val followingKeySet = acc.followingKeySet() + + val messagingWith = acc.userProfile().privateChatrooms.keys.filter { + ( + it.pubkeyHex in followingKeySet || acc.userProfile() + .hasSentMessagesTo(it) + ) && !acc.isHidden(it) + }.toSet() + + if (note.author in messagingWith) { + val content = acc.decryptContent(note) ?: "" + val user = note.author?.toBestDisplayName() ?: "" + val userPicture = note.author?.profilePicture() + val noteUri = note.toNEvent() + notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext) + } + } + } + } + + private fun notify(event: LnZapEvent) { + val noteZapEvent = LocalCache.notes[event.id] ?: return + val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } + val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } + + LocalPreferences.allSavedAccounts().forEach { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + + if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { + val amount = showAmount(event.amount) + val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let { + val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest) + if (decryptedContent != null) { + val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + Pair(author, decryptedContent.content) + } else if (!noteZapRequest.event?.content().isNullOrBlank()) { + Pair(noteZapRequest.author, noteZapRequest.event?.content()) + } else { + Pair(noteZapRequest.author, null) + } + } + + val zappedContent = + noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) } + + val user = senderInfo?.first?.toBestDisplayName() ?: "" + var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) + senderInfo?.second?.ifBlank { null }?.let { + title += " ($it)" + } + var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user) + zappedContent?.let { + content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent) + } + val userPicture = senderInfo?.first?.profilePicture() + val noteUri = "nostr:Notifications" + notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext) + } + } + } + + private fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt new file mode 100644 index 0000000000..36a5bcb847 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -0,0 +1,185 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.core.app.NotificationCompat +import coil.ImageLoader +import coil.executeBlocking +import coil.request.ImageRequest +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.MainActivity + +object NotificationUtils { + private var dmChannel: NotificationChannel? = null + private var zapChannel: NotificationChannel? = null + + private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { + if (dmChannel != null) return dmChannel!! + + dmChannel = NotificationChannel( + applicationContext.getString(R.string.app_notification_dms_channel_id), + applicationContext.getString(R.string.app_notification_dms_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = applicationContext.getString(R.string.app_notification_dms_channel_description) + } + + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(dmChannel!!) + + return dmChannel!! + } + + private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel { + if (zapChannel != null) return zapChannel!! + + zapChannel = NotificationChannel( + applicationContext.getString(R.string.app_notification_zaps_channel_id), + applicationContext.getString(R.string.app_notification_zaps_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = applicationContext.getString(R.string.app_notification_zaps_channel_description) + } + + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(zapChannel!!) + + return zapChannel!! + } + + fun NotificationManager.sendZapNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context + ) { + val zapChannel = getOrCreateZapChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) + + sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) + } + + fun NotificationManager.sendDMNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context + ) { + val dmChannel = getOrCreateDMChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) + + sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) + } + + fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + channelId: String, + applicationContext: Context + ) { + if (pictureUrl != null) { + val request = ImageRequest.Builder(applicationContext) + .data(pictureUrl) + .build() + + val imageLoader = ImageLoader(applicationContext) + val imageResult = imageLoader.executeBlocking(request) + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + picture = imageResult.drawable as? BitmapDrawable, + uri = uri, + channelId, + applicationContext = applicationContext + ) + } else { + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + picture = null, + uri = uri, + channelId, + applicationContext = applicationContext + ) + } + } + + private fun NotificationManager.sendNotification( + id: String, + messageBody: String, + messageTitle: String, + picture: BitmapDrawable?, + uri: String, + channelId: String, + applicationContext: Context + ) { + val contentIntent = Intent(applicationContext, MainActivity::class.java).apply { + data = Uri.parse(uri) + } + + val contentPendingIntent = PendingIntent.getActivity( + applicationContext, + id.hashCode(), + contentIntent, + PendingIntent.FLAG_MUTABLE + ) + + // Build the notification + val builderPublic = NotificationCompat.Builder( + applicationContext, + channelId + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(applicationContext.getString(R.string.app_notification_private_message)) + .setLargeIcon(picture?.bitmap) + .setGroup(messageTitle) + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + // Build the notification + val builder = NotificationCompat.Builder( + applicationContext, + channelId + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(messageBody) + .setLargeIcon(picture?.bitmap) + .setGroup(messageTitle) + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + notify(id.hashCode(), builder.build()) + } + + /** + * Cancels all notifications. + */ + fun NotificationManager.cancelNotifications() { + cancelAll() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt new file mode 100644 index 0000000000..f3772e3e49 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -0,0 +1,73 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.util.Log +import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.model.RelayAuthEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class RegisterAccounts( + private val accounts: List +) { + + // creates proof that it controls all accounts + private fun signEventsToProveControlOfAccounts( + accounts: List, + notificationToken: String + ): List { + return accounts.mapNotNull { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + if (acc != null) { + val relayToUse = acc.activeRelays()?.firstOrNull { it.read } + if (relayToUse != null) { + acc.createAuthEvent(relayToUse, notificationToken) + } else { + null + } + } else { + null + } + } + } + + private fun postRegistrationEvent(events: List) { + try { + val jsonObject = """{ + "events": [ ${events.joinToString(", ") { it.toJson() }} ] + } + """ + + val mediaType = "application/json; charset=utf-8".toMediaType() + val body = jsonObject.toRequestBody(mediaType) + + val request = Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url("https://push.amethyst.social/register") + .post(body) + .build() + + val client = HttpClient.getHttpClient() + + client.newCall(request).execute() + } catch (e: java.lang.Exception) { + Log.e("FirebaseMsgService", "Unable to register with push server", e) + } + } + + fun go(notificationToken: String) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + postRegistrationEvent( + signEventsToProveControlOfAccounts(accounts, notificationToken) + ) + } + } +} 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 256e1b8045..33a02c40ee 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.service.relays +import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.DelicateCoroutinesApi @@ -69,7 +70,7 @@ object Client : RelayPool.Listener { if (relay == null) { RelayPool.send(signedEvent) } else { - val useConnectedRelayIfPresent = relays.filter { it.url == relay } + val useConnectedRelayIfPresent = RelayPool.getRelays(relay) if (useConnectedRelayIfPresent.isNotEmpty()) { useConnectedRelayIfPresent.forEach { @@ -91,7 +92,7 @@ object Client : RelayPool.Listener { @OptIn(DelicateCoroutinesApi::class) fun newSporadicRelay(url: String, feedTypes: Set?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) { - val relay = Relay(url, true, true, feedTypes ?: emptySet()) + val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy()) RelayPool.addRelay(relay) relay.requestAndWatch { @@ -102,7 +103,7 @@ object Client : RelayPool.Listener { onConnected(relay) GlobalScope.launch(Dispatchers.IO) { - delay(10000) // waits for a reply + delay(60000) // waits for a reply relay.disconnect() RelayPool.removeRelay(relay) 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..d62179f8d4 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 @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.amethyst.model.RelaySetupInfo +import com.vitorpamplona.amethyst.service.HttpClient object Constants { val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) @@ -10,7 +11,7 @@ object Constants { fun convertDefaultRelays(): Array { return defaultRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) + Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy()) }.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 280e7bbe71..b4e0a93410 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 @@ -11,6 +11,8 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.net.Proxy +import java.time.Duration import java.util.Date enum class FeedType { @@ -23,9 +25,16 @@ 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? ) { + val seconds = if (proxy != null) 20L else 10L + val duration = Duration.ofSeconds(seconds) private val httpClient = OkHttpClient.Builder() + .proxy(proxy) + .readTimeout(duration) + .connectTimeout(duration) + .writeTimeout(duration) .followRedirects(true) .followSslRedirects(true) .build() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index 702bdc1b2e..23f2c4a254 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -30,6 +30,10 @@ object RelayPool : Relay.Listener { return relays.firstOrNull() { it.url == url } } + fun getRelays(url: String): List { + return relays.filter { it.url == url } + } + fun loadRelays(relayList: List) { if (!relayList.isNullOrEmpty()) { relayList.forEach { addRelay(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 808caf46f6..f758617e89 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -20,7 +20,13 @@ import coil.decode.SvgDecoder import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.VideoCache +import com.vitorpamplona.amethyst.service.HttpClient +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.PrivateDmEvent import com.vitorpamplona.amethyst.service.nip19.Nip19 +import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.components.muted import com.vitorpamplona.amethyst.ui.navigation.Route @@ -39,22 +45,9 @@ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val nip19 = Nip19.uriToRoute(intent?.data?.toString()) - val startingPage = when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> "Event/${nip19.hex}" - Nip19.Type.ADDRESS -> "Note/${nip19.hex}" - else -> null - } ?: try { - intent?.data?.toString()?.let { - Nip47.parse(it) - val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) - Route.Home.base + "?nip47=" + encodedUri - } - } catch (e: Exception) { - null - } + val uri = intent?.data?.toString() + + val startingPage = uriToRoute(uri) // Initializes video cache. VideoCache.init(this.applicationContext) @@ -68,6 +61,7 @@ class MainActivity : FragmentActivity() { } add(SvgDecoder.Factory()) } // .logger(DebugLogger()) + .okHttpClient { HttpClient.getHttpClient() } .respectCacheHeaders(false) .build() } @@ -100,6 +94,8 @@ class MainActivity : FragmentActivity() { GlobalScope.launch(Dispatchers.IO) { ServiceManager.start() } + + PushNotificationUtils().init(LocalPreferences.allSavedAccounts()) } override fun onPause() { @@ -128,3 +124,39 @@ class GetMediaActivityResultContract : ActivityResultContracts.GetContent() { } } } + +fun uriToRoute(uri: String?): String? { + return if (uri.equals("nostr:Notifications", true)) { + Route.Notification.route.replace("{scrollToTop}", "true") + } else { + if (uri?.startsWith("nostr:Hashtag?id=") == true) { + Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id=")) + } else { + val nip19 = Nip19.uriToRoute(uri) + when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + Nip19.Type.EVENT -> { + if (nip19.kind == PrivateDmEvent.kind) { + "Room/${nip19.author}" + } else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) { + "Channel/${nip19.hex}" + } else { + "Event/${nip19.hex}" + } + } + + Nip19.Type.ADDRESS -> "Note/${nip19.hex}" + else -> null + } + } ?: try { + uri?.let { + Nip47.parse(it) + val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) + Route.Home.base + "?nip47=" + encodedUri + } + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt index 6514c5683c..29ba8da535 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt @@ -10,6 +10,7 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.service.HttpClient import okhttp3.* import okio.BufferedSource import okio.IOException @@ -32,7 +33,7 @@ object ImageSaver { onSuccess: () -> Any?, onError: (Throwable) -> Any? ) { - val client = OkHttpClient.Builder().build() + val client = HttpClient.getHttpClient() val request = Request.Builder() .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index 52a83020fe..3a0806ff21 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -6,6 +6,7 @@ import android.provider.OpenableColumns import android.webkit.MimeTypeMap import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.service.HttpClient import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okio.BufferedSink @@ -39,9 +40,9 @@ object ImageUploader { "Can't open the image input stream" } val myServer = when (server) { - ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> { - ImgurServer() - } + // ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> { + // ImgurServer() + // } ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> { NostrImgServer() } @@ -52,7 +53,7 @@ object ImageUploader { NostrFilesDevServer() } else -> { - ImgurServer() + NostrBuildServer() } } @@ -70,7 +71,7 @@ object ImageUploader { val fileName = randomChars() val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - val client = OkHttpClient.Builder().build() + val client = HttpClient.getHttpClient() val requestBody: RequestBody val requestBuilder = Request.Builder() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt new file mode 100644 index 0000000000..eda028bf0b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -0,0 +1,351 @@ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +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.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.note.ChannelName +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) { + val searchBarViewModel: SearchBarViewModel = viewModel() + searchBarViewModel.account = account + + Dialog( + onDismissRequest = { + NostrSearchEventOrUserDataSource.clear() + searchBarViewModel.clean() + onClose() + }, + properties = DialogProperties( + dismissOnClickOutside = false + ) + ) { + Surface() { + Column( + modifier = Modifier.padding(10.dp).heightIn(min = 500.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + searchBarViewModel.clean() + NostrSearchEventOrUserDataSource.clear() + onClose() + }) + + Text( + text = stringResource(R.string.channel_list_join_conversation), + fontWeight = FontWeight.Bold + ) + + Text( + text = "", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(15.dp)) + + RenderSeach(searchBarViewModel, account, navController) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun RenderSeach( + searchBarViewModel: SearchBarViewModel, + account: Account, + navController: NavController +) { + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + // initialize focus reference to be able to request focus programmatically + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + val onlineSearch = NostrSearchEventOrUserDataSource + + val lifeCycleOwner = LocalLifecycleOwner.current + + // Create a channel for processing search queries. + val searchTextChanges = remember { + Channel(Channel.CONFLATED) + } + + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + if (searchBarViewModel.isSearching()) { + searchBarViewModel.invalidateData() + } + } + } + } + + LaunchedEffect(Unit) { + delay(100) + focusRequester.requestFocus() + + // Wait for text changes to stop for 300 ms before firing off search. + withContext(Dispatchers.IO) { + searchTextChanges.receiveAsFlow() + .filter { it.isNotBlank() } + .distinctUntilChanged() + .debounce(300) + .collectLatest { + if (it.length >= 2) { + onlineSearch.search(it.trim()) + } + + searchBarViewModel.invalidateData() + + // makes sure to show the top of the search + scope.launch(Dispatchers.Main) { + listState.animateScrollToItem(0) + } + } + } + } + + DisposableEffect(Unit) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Join Start") + NostrSearchEventOrUserDataSource.start() + searchBarViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Join Stop") + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + // LAST ROW + Row( + modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) }, + value = searchBarViewModel.searchValue, + onValueChange = { + searchBarViewModel.updateSearchValue(it) + scope.launch(Dispatchers.IO) { + searchTextChanges.trySend(it) + } + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + }, + modifier = Modifier + .weight(1f, true) + .defaultMinSize(minHeight = 20.dp) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringResource(R.string.channel_list_user_or_group_id_demo), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + trailingIcon = { + if (searchBarViewModel.isTrailingIconVisible) { + IconButton( + onClick = { + searchBarViewModel.clean() + onlineSearch.clear() + } + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear) + ) + } + } + } + ) + } + + if (searchBarViewModel.searchValue.isNotBlank()) { + Row( + modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp) + ) { + LazyColumn( + modifier = Modifier.fillMaxHeight(), + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ), + state = listState + ) { + itemsIndexed( + searchBarViewModel.searchResults.value, + key = { _, item -> "u" + item.pubkeyHex } + ) { _, item -> + UserComposeForChat( + item, + account = account, + navController = navController + ) + } + + itemsIndexed( + searchBarViewModel.searchResultsChannels.value, + key = { _, item -> "c" + item.idHex } + ) { _, item -> + ChannelName( + channelIdHex = item.idHex, + channelPicture = item.profilePicture(), + channelTitle = { + Text( + "${item.info.name}", + fontWeight = FontWeight.Bold + ) + }, + channelLastTime = null, + channelLastContent = item.info.about, + false, + onClick = { navController.navigate("Channel/${item.idHex}") } + ) + } + } + } + } +} + +@Composable +fun UserComposeForChat( + baseUser: User, + account: Account, + navController: NavController +) { + Column( + modifier = + Modifier.clickable( + onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") } + ) + ) { + Row( + modifier = Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + UserPicture(baseUser, navController, account.userProfile(), 55.dp) + + Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UsernameDisplay(baseUser) + } + + val baseUserState by baseUser.live().metadata.observeAsState() + val user = baseUserState?.user ?: return + + Text( + user.info?.about ?: "", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index e8a6398ec5..4aae8742a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -38,9 +38,10 @@ open class NewMediaModel : ViewModel() { this.mediaType = contentType this.selectedServer = defaultServer() - if (selectedServer == ServersAvailable.IMGUR) { - selectedServer = ServersAvailable.IMGUR_NIP_94 - } else if (selectedServer == ServersAvailable.NOSTRIMG) { + // if (selectedServer == ServersAvailable.IMGUR) { + // selectedServer = ServersAvailable.IMGUR_NIP_94 + // } else + if (selectedServer == ServersAvailable.NOSTRIMG) { selectedServer = ServersAvailable.NOSTRIMG_NIP_94 } else if (selectedServer == ServersAvailable.NOSTR_BUILD) { selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 955e0a4fcc..cd24d89d52 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -111,7 +111,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac fun isNIP94Server(selectedServer: ServersAvailable?): Boolean { return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || - selectedServer == ServersAvailable.IMGUR_NIP_94 || + // selectedServer == ServersAvailable.IMGUR_NIP_94 || selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 || selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94 } @@ -121,7 +121,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { val scope = rememberCoroutineScope() val fileServers = listOf( - Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), + // Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)), 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 620e393c54..b96e0c61c2 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 @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions import android.graphics.Bitmap import android.net.Uri import android.os.Build +import android.util.Log import android.util.Size import android.widget.Toast import androidx.compose.foundation.BorderStroke @@ -24,8 +25,10 @@ import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.Bolt import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect 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.rememberCoroutineScope @@ -47,6 +50,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -58,9 +62,9 @@ import coil.compose.AsyncImage 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.model.User +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.ui.components.* -import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine @@ -81,6 +85,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n val keyboardController = LocalSoftwareKeyboardController.current val scroolState = rememberScrollState() + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { postViewModel.load(account, baseReplyTo, quote) @@ -92,6 +97,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } + DisposableEffect(Unit) { + onDispose { + NostrSearchEventOrUserDataSource.clear() + } + } + Dialog( onDismissRequest = { onClose() }, properties = DialogProperties( @@ -145,10 +156,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n .fillMaxWidth() .verticalScroll(scroolState) ) { - if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) { - ReplyInformation(postViewModel.replyTos, postViewModel.mentions, account, "✖ ") { - postViewModel.removeFromReplyList(it) - } + Notifying(postViewModel.mentions) { + postViewModel.removeFromReplyList(it) } OutlinedTextField( @@ -219,6 +228,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n }, onCancel = { postViewModel.contentToAddUrl = null + }, + onError = { + scope.launch { + postViewModel.imageUploadingError.emit(it) + } } ) } @@ -345,6 +359,48 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun Notifying(baseMentions: List?, onClick: (User) -> Unit) { + val mentions = baseMentions?.toSet() + + FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) { + if (!mentions.isNullOrEmpty()) { + Text( + stringResource(R.string.reply_notify), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + mentions.forEachIndexed { idx, user -> + val innerUserState by user.live().metadata.observeAsState() + val innerUser = innerUserState?.user + + innerUser?.let { myUser -> + Spacer(modifier = Modifier.width(5.dp)) + + Button( + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + ), + onClick = { + onClick(myUser) + } + ) { + CreateTextWithEmoji( + text = "✖ ${myUser.toBestDisplayName()}", + tags = myUser.info?.latestMetadata?.tags, + color = Color.White, + textAlign = TextAlign.Center + ) + } + } + } + } + } +} + @Composable private fun AddPollButton( isPollActive: Boolean, @@ -585,11 +641,12 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } enum class ServersAvailable { - IMGUR, + // IMGUR, NOSTR_BUILD, NOSTRIMG, NOSTRFILES_DEV, - IMGUR_NIP_94, + + // IMGUR_NIP_94, NOSTRIMG_NIP_94, NOSTR_BUILD_NIP_94, NOSTRFILES_DEV_NIP_94, @@ -601,7 +658,8 @@ fun ImageVideoDescription( uri: Uri, defaultServer: ServersAvailable, onAdd: (String, ServersAvailable) -> Unit, - onCancel: () -> Unit + onCancel: () -> Unit, + onError: (String) -> Unit ) { val resolver = LocalContext.current.contentResolver val mediaType = resolver.getType(uri) ?: "" @@ -611,11 +669,11 @@ fun ImageVideoDescription( val isVideo = mediaType.startsWith("video") val fileServers = listOf( - Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)), + // Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)), Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)), Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)), Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)), - Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), + // Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)), @@ -709,7 +767,12 @@ fun ImageVideoDescription( LaunchedEffect(key1 = uri) { scope.launch(Dispatchers.IO) { - bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) + try { + bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) + } catch (e: Exception) { + onError("Unable to load file") + Log.e("NewPostView", "Couldn't create thumbnail for $uri") + } } } 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 4b12675d4d..c221152611 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 @@ -14,6 +14,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.isValidURL @@ -188,6 +189,8 @@ open class NewPostViewModel : ViewModel() { userSuggestions = emptyList() userSuggestionAnchor = null userSuggestionsMainMessage = null + + NostrSearchEventOrUserDataSource.clear() } open fun findUrlInMessage(): String? { @@ -198,8 +201,8 @@ open class NewPostViewModel : ViewModel() { } } - open fun removeFromReplyList(it: User) { - mentions = mentions?.minus(it) + open fun removeFromReplyList(userToRemove: User) { + mentions = mentions?.filter { it != userToRemove } } open fun updateMessage(it: TextFieldValue) { @@ -211,8 +214,10 @@ open class NewPostViewModel : ViewModel() { userSuggestionAnchor = it.selection userSuggestionsMainMessage = true if (lastWord.startsWith("@") && lastWord.length > 2) { - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed() } else { + NostrSearchEventOrUserDataSource.clear() userSuggestions = emptyList() } } @@ -225,8 +230,10 @@ open class NewPostViewModel : ViewModel() { userSuggestionAnchor = it.selection userSuggestionsMainMessage = false if (lastWord.startsWith("@") && lastWord.length > 2) { - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed() } else { + NostrSearchEventOrUserDataSource.clear() userSuggestions = emptyList() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt similarity index 50% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt index 771b5f6586..7a4d3dad86 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt @@ -6,69 +6,78 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add 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.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.actions.NewPollView +import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView +import com.vitorpamplona.amethyst.ui.actions.NewChannelView @Composable -fun FabColumn(account: Account) { +fun ChannelFabColumn(account: Account, navController: NavController) { var isOpen by remember { mutableStateOf(false) } - var wantsToPoll by remember { + + var wantsToJoinChannelOrUser by remember { mutableStateOf(false) } - var wantsToPost by remember { + + var wantsToCreateChannel by remember { mutableStateOf(false) } + if (wantsToCreateChannel) { + NewChannelView({ wantsToCreateChannel = false }, account = account) + } + + if (wantsToJoinChannelOrUser) { + JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, navController = navController) + } + Column() { if (isOpen) { OutlinedButton( - onClick = { - wantsToPoll = true - isOpen = false - }, - modifier = Modifier.size(45.dp), + onClick = { wantsToJoinChannelOrUser = true; isOpen = false }, + modifier = Modifier.size(55.dp), shape = CircleShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), - contentPadding = PaddingValues(0.dp) + contentPadding = PaddingValues(bottom = 3.dp) ) { - Icon( - painter = painterResource(R.drawable.ic_poll), - null, - modifier = Modifier.size(26.dp), - tint = Color.White + Text( + text = stringResource(R.string.channel_list_join_channel), + color = Color.White, + textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(20.dp)) OutlinedButton( - onClick = { - wantsToPost = true - isOpen = false - }, - modifier = Modifier.size(45.dp), + onClick = { wantsToCreateChannel = true; isOpen = false }, + modifier = Modifier.size(55.dp), shape = CircleShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), - contentPadding = PaddingValues(0.dp) + contentPadding = PaddingValues(bottom = 3.dp) ) { - Icon( - painter = painterResource(R.drawable.ic_lists), - null, - modifier = Modifier.size(26.dp), - tint = Color.White + Text( + text = stringResource(R.string.channel_list_create_channel), + color = Color.White, + textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(20.dp)) } + OutlinedButton( onClick = { isOpen = !isOpen }, modifier = Modifier.size(55.dp), @@ -77,19 +86,11 @@ fun FabColumn(account: Account) { contentPadding = PaddingValues(0.dp) ) { Icon( - painter = painterResource(R.drawable.ic_compose), - null, + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.new_channel), 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/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index ea6dff88f1..2a1036fb9b 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 @@ -8,8 +8,8 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.LinkedBlockingQueue 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 @@ -54,23 +54,26 @@ class BundledInsert( 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 - } + private var queue = LinkedBlockingQueue() + fun invalidateList(newObject: T, onUpdate: suspend (Set) -> Unit) { + queue.put(newObject) if (onlyOneInBlock.getAndSet(true)) { return } val scope = CoroutineScope(Job() + dispatcher) - scope.launch { + scope.launch(Dispatchers.IO) { try { - onUpdate(atomicSet.getAndSet(emptySet())) + val mySet = mutableSetOf() + queue.drainTo(mySet) + onUpdate(mySet) + delay(delay) - onUpdate(atomicSet.getAndSet(emptySet())) + + val mySet2 = mutableSetOf() + queue.drainTo(mySet2) + onUpdate(mySet2) } finally { withContext(NonCancellable) { onlyOneInBlock.set(false) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index c94931a409..acbc4b65f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -1,6 +1,14 @@ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -11,12 +19,30 @@ 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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import coil.compose.AsyncImage import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NIP30Parser import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.nip19.Nip19 @@ -58,36 +84,36 @@ private fun DisplayEvent( noteBase?.let { val noteState by it.live().metadata.observeAsState() - val note = noteState?.note ?: return - val channel = note.channel() + val note = remember(noteState) { noteState?.note } ?: return + val channel = remember(noteState) { note.channel() } if (note.event is ChannelCreateEvent) { CreateClickableText( - note.idDisplayNote(), - nip19.additionalChars, - "Channel/${nip19.hex}", - navController + clickablePart = "@${note.idDisplayNote()}", + suffix = "${nip19.additionalChars} ", + route = "Channel/${nip19.hex}", + navController = navController ) } else if (note.event is PrivateDmEvent) { CreateClickableText( - note.idDisplayNote(), - nip19.additionalChars, - "Room/${note.author?.pubkeyHex}", - navController + clickablePart = "@${note.idDisplayNote()}", + suffix = "${nip19.additionalChars} ", + route = "Room/${note.author?.pubkeyHex}", + navController = navController ) } else if (channel != null) { CreateClickableText( - channel.toBestDisplayName(), - nip19.additionalChars, - "Channel/${note.channel()?.idHex}", - navController + clickablePart = channel.toBestDisplayName(), + suffix = "${nip19.additionalChars} ", + route = "Channel/${channel.idHex}", + navController = navController ) } else { CreateClickableText( - note.idDisplayNote(), - nip19.additionalChars, - "Event/${nip19.hex}", - navController + clickablePart = "@${note.idDisplayNote()}", + suffix = "${nip19.additionalChars} ", + route = "Event/${nip19.hex}", + navController = navController ) } } @@ -114,36 +140,36 @@ private fun DisplayNote( noteBase?.let { val noteState by it.live().metadata.observeAsState() - val note = noteState?.note ?: return + val note = remember(noteState) { noteState?.note } ?: return val channel = note.channel() if (note.event is ChannelCreateEvent) { CreateClickableText( - note.idDisplayNote(), - nip19.additionalChars, - "Channel/${nip19.hex}", - navController + clickablePart = "@${note.idDisplayNote()}", + suffix = "${nip19.additionalChars} ", + route = "Channel/${nip19.hex}", + navController = navController ) } else if (note.event is PrivateDmEvent) { CreateClickableText( - note.idDisplayNote(), - nip19.additionalChars, - "Room/${note.author?.pubkeyHex}", - navController + clickablePart = "@${note.idDisplayNote()}", + suffix = "${nip19.additionalChars} ", + route = "Room/${note.author?.pubkeyHex}", + navController = navController ) } else if (channel != null) { CreateClickableText( - channel.toBestDisplayName(), - nip19.additionalChars, - "Channel/${note.channel()?.idHex}", - navController + clickablePart = channel.toBestDisplayName(), + suffix = "${nip19.additionalChars} ", + route = "Channel/${note.channel()?.idHex}", + navController = navController ) } else { CreateClickableText( - note.idDisplayNote(), - nip19.additionalChars, - "Note/${nip19.hex}", - navController + clickablePart = "@${note.idDisplayNote()}", + suffix = "${nip19.additionalChars} ", + route = "Note/${nip19.hex}", + navController = navController ) } } @@ -170,13 +196,13 @@ private fun DisplayAddress( noteBase?.let { val noteState by it.live().metadata.observeAsState() - val note = noteState?.note ?: return + val note = remember(noteState) { noteState?.note } ?: return CreateClickableText( - note.idDisplayNote(), - nip19.additionalChars, - "Note/${nip19.hex}", - navController + clickablePart = "@${note.idDisplayNote()}", + suffix = "${nip19.additionalChars} ", + route = "Note/${nip19.hex}", + navController = navController ) } @@ -202,14 +228,19 @@ private fun DisplayUser( userBase?.let { val userState by it.live().metadata.observeAsState() - val user = userState?.user ?: return + val route = remember { "User/${it.pubkeyHex}" } + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } - CreateClickableText( - user.toBestDisplayName(), - nip19.additionalChars, - "User/${nip19.hex}", - navController - ) + if (userDisplayName != null) { + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + suffix = "${nip19.additionalChars} ", + tags = userTags, + route = route, + navController = navController + ) + } } if (userBase == null) { @@ -220,20 +251,281 @@ private fun DisplayUser( } @Composable -fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) { +fun CreateClickableText( + clickablePart: String, + suffix: String, + overrideColor: Color? = null, + fontWeight: FontWeight = FontWeight.Normal, + route: String, + navController: NavController +) { ClickableText( text = buildAnnotatedString { withStyle( - LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() + LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle() ) { - append("@$clickablePart") + append(clickablePart) } withStyle( - LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle() + LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle() ) { - append("$suffix ") + append(suffix) } }, onClick = { navController.navigate(route) } ) } + +@Composable +fun CreateTextWithEmoji( + text: String, + tags: List>?, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontWeight: FontWeight? = null, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier +) { + val emojis = remember { + tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + } + + CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier) +} + +@Composable +fun CreateTextWithEmoji( + text: String, + emojis: Map, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + fontWeight: FontWeight? = null, + fontSize: TextUnit = TextUnit.Unspecified, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier +) { + val textColor = color.takeOrElse { + LocalTextStyle.current.color.takeOrElse { + LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + } + } + + if (emojis.isEmpty()) { + Text( + text = text, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier + ) + } else { + val myList = remember { + assembleAnnotatedList(text, emojis) + } + + val style = LocalTextStyle.current.merge( + TextStyle( + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize + ) + ).toSpanStyle() + + InLineIconRenderer(myList, style, maxLines, overflow, modifier) + } +} + +@Composable +fun CreateClickableTextWithEmoji( + clickablePart: String, + tags: List>?, + style: TextStyle, + onClick: (Int) -> Unit +) { + val emojis = remember(tags) { + tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + } + + if (emojis.isEmpty()) { + ClickableText( + AnnotatedString(clickablePart), + style = style, + onClick = onClick + ) + } else { + val myList = remember { + assembleAnnotatedList(clickablePart, emojis) + } + + ClickableInLineIconRenderer(myList, style.toSpanStyle()) { + onClick(it) + } + } +} + +@Composable +fun CreateClickableTextWithEmoji( + clickablePart: String, + suffix: String, + tags: List>?, + overrideColor: Color? = null, + fontWeight: FontWeight = FontWeight.Normal, + route: String, + navController: NavController +) { + val emojis = remember(tags) { + tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + } + + if (emojis.isEmpty()) { + CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, navController) + } else { + val myList = remember { + assembleAnnotatedList(clickablePart, emojis) + } + + ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) { + navController.navigate(route) + } + + val myList2 = remember { + assembleAnnotatedList(suffix, emojis) + } + + InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle()) + } +} + +fun assembleAnnotatedList(text: String, emojis: Map): List { + return NIP30Parser().buildArray(text).map { + val url = emojis[it] + if (url != null) { + ImageUrlType(url) + } else { + TextType(it) + } + } +} + +open class Renderable() +class TextType(val text: String) : Renderable() +class ImageUrlType(val url: String) : Renderable() + +@Composable +fun ClickableInLineIconRenderer(wordsInOrder: List, style: SpanStyle, onClick: (Int) -> Unit) { + val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value -> + if (value is ImageUrlType) { + Pair( + "inlineContent$idx", + InlineTextContent( + Placeholder( + width = 17.sp, + height = 17.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + AsyncImage( + model = value.url, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(1.dp) + ) + } + ) + } else { + null + } + }.associate { it.first to it.second } + + val annotatedText = buildAnnotatedString { + wordsInOrder.forEachIndexed { idx, value -> + withStyle( + style + ) { + if (value is TextType) { + append(value.text) + } else if (value is ImageUrlType) { + appendInlineContent("inlineContent$idx", "[icon]") + } + } + } + } + + val layoutResult = remember { mutableStateOf(null) } + val pressIndicator = Modifier.pointerInput(onClick) { + detectTapGestures { pos -> + layoutResult.value?.let { layoutResult -> + onClick(layoutResult.getOffsetForPosition(pos)) + } + } + } + + BasicText( + text = annotatedText, + modifier = pressIndicator, + inlineContent = inlineContent, + onTextLayout = { + layoutResult.value = it + } + ) +} + +@Composable +fun InLineIconRenderer( + wordsInOrder: List, + style: SpanStyle, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + modifier: Modifier = Modifier +) { + val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value -> + if (value is ImageUrlType) { + Pair( + "inlineContent$idx", + InlineTextContent( + Placeholder( + width = 17.sp, + height = 17.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + AsyncImage( + model = value.url, + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(1.dp) + ) + } + ) + } else { + null + } + }.associate { it.first to it.second } + + val annotatedText = buildAnnotatedString { + wordsInOrder.forEachIndexed { idx, value -> + withStyle( + style + ) { + if (value is TextType) { + append(value.text) + } else if (value is ImageUrlType) { + appendInlineContent("inlineContent$idx", "[icon]") + } + } + } + } + + Text( + text = annotatedText, + inlineContent = inlineContent, + maxLines = maxLines, + overflow = overflow, + modifier = modifier + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index ba0d79464f..8d622093f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -13,6 +13,7 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,10 +51,14 @@ fun ExpandableRichTextViewer( minOf(firstSpaceAfterCut, firstNewLineAfterCut) } - val text = if (showFullText) { - content - } else { - content.take(whereToCut) + val text by remember(content) { + derivedStateOf { + if (showFullText) { + content + } else { + content.take(whereToCut) + } + } } Box { 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 bc02931e50..d745aeecff 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 @@ -19,10 +19,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -43,7 +43,9 @@ 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 com.vitorpamplona.amethyst.ui.uriToRoute import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.MalformedURLException import java.net.URISyntaxException @@ -80,7 +82,8 @@ fun isMarkdown(content: String): Boolean { content.startsWith("# ") || content.contains("##") || content.contains("__") || - content.contains("```") + content.contains("```") || + content.contains("](") } @Composable @@ -97,7 +100,7 @@ fun RichTextViewer( Column(modifier = modifier) { if (isMarkdown) { - RenderContentAsMarkdown(content, backgroundColor) + RenderContentAsMarkdown(content, backgroundColor, tags, navController) } else { RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController) } @@ -106,9 +109,10 @@ fun RichTextViewer( class RichTextViewerState( val content: String, - val urlSet: LinkedHashSet, + val urlSet: Set, val imagesForPager: Map, - val imageList: List + val imageList: List, + val customEmoji: Map ) @Composable @@ -121,11 +125,13 @@ private fun RenderRegular( navController: NavController ) { var processedState by remember { - mutableStateOf(null) + mutableStateOf(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap())) } + val scope = rememberCoroutineScope() + LaunchedEffect(key1 = content) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } val imagesForPager = urlSet.mapNotNull { fullUrl -> @@ -140,7 +146,11 @@ private fun RenderRegular( }.associateBy { it.url } val imageList = imagesForPager.values.toList() - processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList) + val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + + if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) { + processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap) + } } } @@ -162,6 +172,8 @@ private fun RenderRegular( ZoomableContentView(img, state.imageList) } else if (state.urlSet.contains(word)) { UrlPreview(word, "$word ") + } else if (state.customEmoji.any { word.contains(it.key) }) { + RenderCustomEmoji(word, state.customEmoji) } else if (word.startsWith("lnbc", true)) { MayBeInvoicePreview(word) } else if (word.startsWith("lnurl", true)) { @@ -230,6 +242,8 @@ private fun RenderRegular( style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) ) } + } else if (state.customEmoji.any { word.contains(it.key) }) { + RenderCustomEmoji(word, state.customEmoji) } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { ClickableEmail(word) } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { @@ -289,7 +303,15 @@ private fun RenderRegular( } @Composable -private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) { +fun RenderCustomEmoji(word: String, customEmoji: Map) { + CreateTextWithEmoji( + text = "$word ", + emojis = customEmoji + ) +} + +@Composable +private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: List>?, navController: NavController) { val myMarkDownStyle = richTextDefaults.copy( codeBlockStyle = richTextDefaults.codeBlockStyle?.copy( textStyle = TextStyle( @@ -313,7 +335,6 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) { ), stringStyle = richTextDefaults.stringStyle?.copy( linkStyle = SpanStyle( - textDecoration = TextDecoration.Underline, color = MaterialTheme.colors.primary ), codeStyle = SpanStyle( @@ -331,14 +352,14 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) { LaunchedEffect(key1 = content) { withContext(Dispatchers.IO) { - nip19References = returnNIP19References(content) - markdownWithSpecialContent = returnMarkdownWithSpecialContent(content) + nip19References = returnNIP19References(content, tags) + markdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags) } } LaunchedEffect(key1 = refresh) { withContext(Dispatchers.IO) { - val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content) + val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags) if (markdownWithSpecialContent != newMarkdownWithSpecialContent) { markdownWithSpecialContent = newMarkdownWithSpecialContent } @@ -368,17 +389,23 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) { baseNote?.let { val noteState by it.live().metadata.observeAsState() if (noteState?.note?.event != null) { - refresh++ + LaunchedEffect(key1 = noteState) { + refresh++ + } } } baseUser?.let { val userState by it.live().metadata.observeAsState() if (userState?.user?.info != null) { - refresh++ + LaunchedEffect(key1 = userState) { + refresh++ + } } } } + val uri = LocalUriHandler.current + markdownWithSpecialContent?.let { MaterialRichText( style = myMarkDownStyle @@ -386,26 +413,70 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) { Markdown( content = it, markdownParseOptions = MarkdownParseOptions.Default - ) + ) { link -> + val route = uriToRoute(link) + if (route != null) { + navController.navigate(route) + } else { + runCatching { uri.openUri(link) } + } + } } } } -private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? { - if (nip19.type == Nip19.Type.USER) { - return LocalCache.users[nip19.hex]?.bestDisplayName() - } else if (nip19.type == Nip19.Type.NOTE) { - return LocalCache.notes[nip19.hex]?.idDisplayNote() - } else if (nip19.type == Nip19.Type.ADDRESS) { - return LocalCache.addressables[nip19.hex]?.idDisplayNote() - } else if (nip19.type == Nip19.Type.EVENT) { - return LocalCache.notes[nip19.hex]?.idDisplayNote() ?: LocalCache.addressables[nip19.hex]?.idDisplayNote() - } else { - return null +private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List>): Pair? { + val matcher = tagIndex.matcher(tag) + 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 $tag", e) + Pair(null, null) } + + 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 { + return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + } + } else if (tag[0] == "e" || tag[0] == "a") { + LocalCache.checkGetOrCreateNote(tag[1])?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } + } + } + + return null } -private fun returnNIP19References(content: String): List { +private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair? { + if (nip19.type == Nip19.Type.USER) { + LocalCache.users[nip19.hex]?.let { + return Pair(it.toBestDisplayName(), it.pubkeyNpub()) + } + } else if (nip19.type == Nip19.Type.NOTE) { + LocalCache.notes[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } else if (nip19.type == Nip19.Type.ADDRESS) { + LocalCache.addressables[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } else if (nip19.type == Nip19.Type.EVENT) { + LocalCache.notes[nip19.hex]?.let { + return Pair(it.idDisplayNote(), it.toNEvent()) + } + } + + return null +} + +private fun returnNIP19References(content: String, tags: List>?): List { val listOfReferences = mutableListOf() content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> @@ -417,10 +488,21 @@ private fun returnNIP19References(content: String): List { } } } + + tags?.forEach { + if (it[0] == "p" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, "")) + } else if (it[0] == "e" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, "")) + } else if (it[0] == "a" && it.size > 1) { + listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, "")) + } + } + return listOfReferences } -private fun returnMarkdownWithSpecialContent(content: String): String { +private fun returnMarkdownWithSpecialContent(content: String, tags: List>?): String { var returnContent = "" content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> @@ -433,15 +515,43 @@ private fun returnMarkdownWithSpecialContent(content: String): String { } else if (isBechLink(word)) { val parsedNip19 = Nip19.uriToRoute(word) returnContent += if (parsedNip19 !== null) { - val displayName = getDisplayNameFromNip19(parsedNip19) - if (displayName != null) { - "[@$displayName](nostr://$word) " + val pair = getDisplayNameFromNip19(parsedNip19) + if (pair != null) { + val (displayName, nip19) = pair + "[$displayName](nostr:$nip19) " } else { "$word " } } else { "$word " } + } else if (word.startsWith("#")) { + if (tagIndex.matcher(word).matches() && tags != null) { + val pair = getDisplayNameAndNIP19FromTag(word, tags) + if (pair != null) { + returnContent += "[${pair.first}](nostr:${pair.second}) " + } else { + returnContent += "$word " + } + } else if (hashTagsPattern.matcher(word).matches()) { + 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) { + returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix " + } else { + returnContent += "$word " + } + } else { + returnContent += "$word " + } } else { returnContent += "$word " } @@ -595,8 +705,10 @@ fun TagLink(word: String, tags: List>, canPreview: Boolean, backgro var baseUserPair by remember { mutableStateOf?>(null) } var baseNotePair by remember { mutableStateOf?>(null) } + val scope = rememberCoroutineScope() + LaunchedEffect(key1 = word) { - withContext(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { val matcher = tagIndex.matcher(word) val (index, suffix) = try { matcher.find() @@ -625,8 +737,24 @@ fun TagLink(word: String, tags: List>, canPreview: Boolean, backgro } baseUserPair?.let { - ClickableUserTag(it.first, navController) - Text(text = "${it.second} ") + val innerUserState by it.first.live().metadata.observeAsState() + val displayName = remember(innerUserState) { + innerUserState?.user?.toBestDisplayName() ?: "" + } + val route = remember(innerUserState) { + "User/${it.first.pubkeyHex}" + } + val userTags = remember(innerUserState) { + innerUserState?.user?.info?.latestMetadata?.tags + } + + CreateClickableTextWithEmoji( + clickablePart = displayName, + suffix = "${it.second} ", + tags = userTags, + route = route, + navController = navController + ) } baseNotePair?.let { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index c50d08163a..c387bfa5d9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -15,6 +16,7 @@ import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import coil.size.Size +import java.util.Date @Composable fun RobohashAsyncImage( @@ -30,27 +32,34 @@ fun RobohashAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - with(LocalDensity.current) { - AsyncImage( - model = Robohash.imageRequest( - LocalContext.current, - robot, - Size(robotSize.roundToPx(), robotSize.roundToPx()) - ), - contentDescription = contentDescription, - modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality + val context = LocalContext.current + val size = with(LocalDensity.current) { + robotSize.roundToPx() + } + + val imageRequest = remember(robotSize, robot) { + Robohash.imageRequest( + context, + robot, + Size(size, size) ) } + + AsyncImage( + model = imageRequest, + contentDescription = contentDescription, + modifier = modifier, + transform = transform, + onState = onState, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) } -var imageErrors = setOf() +var imageErrors = mapOf() @Composable fun RobohashFallbackAsyncImage( @@ -65,7 +74,9 @@ fun RobohashFallbackAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - if (imageErrors.contains(model)) { + val errorCache = remember(imageErrors) { imageErrors[model] } + + if (errorCache != null && (Date().time / 1000) - errorCache < (60 * 5)) { RobohashAsyncImage( robot = robot, robotSize = robotSize, @@ -102,7 +113,7 @@ fun RobohashFallbackAsyncImage( colorFilter = colorFilter, filterQuality = filterQuality, onError = { - imageErrors = imageErrors + model + imageErrors = imageErrors + Pair(model, Date().time / 1000) } ) } @@ -120,7 +131,7 @@ fun RobohashAsyncImageProxy( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - val proxy = model.proxyUrl() + val proxy = remember(model) { model.proxyUrl() } if (proxy == null) { RobohashAsyncImage( robot = robot, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt index 14fb7bd9f0..b4d914a403 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt @@ -19,16 +19,19 @@ import kotlinx.coroutines.launch @Composable fun UrlPreview(url: String, urlText: String) { - val default = UrlCachedPreviewer.cache[url]?.let { - if (it.allFetchComplete() && it.url == url) { - UrlPreviewState.Loaded(it) - } else { - UrlPreviewState.Empty - } - } ?: UrlPreviewState.Loading val context = LocalContext.current - var urlPreviewState by remember { mutableStateOf(default) } + var urlPreviewState by remember { + val default = UrlCachedPreviewer.cache[url]?.let { + if (it.allFetchComplete() && it.url == url) { + UrlPreviewState.Loaded(it) + } else { + UrlPreviewState.Empty + } + } ?: UrlPreviewState.Loading + + mutableStateOf(default) + } val scope = rememberCoroutineScope() // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 561d86ab3c..88de0195be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -41,13 +42,15 @@ fun UrlPreviewCard( ) ) { Column { - val validatedUrl = URL(previewInfo.url) + val validatedUrl = remember { URL(previewInfo.url) } // correctly treating relative images - val imageUrl = if (previewInfo.image.startsWith("/")) { - URL(validatedUrl, previewInfo.image).toString() - } else { - previewInfo.image + val imageUrl = remember { + if (previewInfo.image.startsWith("/")) { + URL(validatedUrl, previewInfo.image).toString() + } else { + previewInfo.image + } } AsyncImage( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 7a6441fcbe..909b366a71 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -1,6 +1,8 @@ package com.vitorpamplona.amethyst.ui.components +import android.graphics.drawable.Drawable import android.net.Uri +import android.util.Log import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -28,6 +30,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -46,6 +49,8 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import coil.imageLoader +import coil.request.ImageRequest import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem @@ -55,6 +60,8 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView import com.vitorpamplona.amethyst.VideoCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.io.File public var muted = mutableStateOf(true) @@ -62,20 +69,55 @@ public var muted = mutableStateOf(true) @Composable fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { if (localFile != null) { - VideoView(localFile.toUri(), description, onDialog) + VideoView(localFile.toUri(), description, null, onDialog) } } @Composable fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { - VideoView(Uri.parse(videoUri), description, onDialog) + VideoView(Uri.parse(videoUri), description, null, onDialog) } @Composable -fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { +fun VideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) { + var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + try { + val request = ImageRequest.Builder(context).data(thumbUri).build() + val myCover = context.imageLoader.execute(request).drawable + if (myCover != null) { + loadingFinished = Pair(true, myCover) + } else { + loadingFinished = Pair(true, null) + } + } catch (e: Exception) { + Log.e("VideoView", "Fail to load cover $thumbUri", e) + loadingFinished = Pair(true, null) + } + } + } + + if (loadingFinished.first) { + if (loadingFinished.second != null) { + VideoView(Uri.parse(videoUri), description, loadingFinished.second, onDialog) + } else { + VideoView(videoUri, description, onDialog) + } + } +} + +@Composable +fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) { val context = LocalContext.current val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + println("loading audio with artwork $thumb") + val exoPlayer = remember(videoUri) { val mediaBuilder = MediaItem.Builder().setUri(videoUri) @@ -89,7 +131,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT volume = 0f if (videoUri.scheme?.startsWith("file") == true) { setMediaItem(media) @@ -116,9 +158,9 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - .defaultMinSize(minHeight = 70.dp) .align(Alignment.Center) .onVisibilityChanges { visible -> - if (visible) { + if (visible && !exoPlayer.isPlaying) { exoPlayer.play() - } else { + } else if (!visible && exoPlayer.isPlaying) { exoPlayer.pause() } }, @@ -130,6 +172,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - ViewGroup.LayoutParams.WRAP_CONTENT ) controllerAutoShow = false + thumb?.let { defaultArtwork = thumb } hideController() resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH onDialog?.let { innerOnDialog -> @@ -146,7 +189,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - muted.value = !muted.value } } - ) { val observer = LifecycleEventObserver { _, event -> when (event) { @@ -175,7 +217,10 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi } onGloballyPositioned { coordinates -> - isVisible = coordinates.isCompletelyVisible(view) + val newIsVisible = coordinates.isCompletelyVisible(view) + if (isVisible != newIsVisible) { + isVisible = newIsVisible + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 5c34d567fa..055f31624a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -17,9 +17,11 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -209,17 +211,21 @@ private fun LocalImageView( mutableStateOf(null) } - val ratio = remember { - aspectRatio(content.dim) - } - BoxWithConstraints(contentAlignment = Alignment.Center) { - val myModifier = mainImageModifier.also { - if (ratio != null) { - it.aspectRatio(ratio, maxHeight.isFinite) - } + val myModifier = remember { + mainImageModifier + .widthIn(max = maxWidth) + .heightIn(max = maxHeight) + .run { + aspectRatio(content.dim)?.let { ratio -> + this.aspectRatio(ratio, false) + } ?: this + } + } + + val contentScale = remember { + if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth } - val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth if (content.localFile != null && content.localFile.exists()) { AsyncImage( @@ -271,10 +277,6 @@ private fun UrlImageView( mutableStateOf(null) } - val ratio = remember { - aspectRatio(content.dim) - } - LaunchedEffect(key1 = content.url, key2 = imageState) { if (imageState is AsyncImagePainter.State.Success) { scope.launch(Dispatchers.IO) { @@ -284,12 +286,20 @@ private fun UrlImageView( } BoxWithConstraints(contentAlignment = Alignment.Center) { - val myModifier = mainImageModifier.also { - if (ratio != null) { - it.aspectRatio(ratio, maxHeight.isFinite) - } + val myModifier = remember { + mainImageModifier + .widthIn(max = maxWidth) + .heightIn(max = maxHeight) + .run { + aspectRatio(content.dim)?.let { ratio -> + this.aspectRatio(ratio, false) + } ?: this + } + } + + val contentScale = remember { + if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth } - val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth AsyncImage( model = content.url, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt index 7f049c8820..49cb27fbb2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPrivateFeedFilter.kt @@ -19,7 +19,7 @@ object BookmarkPrivateFeedFilter : FeedFilter() { ?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList() return notes.plus(addresses).toSet() - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt index 3bc368f1f2..779be607e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/BookmarkPublicFeedFilter.kt @@ -14,7 +14,7 @@ object BookmarkPublicFeedFilter : FeedFilter() { val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList() return notes.plus(addresses).toSet() - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } } 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 74a104ae40..21ea7ef48d 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 @@ -21,7 +21,7 @@ object ChannelFeedFilter : AdditiveFeedFilter() { return channel.notes .values .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } @@ -35,6 +35,6 @@ object ChannelFeedFilter : AdditiveFeedFilter() { } override fun sort(collection: Set): List { - return collection.sortedBy { it.createdAt() }.reversed() + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).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 3cdb265f7e..2aa617fe01 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 @@ -28,7 +28,7 @@ object ChatroomFeedFilter : AdditiveFeedFilter() { return messages.roomMessages .filter { myAccount.isAcceptable(it) } - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } @@ -50,6 +50,6 @@ object ChatroomFeedFilter : AdditiveFeedFilter() { } override fun sort(collection: Set): List { - return collection.sortedBy { it.createdAt() }.reversed() + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index 812872134a..66d44afe4c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -19,20 +19,20 @@ object ChatroomListKnownFeedFilter : FeedFilter() { val privateMessages = messagingWith.mapNotNull { it -> privateChatrooms[it] ?.roomMessages - ?.sortedBy { it.createdAt() } + ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) ?.lastOrNull { it.event != null } } val publicChannels = account.followingChannels().map { it -> it.notes.values .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .lastOrNull { it.event != null } } return (privateMessages + publicChannels) .filterNotNull() - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index 4ca7334e58..a69e6c60d3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -19,12 +19,12 @@ object ChatroomListNewFeedFilter : FeedFilter() { val privateMessages = messagingWith.mapNotNull { it -> privateChatrooms[it] ?.roomMessages - ?.sortedBy { it.createdAt() } + ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) ?.lastOrNull { it.event != null } } return privateMessages - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } } 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 aa37f11abf..e048b2e356 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 @@ -27,7 +27,7 @@ object GlobalFeedFilter : AdditiveFeedFilter() { return collection .asSequence() .filter { - it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty() + (it.event is BaseTextNoteEvent || it.event is AudioTrackEvent) && it.replyTo.isNullOrEmpty() } .filter { val channel = it.channelHex() 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 97775bfb99..5f5106c848 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 @@ -44,6 +44,6 @@ object HashtagFeedFilter : AdditiveFeedFilter() { } override fun sort(collection: Set): List { - return collection.sortedBy { it.createdAt() }.reversed() + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).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 a5cda41b9c..e7bf4b9ae2 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 @@ -34,6 +34,6 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter() { } override fun sort(collection: Set): List { - return collection.sortedBy { it.createdAt() }.reversed() + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).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 fcd1750209..6519bc6349 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 @@ -3,6 +3,7 @@ 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.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent @@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { return collection .asSequence() .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is AudioTrackEvent) && (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.pubkeyHex) } ?: true && @@ -40,6 +41,6 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { } override fun sort(collection: Set): List { - return collection.sortedBy { it.createdAt() }.reversed() + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).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 ea26abdd3c..4aad1f4816 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 @@ -41,14 +41,22 @@ object NotificationFeedFilter : AdditiveFeedFilter() { } override fun sort(collection: Set): List { - return collection.sortedBy { it.createdAt() }.reversed() + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).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) + val isAuthoredPostCited = event.findCitations().any { + LocalCache.notes[it]?.author === author || LocalCache.addressables[it]?.author === author + } + + return isAuthoredPostCited || + ( + event.citedUsers().contains(author.pubkeyHex) || + note.replyTo?.any { it.author === author } == true + ) } if (event is ReactionEvent) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt index 11263ffc8d..46bf82fe98 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt @@ -19,7 +19,7 @@ object PeopleListFeedFilter : FeedFilter() { .toSet() return lists - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt index b2c0829fa1..837b77f122 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileBookmarksFeedFilter.kt @@ -25,7 +25,7 @@ object UserProfileBookmarksFeedFilter : FeedFilter() { return (notes + addresses) .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } + .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index 681865c78b..8d8f559c25 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -16,7 +16,7 @@ object UserProfileConversationsFeedFilter : FeedFilter() { override fun feed(): List { return user?.notes ?.filter { account?.isAcceptable(it) == true && !it.isNewThread() } - ?.sortedBy { it.createdAt() } + ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) ?.reversed() ?: emptyList() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 13d1974210..87e2e642a2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -23,7 +23,7 @@ object UserProfileNewThreadFeedFilter : FeedFilter() { return user?.notes ?.plus(longFormNotes) ?.filter { account?.isAcceptable(it) == true && it.isNewThread() } - ?.sortedBy { it.createdAt() } + ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) ?.reversed() ?: emptyList() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt index 50716ac5b4..12badf519b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt @@ -14,7 +14,7 @@ object UserProfileReportsFeedFilter : FeedFilter() { return user?.reports ?.values ?.flatten() - ?.sortedBy { it.createdAt() } + ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) ?.reversed() ?: emptyList() } } 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 d50ce32bcf..8cb995ace6 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 @@ -45,6 +45,7 @@ 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.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.note.toShortenHex @@ -149,7 +150,10 @@ fun AccountSwitchBottomSheet( val npubShortHex = acc.npub.toShortenHex() user.bestDisplayName()?.let { - Text(it) + CreateTextWithEmoji( + text = it, + tags = user.info?.latestMetadata?.tags + ) } Text(npubShortHex) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index 26f8721111..f6c9c88237 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -5,6 +5,7 @@ import android.view.ViewTreeObserver import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -33,15 +34,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavController import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.time.ExperimentalTime val bottomNavigationItems = listOf( Route.Home, @@ -83,11 +86,7 @@ fun keyboardAsState(): State { @Composable fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) { - val currentRoute = currentRoute(navController) - val currentRouteBase = currentRoute?.substringBefore("?") - val coroutineScope = rememberCoroutineScope() val isKeyboardOpen by keyboardAsState() - if (isKeyboardOpen == Keyboard.Closed) { Column() { Divider( @@ -99,79 +98,126 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView backgroundColor = MaterialTheme.colors.background ) { bottomNavigationItems.forEach { item -> - val selected = currentRouteBase == item.base - - BottomNavigationItem( - icon = { NotifiableIcon(item, selected, accountViewModel) }, - selected = selected, - onClick = { - coroutineScope.launch { - if (currentRouteBase != item.base) { - navController.navigate(item.base) { - navController.graph.startDestinationRoute?.let { start -> - popUpTo(start) - restoreState = true - } - launchSingleTop = true - restoreState = true - } - } else { - val route = currentRoute.replace("{scrollToTop}", "true") - navController.navigate(route) { - navController.graph.startDestinationRoute?.let { start -> - popUpTo(start) { inclusive = item.route == Route.Home.route } - restoreState = true - } - - launchSingleTop = true - restoreState = true - } - } - } - } - ) + HasNewItemsIcon(item, accountViewModel, navController) } } } } } -@OptIn(ExperimentalTime::class) @Composable -private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { - Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { +private fun RowScope.HasNewItemsIcon( + route: Route, + accountViewModel: AccountViewModel, + navController: NavHostController +) { + val scope = rememberCoroutineScope() + + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = remember(accountState) { accountState?.account } ?: return + + val notifState = NotificationCache.live.observeAsState() + val notif = remember(notifState) { notifState.value } ?: return + + var hasNewItems by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = notif) { + scope.launch(Dispatchers.IO) { + val newHasNewItems = route.hasNewItems(account, notif.cache, emptySet()) + if (newHasNewItems != hasNewItems) { + hasNewItems = newHasNewItems + } + } + } + + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + val newHasNewItems = route.hasNewItems(account, notif.cache, it) + if (newHasNewItems != hasNewItems) { + hasNewItems = newHasNewItems + } + } + } + } + + BottomIcon( + icon = route.icon, + size = if ("Home" == route.base) 25.dp else 23.dp, + iconSize = if ("Home" == route.base) 24.dp else 20.dp, + base = route.base, + hasNewItems = hasNewItems, + navController + ) { selected -> + scope.launch { + if (!selected) { + navController.navigate(route.base) { + navController.graph.startDestinationRoute?.let { start -> + popUpTo(start) + restoreState = true + } + launchSingleTop = true + restoreState = true + } + } else { + val newRoute = route.route.replace("{scrollToTop}", "true") + navController.navigate(newRoute) { + navController.graph.startDestinationRoute?.let { start -> + popUpTo(start) { inclusive = route.route == Route.Home.route } + restoreState = true + } + + launchSingleTop = true + restoreState = true + } + } + } + } +} + +@Composable +private fun RowScope.BottomIcon( + icon: Int, + size: Dp, + iconSize: Dp, + base: String, + hasNewItems: Boolean, + navController: NavController, + onClick: (Boolean) -> Unit +) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + + navBackStackEntry?.let { + val selected = remember(it) { + it.destination.route?.substringBefore("?") == base + } + + BottomNavigationItem( + icon = { + NotifiableIcon( + icon, + size, + iconSize, + selected, + hasNewItems + ) + }, + selected = selected, + onClick = { onClick(selected) } + ) + } +} + +@Composable +private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean, hasNewItems: Boolean) { + Box(Modifier.size(size)) { Icon( - painter = painterResource(id = route.icon), + painter = painterResource(id = icon), contentDescription = null, - modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), + modifier = Modifier.size(iconSize), tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified ) - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return - - val notifState = NotificationCache.live.observeAsState() - val notif = notifState.value ?: return - - var hasNewItems by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - LaunchedEffect(key1 = notif) { - scope.launch(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) - } - } - - LaunchedEffect(key1 = db) { - scope.launch(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, db) - } - } - if (hasNewItems) { Box( Modifier diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 512e6c8e25..d1005265bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -8,6 +8,7 @@ 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController @@ -18,6 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel @@ -37,6 +39,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -50,7 +54,8 @@ fun AppNavigation( // Avoids creating ViewModels for performance reasons (up to 1 second delays) val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return + val accountHex = remember(accountState) { accountState?.account?.userProfile()?.pubkeyHex } HomeNewThreadFeedFilter.account = account HomeConversationsFeedFilter.account = account @@ -67,6 +72,16 @@ fun AppNavigation( NotificationFeedFilter.account = account val notifFeedViewModel: NotificationViewModel = viewModel() + val userReactionsStatsModel: UserReactionsViewModel = viewModel() + val scope = rememberCoroutineScope() + + LaunchedEffect(accountHex) { + scope.launch(Dispatchers.IO) { + userReactionsStatsModel.load(account.userProfile()) + userReactionsStatsModel.initializeSuspend() + } + } + NavHost(navController, startDestination = Route.Home.route) { Route.Video.let { route -> composable(route.route, route.arguments, content = { @@ -135,6 +150,7 @@ fun AppNavigation( NotificationScreen( notifFeedViewModel = notifFeedViewModel, + userReactionsStatsModel = userReactionsStatsModel, accountViewModel = accountViewModel, navController = navController, scrollToTop = scrollToTop diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index b18aefcda3..cecad69ec1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -23,8 +24,8 @@ import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -40,6 +41,8 @@ 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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import coil.Coil @@ -71,43 +74,45 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable -fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { when (currentRoute(navController)?.substringBefore("?")) { // Route.Profile.route -> TopBarWithBackButton(navController) - Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel) - Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel) - Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel) + Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel) + Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel) + Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel) else -> MainTopBar(scaffoldState, accountViewModel) } } @Composable -fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { account -> - FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName -> + FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName -> account.changeDefaultStoriesFollowList(listName) } } } @Composable -fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { account -> - FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName -> + FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName -> account.changeDefaultHomeFollowList(listName) } } } @Composable -fun NotificationTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { account -> - FollowList(account.defaultNotificationFollowList, account.userProfile(), true) { listName -> + FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName -> account.changeDefaultNotificationFollowList(listName) } } @@ -237,30 +242,19 @@ private fun LoggedInUserPictureDrawer( } @Composable -fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) { - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return - +fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) { val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows)) val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global)) val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow) - var followLists by remember { mutableStateOf(defaultOptions) } - val followNames = remember { derivedStateOf { followLists.map { it.second } } } + val followLists = remember(followListsModel.followLists) { + (defaultOptions + followListsModel.followLists) + } - LaunchedEffect(key1 = db) { - withContext(Dispatchers.IO) { - followLists = defaultOptions + LocalCache.addressables.mapNotNull { - val event = (it.value.event as? PeopleListEvent) - // Has to have an list - if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) { - Pair(event.dTag(), event.dTag()) - } else { - null - } - }.sortedBy { it.second } + val followNames = remember(followLists) { + derivedStateOf { + followLists.map { it.second } } } @@ -273,6 +267,62 @@ fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: ) } +class FollowListViewModel : ViewModel() { + var followLists by mutableStateOf>>(emptyList()) + var account: Account? = null + + fun load(account: Account?) { + this.account = account + refresh() + } + + fun refresh() { + val scope = CoroutineScope(Job() + Dispatchers.Default) + scope.launch { + refreshFollows() + } + } + + private suspend fun refreshFollows() { + val myAccount = account ?: return + + val newFollowLists = LocalCache.addressables.mapNotNull { + val event = (it.value.event as? PeopleListEvent) + // Has to have an list + if (event != null && event.pubKey == myAccount.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) { + Pair(event.dTag(), event.dTag()) + } else { + null + } + }.sortedBy { it.second } + + withContext(Dispatchers.Main) { + if (followLists != newFollowLists) { + followLists = newFollowLists + } + } + } + + var collectorJob: Job? = null + + init { + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + newNotes.forEach { + if (it.event is PeopleListEvent) { + refresh() + } + } + } + } + } + + override fun onCleared() { + collectorJob?.cancel() + super.onCleared() + } +} + @Composable fun SimpleTextSpinner( placeholder: String, @@ -289,7 +339,16 @@ fun SimpleTextSpinner( modifier = modifier, contentAlignment = Alignment.Center ) { - Text(placeholder) + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.size(20.dp)) + Text(placeholder) + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } Box( modifier = Modifier .matchParentSize() 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..d5f483769e 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 @@ -1,9 +1,11 @@ package com.vitorpamplona.amethyst.ui.navigation +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,7 +16,10 @@ 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 +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -24,7 +29,9 @@ import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ScaffoldState import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -45,13 +52,18 @@ import androidx.navigation.NavController import androidx.navigation.NavHostController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.LocalPreferences 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.service.HttpClient +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog import kotlinx.coroutines.launch @OptIn(ExperimentalMaterialApi::class) @@ -84,7 +96,7 @@ fun DrawerContent( modifier = Modifier.padding(top = 20.dp) ) ListContent( - account.userProfile(), + account.userProfile().pubkeyHex, navController, scaffoldState, sheetState, @@ -100,20 +112,34 @@ 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() - val accountUser = accountUserState?.user ?: return + val accountUser = remember(accountUserState) { accountUserState?.user } ?: return + + val profilePubHex = remember(accountUserState) { accountUserState?.user?.pubkeyHex } ?: return + + val profileBanner = remember(accountUserState) { accountUserState?.user?.info?.banner?.ifBlank { null } } + val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture()?.ifBlank { null }?.let { ResizeImage(it, 100.dp) } } + val bestUserName = remember(accountUserState) { accountUserState?.user?.bestUsername() } + val bestDisplayName = remember(accountUserState) { accountUserState?.user?.bestDisplayName() } + val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags } + val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" } val accountUserFollowsState by baseAccountUser.live().follows.observeAsState() - val accountUserFollows = accountUserFollowsState?.user ?: return + val followingCount = remember(accountUserFollowsState) { accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--" } + val followerCount = remember(accountUserFollowsState) { accountUserFollowsState?.user?.cachedFollowerCount()?.toString() ?: "--" } Box { - val banner = accountUser.info?.banner - if (!banner.isNullOrBlank()) { + if (profileBanner != null) { AsyncImage( - model = banner, + model = profileBanner, contentDescription = stringResource(id = R.string.profile_image), contentScale = ContentScale.FillWidth, modifier = Modifier @@ -132,34 +158,34 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol } Column(modifier = modifier) { - RobohashAsyncImageProxy( - robot = accountUser.pubkeyHex, - model = ResizeImage(accountUser.profilePicture(), 100.dp), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(100.dp) - .height(100.dp) - .clip(shape = CircleShape) - .border(3.dp, MaterialTheme.colors.background, CircleShape) - .background(MaterialTheme.colors.background) - .clickable(onClick = { - accountUser.let { - navController.navigate("User/${it.pubkeyHex}") - } - coroutineScope.launch { - scaffoldState.drawerState.close() - } - }) - ) - if (accountUser.bestDisplayName() != null) { - Text( - accountUser.bestDisplayName() ?: "", + profilePicture?.let { + RobohashAsyncImageProxy( + robot = profilePubHex, + model = profilePicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(100.dp) + .height(100.dp) + .clip(shape = CircleShape) + .border(3.dp, MaterialTheme.colors.background, CircleShape) + .background(MaterialTheme.colors.background) + .clickable(onClick = { + navController.navigate(route) + coroutineScope.launch { + scaffoldState.drawerState.close() + } + }) + ) + } + + if (bestDisplayName != null) { + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, modifier = Modifier .padding(top = 7.dp) .clickable(onClick = { - accountUser.let { - navController.navigate("User/${it.pubkeyHex}") - } + navController.navigate(route) coroutineScope.launch { scaffoldState.drawerState.close() } @@ -168,40 +194,45 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol fontSize = 18.sp ) } - if (accountUser.bestUsername() != null) { - Text( - " @${accountUser.bestUsername()}", + if (bestUserName != null) { + CreateTextWithEmoji( + text = " @$bestUserName", + tags = accountUser.info?.latestMetadata?.tags, color = Color.LightGray, modifier = Modifier .padding(top = 15.dp) - .clickable(onClick = { - accountUser.let { - navController.navigate("User/${it.pubkeyHex}") + .clickable( + onClick = { + navController.navigate(route) + coroutineScope.launch { + scaffoldState.drawerState.close() + } } - coroutineScope.launch { - scaffoldState.drawerState.close() - } - }) + ) ) } Row( modifier = Modifier .padding(top = 15.dp) .clickable(onClick = { - accountUser.let { - navController.navigate("User/${it.pubkeyHex}") - } + navController.navigate(route) coroutineScope.launch { scaffoldState.drawerState.close() } }) ) { Row() { - Text("${accountUserFollows.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold) + Text( + text = followingCount, + fontWeight = FontWeight.Bold + ) Text(stringResource(R.string.following)) } Row(modifier = Modifier.padding(start = 10.dp)) { - Text("${accountUserFollows.cachedFollowerCount() ?: "--"}", fontWeight = FontWeight.Bold) + Text( + text = followerCount, + fontWeight = FontWeight.Bold + ) Text(stringResource(R.string.followers)) } } @@ -212,7 +243,7 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol @OptIn(ExperimentalMaterialApi::class) @Composable fun ListContent( - accountUser: User?, + accountUserPubKey: String?, navController: NavHostController, scaffoldState: ScaffoldState, sheetState: ModalBottomSheetState, @@ -221,16 +252,20 @@ fun ListContent( ) { val coroutineScope = rememberCoroutineScope() var backupDialogOpen by remember { mutableStateOf(false) } + var checked by remember { mutableStateOf(account.proxy != null) } + var disconnectTorDialog by remember { mutableStateOf(false) } + var conectOrbotDialogOpen by remember { mutableStateOf(false) } + var proxyPort = remember { mutableStateOf(account.proxyPort.toString()) } - Column(modifier = modifier.fillMaxHeight()) { - if (accountUser != null) { + Column(modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState())) { + if (accountUserPubKey != null) { NavigationRow( title = stringResource(R.string.profile), icon = Route.Profile.icon, tint = MaterialTheme.colors.primary, navController = navController, scaffoldState = scaffoldState, - route = "User/${accountUser.pubkeyHex}" + route = "User/$accountUserPubKey" ) NavigationRow( @@ -256,7 +291,36 @@ fun ListContent( title = stringResource(R.string.backup_keys), icon = R.drawable.ic_key, tint = MaterialTheme.colors.onBackground, - onClick = { backupDialogOpen = true } + onClick = { + coroutineScope.launch { + scaffoldState.drawerState.close() + } + backupDialogOpen = true + } + ) + + val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short) + + IconRow( + title = textTorProxy, + icon = R.drawable.ic_tor, + tint = MaterialTheme.colors.onBackground, + onLongClick = { + coroutineScope.launch { + scaffoldState.drawerState.close() + } + conectOrbotDialogOpen = true + }, + onClick = { + if (checked) { + disconnectTorDialog = true + } else { + coroutineScope.launch { + scaffoldState.drawerState.close() + } + conectOrbotDialogOpen = true + } + } ) Spacer(modifier = Modifier.weight(1f)) @@ -272,6 +336,65 @@ fun ListContent( if (backupDialogOpen) { AccountBackupDialog(account, onClose = { backupDialogOpen = false }) } + + if (conectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { conectOrbotDialogOpen = false }, + onPost = { + conectOrbotDialogOpen = false + disconnectTorDialog = false + checked = true + enableTor(account, true, proxyPort) + }, + proxyPort + ) + } + + if (disconnectTorDialog) { + AlertDialog( + title = { + Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title)) + }, + text = { + Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_text)) + }, + onDismissRequest = { + disconnectTorDialog = false + }, + confirmButton = { + TextButton( + onClick = { + disconnectTorDialog = false + checked = false + enableTor(account, false, proxyPort) + } + ) { + Text(text = stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton( + onClick = { + disconnectTorDialog = false + } + ) { + Text(text = stringResource(R.string.no)) + } + } + ) + } +} + +private fun enableTor( + account: Account, + checked: Boolean, + portNumber: MutableState +) { + account.proxyPort = portNumber.value.toInt() + account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort) + LocalPreferences.saveToEncryptedStorage(account) + ServiceManager.pause() + ServiceManager.start() } @Composable @@ -284,23 +407,24 @@ fun NavigationRow( route: String ) { val coroutineScope = rememberCoroutineScope() - val currentRoute = currentRoute(navController) IconRow(title, icon, tint, onClick = { - if (currentRoute != route) { - navController.navigate(route) - } + navController.navigate(route) coroutineScope.launch { scaffoldState.drawerState.close() } }) } +@OptIn(ExperimentalFoundationApi::class) @Composable -fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit) { +fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 98aa24cdb0..a333a2b6c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @Composable -fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { +fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: String? = null) { Column(modifier = modifier) { Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) { @@ -39,7 +39,7 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { horizontalArrangement = Arrangement.Center ) { Text( - text = stringResource(R.string.post_not_found), + text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "", modifier = Modifier.padding(30.dp), color = Color.Gray ) 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 21e7bb60c3..e799500d14 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 @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -27,6 +26,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -55,9 +55,12 @@ import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User 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.ui.components.CreateClickableTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -65,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) @@ -80,27 +84,54 @@ fun ChatroomMessageCompose( navController: NavController, onWantsToReply: (Note) -> Unit ) { - val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return + val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return + + val noteState by baseNote.live().metadata.observeAsState() + val note = remember(noteState) { noteState?.note } ?: return val noteReportsState by baseNote.live().reports.observeAsState() - val noteForReports = noteReportsState?.note ?: return + val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return - val accountUser = account.userProfile() + val noteEvent = remember(noteState) { note.event } var popupExpanded by remember { mutableStateOf(false) } - var showHiddenNote by remember { mutableStateOf(false) } - val context = LocalContext.current.applicationContext - val scope = rememberCoroutineScope() + if (noteEvent == null) { + BlankNote( + Modifier.combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true } + ) + ) - if (note?.event == null) { - BlankNote(Modifier) - } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { - if (!account.isHidden(noteForReports.author!!)) { + note.let { + NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel) + } + } else if (account.isHidden(noteForReports.author!!)) { + // Does nothing + } else { + var showHiddenNote by remember { mutableStateOf(false) } + var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) } + + LaunchedEffect(key1 = noteReportsState, key2 = accountState) { + withContext(Dispatchers.IO) { + account.userProfile().let { loggedIn -> + val newCanPreview = note.author === loggedIn || + (note.author?.let { loggedIn.isFollowingCached(it) } ?: true) || + !(noteForReports.hasAnyReports()) + + val newIsAcceptable = account.isAcceptable(noteForReports) + + if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) { + isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview) + } + } + } + } + + if (!isAcceptableAndCanPreview.first && !showHiddenNote) { HiddenNote( account.getRelevantReports(noteForReports), account.userProfile(), @@ -109,299 +140,436 @@ fun ChatroomMessageCompose( navController, onClick = { showHiddenNote = true } ) - } - } else { - var backgroundBubbleColor: Color - var alignment: Arrangement.Horizontal - var shape: Shape - - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - - if (note.author == accountUser) { - backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) - alignment = Arrangement.End - shape = ChatBubbleShapeMe } else { - backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) - alignment = Arrangement.Start - shape = ChatBubbleShapeThem - } + val backgroundBubbleColor: Color + val alignment: Arrangement.Horizontal + val shape: Shape - if (parentBackgroundColor != null) { - backgroundBubbleColor = backgroundBubbleColor.compositeOver(parentBackgroundColor) - } else { - backgroundBubbleColor = backgroundBubbleColor.compositeOver(MaterialTheme.colors.background) - } + if (note.author == loggedIn) { + backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + .compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background) - var isNew by remember { mutableStateOf(false) } + alignment = Arrangement.End + shape = ChatBubbleShapeMe + } else { + backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + .compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background) - LaunchedEffect(key1 = routeForLastRead) { - routeForLastRead?.let { - scope.launch(Dispatchers.IO) { - val lastTime = NotificationCache.load(it) + alignment = Arrangement.Start + shape = ChatBubbleShapeThem + } - val createdAt = note.createdAt() - if (createdAt != null) { - NotificationCache.markAsRead(it, createdAt) - isNew = createdAt > lastTime + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = routeForLastRead) { + routeForLastRead?.let { + scope.launch(Dispatchers.IO) { + val lastTime = NotificationCache.load(it) + + val createdAt = note.createdAt() + if (createdAt != null) { + NotificationCache.markAsRead(it, createdAt) + } } } } - } - Column() { - val modif = if (innerQuote) { - Modifier.padding(top = 10.dp, end = 5.dp) - } else { - Modifier - .fillMaxWidth(1f) - .padding( - start = 12.dp, - end = 12.dp, - top = 5.dp, - bottom = 5.dp - ) - } - - Row( - modifier = modif, - horizontalArrangement = alignment - ) { - var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) } - val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f) + Column() { + val modif = remember { + if (innerQuote) { + Modifier.padding(top = 10.dp, end = 5.dp) + } else { + Modifier + .fillMaxWidth(1f) + .padding( + start = 12.dp, + end = 12.dp, + top = 5.dp, + bottom = 5.dp + ) + } + } Row( - horizontalArrangement = alignment, - modifier = modif2.onSizeChanged { - availableBubbleSize = it - } + modifier = modif, + horizontalArrangement = alignment ) { - Surface( - color = backgroundBubbleColor, - shape = shape, - modifier = Modifier - .combinedClickable( - onClick = { }, - onLongClick = { popupExpanded = true } - ) + var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) } + val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f) + + Row( + horizontalArrangement = alignment, + modifier = modif2.onSizeChanged { + availableBubbleSize = it + } ) { - var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - - Column( + Surface( + color = backgroundBubbleColor, + shape = shape, modifier = Modifier - .padding(start = 10.dp, end = 5.dp, bottom = 5.dp) - .onSizeChanged { - bubbleSize = it - } + .combinedClickable( + onClick = { + if (noteEvent is ChannelCreateEvent) { + navController.navigate("Channel/${note.idHex}") + } + }, + onLongClick = { popupExpanded = true } + ) ) { - val authorState by note.author!!.live().metadata.observeAsState() - val author = authorState?.user!! + var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = alignment, - modifier = Modifier.padding(top = 5.dp) - ) { - RobohashAsyncImageProxy( - robot = author.pubkeyHex, - model = ResizeImage(author.profilePicture(), 25.dp), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(25.dp) - .height(25.dp) - .clip(shape = CircleShape) - .clickable(onClick = { - author.let { - navController.navigate("User/${it.pubkeyHex}") - } - }) - ) - - Text( - " ${author.toBestDisplayName()}", - fontWeight = FontWeight.Bold, - modifier = Modifier.clickable(onClick = { - author.let { - navController.navigate("User/${it.pubkeyHex}") - } - }) - ) - } - } - - val replyTo = note.replyTo - if (!innerQuote && !replyTo.isNullOrEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - replyTo.toSet().mapIndexed { _, note -> - ChatroomMessageCompose( - note, - null, - innerQuote = true, - parentBackgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - navController = navController, - onWantsToReply = onWantsToReply - ) + Column( + modifier = Modifier + .padding(start = 10.dp, end = 5.dp, bottom = 5.dp) + .onSizeChanged { + bubbleSize = it } - } - } - - 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)} " + ( - channelInfo.name - ?: "" - ) + " ${stringResource(R.string.with_description_of)} '" + ( - channelInfo.about - ?: "" - ) + "', ${stringResource(R.string.and_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)} '" + ( - channelInfo.name - ?: "" - ) + "$', {stringResource(R.string.description_to)} '" + ( - channelInfo.about - ?: "" - ) + "', ${stringResource(R.string.and_picture_to)} '" + ( - channelInfo.picture - ?: "" - ) + "'" + ) { + if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) { + DrawAuthorInfo( + baseNote, + alignment, + navController ) } else { - val eventContent = accountViewModel.decrypt(note) - - val canPreview = note.author == accountUser || - (note.author?.let { accountUser.isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - if (eventContent != null) { - TranslatableRichTextViewer( - eventContent, - canPreview, - Modifier.padding(top = 5.dp), - note.event?.tags(), - backgroundBubbleColor, - accountViewModel, - navController - ) - } else { - TranslatableRichTextViewer( - stringResource(R.string.could_not_decrypt_the_message), - true, - Modifier.padding(top = 5.dp), - note.event?.tags(), - backgroundBubbleColor, - accountViewModel, - navController - ) - } + Spacer(modifier = Modifier.height(5.dp)) } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .padding(top = 5.dp) - .then( - with(LocalDensity.current) { - Modifier.widthIn( - bubbleSize.width.toDp(), - availableBubbleSize.width.toDp() + val replyTo = note.replyTo + if (!innerQuote && !replyTo.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + replyTo.lastOrNull()?.let { note -> + ChatroomMessageCompose( + note, + null, + innerQuote = true, + parentBackgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + navController = navController, + onWantsToReply = onWantsToReply ) } - ) - ) { - Row() { - Text( - timeAgoShort(note.createdAt(), context), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - fontSize = 12.sp - ) - - RelayBadges(note) - - Spacer(modifier = Modifier.width(10.dp)) + } } - Row() { - LikeReaction(baseNote, grayTint, accountViewModel) - Spacer(modifier = Modifier.width(5.dp)) - ZapReaction(baseNote, grayTint, accountViewModel) - Spacer(modifier = Modifier.width(5.dp)) - ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) { - onWantsToReply(baseNote) + Row(verticalAlignment = Alignment.CenterVertically) { + when (noteEvent) { + is ChannelCreateEvent -> { + RenderCreateChannelNote(note) + } + + is ChannelMetadataEvent -> { + RenderChangeChannelMetadataNote(note) + } + + else -> { + RenderRegularTextNote( + note, + loggedIn, + isAcceptableAndCanPreview.second, + backgroundBubbleColor, + accountViewModel, + navController + ) + } } } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(top = 5.dp) + .then( + with(LocalDensity.current) { + Modifier.widthIn( + bubbleSize.width.toDp(), + availableBubbleSize.width.toDp() + ) + } + ) + ) { + StatusRow( + baseNote, + accountViewModel, + onWantsToReply + ) + } } } } - } - NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + NoteQuickActionMenu( + note, + popupExpanded, + { popupExpanded = false }, + accountViewModel + ) + } } } } } @Composable -private fun RelayBadges(baseNote: Note) { - val noteRelaysState by baseNote.live().relays.observeAsState() - val noteRelays = noteRelaysState?.note?.relays ?: emptySet() +private fun StatusRow( + baseNote: Note, + accountViewModel: AccountViewModel, + onWantsToReply: (Note) -> Unit +) { + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val time = remember { baseNote.createdAt() ?: 0 } - var expanded by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically) { + ChatTimeAgo(time) + RelayBadges(baseNote) + Spacer(modifier = Modifier.width(10.dp)) + } - val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3) - - val uri = LocalUriHandler.current - - FlowRow(Modifier.padding(start = 10.dp)) { - relaysToDisplay.forEach { - val url = it.removePrefix("wss://").removePrefix("ws://") - Box( - Modifier - .size(15.dp) - .padding(1.dp) - ) { - RobohashFallbackAsyncImage( - robot = "https://$url/favicon.ico", - robotSize = 15.dp, - model = "https://$url/favicon.ico", - contentDescription = stringResource(id = R.string.relay_icon), - colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), - modifier = Modifier - .fillMaxSize(1f) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://$url") }) - ) - } - } - - if (noteRelays.size > 3 && !expanded) { - IconButton( - modifier = Modifier.then(Modifier.size(15.dp)), - onClick = { expanded = true } - ) { - Icon( - imageVector = Icons.Default.ChevronRight, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + LikeReaction(baseNote, grayTint, accountViewModel) + Spacer(modifier = Modifier.width(5.dp)) + ZapReaction(baseNote, grayTint, accountViewModel) + Spacer(modifier = Modifier.width(5.dp)) + ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = 16.dp) { + onWantsToReply(baseNote) } } } + +@Composable +fun ChatTimeAgo(time: Long) { + val context = LocalContext.current + + var timeStr by remember { mutableStateOf("") } + + LaunchedEffect(key1 = time) { + withContext(Dispatchers.IO) { + timeStr = timeAgoShort(time, context = context) + } + } + + Text( + timeStr, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 12.sp + ) +} + +@Composable +private fun RenderRegularTextNote( + note: Note, + loggedIn: User, + canPreview: Boolean, + backgroundBubbleColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val tags = remember { note.event?.tags() } + val eventContent = remember { accountViewModel.decrypt(note) } + val modifier = remember { Modifier.padding(top = 5.dp) } + + if (eventContent != null) { + TranslatableRichTextViewer( + eventContent, + canPreview, + modifier, + tags, + backgroundBubbleColor, + accountViewModel, + navController + ) + } else { + TranslatableRichTextViewer( + stringResource(R.string.could_not_decrypt_the_message), + true, + modifier, + tags, + backgroundBubbleColor, + accountViewModel, + navController + ) + } +} + +@Composable +private fun RenderChangeChannelMetadataNote( + note: Note +) { + val noteEvent = note.event as? ChannelMetadataEvent ?: return + + val channelInfo = noteEvent.channelInfo() + val text = note.author?.toBestDisplayName() + .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( + channelInfo.name + ?: "" + ) + "', ${stringResource(R.string.description_to)} '" + ( + channelInfo.about + ?: "" + ) + "', ${stringResource(R.string.and_picture_to)} '" + ( + channelInfo.picture + ?: "" + ) + "'" + + CreateTextWithEmoji( + text = text, + tags = note.author?.info?.latestMetadata?.tags + ) +} + +@Composable +private fun RenderCreateChannelNote(note: Note) { + val noteEvent = note.event as? ChannelCreateEvent ?: return + val channelInfo = remember { noteEvent.channelInfo() } + + val text = note.author?.toBestDisplayName() + .toString() + " ${stringResource(R.string.created)} " + ( + channelInfo.name + ?: "" + ) + " ${stringResource(R.string.with_description_of)} '" + ( + channelInfo.about + ?: "" + ) + "', ${stringResource(R.string.and_picture)} '" + ( + channelInfo.picture + ?: "" + ) + "'" + + CreateTextWithEmoji( + text = text, + tags = note.author?.info?.latestMetadata?.tags + ) +} + +@Composable +private fun DrawAuthorInfo( + baseNote: Note, + alignment: Arrangement.Horizontal, + navController: NavController +) { + val userState by baseNote.author!!.live().metadata.observeAsState() + + val pubkeyHex = remember { baseNote.author?.pubkeyHex } ?: return + val route = remember { "User/$pubkeyHex" } + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) } + val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = alignment, + modifier = Modifier.padding(top = 5.dp) + ) { + RobohashAsyncImageProxy( + robot = pubkeyHex, + model = userProfilePicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(25.dp) + .height(25.dp) + .clip(shape = CircleShape) + .clickable(onClick = { + navController.navigate(route) + }) + ) + + CreateClickableTextWithEmoji( + clickablePart = " $userDisplayName", + suffix = "", + tags = userTags, + fontWeight = FontWeight.Bold, + overrideColor = MaterialTheme.colors.onBackground, + route = route, + navController = navController + ) + } +} + +data class RelayBadgesState( + val shouldDisplayExpandButton: Boolean, + val noteRelays: List, + val noteRelaysSimple: List +) + +@Composable +private fun RelayBadges(baseNote: Note) { + val noteRelaysState by baseNote.live().relays.observeAsState() + + val state: RelayBadgesState by remember(noteRelaysState) { + val newShouldDisplayExpandButton = (noteRelaysState?.note?.relays?.size ?: 0) > 3 + val noteRelays = noteRelaysState?.note?.relays?.toList() ?: emptyList() + val noteRelaysSimple = noteRelaysState?.note?.relays?.take(3)?.toList() ?: emptyList() + + mutableStateOf(RelayBadgesState(newShouldDisplayExpandButton, noteRelays, noteRelaysSimple)) + } + + var expanded by remember { mutableStateOf(false) } + + val relaysToDisplay by remember(noteRelaysState) { + derivedStateOf { + if (expanded) state.noteRelays else state.noteRelaysSimple + } + } + + FlowRow(Modifier.padding(start = 10.dp)) { + relaysToDisplay.forEach { + RenderRelay(it) + } + } + + if (state.shouldDisplayExpandButton && !expanded) { + IconButton( + modifier = Modifier.then(Modifier.size(15.dp)), + onClick = { expanded = true } + ) { + Icon( + imageVector = Icons.Default.ChevronRight, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } +} + +@Composable +private fun RenderRelay(dirtyUrl: String) { + val uri = LocalUriHandler.current + val website = remember { + val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://") + "https://$cleanUrl" + } + val iconUrl = remember { + val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://") + "https://$cleanUrl/favicon.ico" + } + + val clickableModifier = remember { + Modifier + .size(15.dp) + .padding(1.dp) + .clickable(onClick = { uri.openUri(website) }) + } + + val colorFilter = remember { + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) }) + } + + val iconModifier = remember { + Modifier + .size(15.dp) + .clip(shape = CircleShape) + } + + Box( + modifier = clickableModifier + ) { + RobohashFallbackAsyncImage( + robot = iconUrl, + robotSize = 15.dp, + model = iconUrl, + contentDescription = stringResource(id = R.string.relay_icon), + colorFilter = colorFilter, + modifier = iconModifier.background(MaterialTheme.colors.background) + ) + } +} 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 4e6fa71fe1..c893e0439b 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -34,18 +35,18 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable -fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) { +fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) { val noteState by messageSetCard.note.live().metadata.observeAsState() - val note = noteState?.note + val note = remember(noteState) { noteState?.note } var popupExpanded by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() if (note == null) { - BlankNote(Modifier, isInnerNote) + BlankNote(Modifier) } else { - var isNew by remember { mutableStateOf(false) } + var isNew by remember { mutableStateOf(false) } LaunchedEffect(key1 = messageSetCard.createdAt()) { scope.launch(Dispatchers.IO) { @@ -66,44 +67,40 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal MaterialTheme.colors.background } - Column( - modifier = Modifier.background(backgroundColor).combinedClickable( - onClick = { - scope.launch { - routeFor( - note, - accountViewModel.userProfile() - )?.let { navController.navigate(it) } - } - }, - onLongClick = { popupExpanded = true } - ) - ) { - Row( - modifier = Modifier - .padding( - start = if (!isInnerNote) 12.dp else 0.dp, - end = if (!isInnerNote) 12.dp else 0.dp, - top = 10.dp + val columnModifier = remember(isNew) { + Modifier + .background(backgroundColor) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp + ) + .combinedClickable( + onClick = { + scope.launch { + routeFor( + note, + accountViewModel.userProfile() + )?.let { navController.navigate(it) } + } + }, + onLongClick = { popupExpanded = true } + ) + .fillMaxWidth() + } + + Column(columnModifier) { + Row(Modifier.fillMaxWidth()) { + Box(modifier = remember { Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp) }) { + Icon( + painter = painterResource(R.drawable.ic_dm), + null, + modifier = remember { Modifier.size(16.dp).align(Alignment.TopEnd) }, + tint = MaterialTheme.colors.primary ) - ) { - // Draws the like picture outside the boosted card. - if (!isInnerNote) { - Box( - modifier = Modifier - .width(55.dp) - .padding(top = 5.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - null, - modifier = Modifier.size(16.dp).align(Alignment.TopEnd), - tint = MaterialTheme.colors.primary - ) - } } - Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { + Column(modifier = remember { Modifier.padding(start = 10.dp) }) { NoteCompose( baseNote = messageSetCard.note, routeForLastRead = null, 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 1aebd5efab..49bd4cffcf 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 @@ -19,6 +19,7 @@ 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.State import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -30,8 +31,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache @@ -40,31 +43,37 @@ 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.model.UserState +import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.math.BigDecimal @OptIn(ExperimentalFoundationApi::class) @Composable fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) { - val noteState by multiSetCard.note.live().metadata.observeAsState() - val note = noteState?.note + val baseNote = remember { multiSetCard.note } + + val noteState by baseNote.live().metadata.observeAsState() + val note = remember(noteState) { noteState?.note } ?: return val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return var popupExpanded by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - if (note == null) { + if (note.event == null) { BlankNote(Modifier, false) } else { - var isNew by remember { mutableStateOf(false) } + var isNew by remember { mutableStateOf(false) } LaunchedEffect(key1 = multiSetCard.createdAt()) { scope.launch(Dispatchers.IO) { @@ -78,118 +87,168 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } } - val backgroundColor = if (isNew) { - MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background) - } else { - MaterialTheme.colors.background + val primaryColor = MaterialTheme.colors.primary.copy(0.12f) + val defaultBackgroundColor = MaterialTheme.colors.background + + val backgroundColor = remember(isNew) { + if (isNew) { + primaryColor.compositeOver(defaultBackgroundColor) + } else { + defaultBackgroundColor + } } - Column( - modifier = Modifier + val columnModifier = remember(isNew) { + Modifier .background(backgroundColor) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp + ) .combinedClickable( onClick = { scope.launch { - routeFor(note, account.userProfile())?.let { navController.navigate(it) } + routeFor( + baseNote, + account.userProfile() + )?.let { navController.navigate(it) } } }, onLongClick = { popupExpanded = true } ) - ) { - Row( - modifier = Modifier - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ) - ) { - Column(Modifier.fillMaxWidth()) { - if (multiSetCard.zapEvents.isNotEmpty()) { - Row(Modifier.fillMaxWidth()) { - // Draws the like picture outside the boosted card. - Box( - modifier = Modifier - .width(55.dp) - .padding(0.dp) - ) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = "Zaps", - tint = BitcoinOrange, - modifier = Modifier - .size(25.dp) - .align(Alignment.TopEnd) - ) - } + .fillMaxWidth() + } - AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel) - } - } + val zapEvents = remember { multiSetCard.zapEvents } + val boostEvents = remember { multiSetCard.boostEvents } + val likeEvents = remember { multiSetCard.likeEvents } - if (multiSetCard.boostEvents.isNotEmpty()) { - Row(Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .width(55.dp) - .padding(end = 4.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_retweeted), - null, - modifier = Modifier - .size(18.dp) - .align(Alignment.TopEnd), - tint = Color.Unspecified - ) - } + Column(modifier = columnModifier) { + if (zapEvents.isNotEmpty()) { + RenderZapGallery(zapEvents, navController, account, accountViewModel) + } - AuthorGallery(multiSetCard.boostEvents, navController, account, accountViewModel) - } - } + if (boostEvents.isNotEmpty()) { + RenderBoostGallery(boostEvents, navController, account, accountViewModel) + } - if (multiSetCard.likeEvents.isNotEmpty()) { - Row(Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .width(55.dp) - .padding(end = 5.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = Modifier - .size(16.dp) - .align(Alignment.TopEnd), - tint = Color.Unspecified - ) - } + if (likeEvents.isNotEmpty()) { + RenderLikeGallery(likeEvents, navController, account, accountViewModel) + } - AuthorGallery(multiSetCard.likeEvents, navController, account, accountViewModel) - } - } + Row(Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.width(65.dp)) - Row(Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.width(65.dp)) + NoteCompose( + baseNote = baseNote, + routeForLastRead = null, + modifier = Modifier.padding(top = 5.dp), + isBoostedNote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) - NoteCompose( - baseNote = multiSetCard.note, - routeForLastRead = null, - modifier = Modifier.padding(top = 5.dp), - isBoostedNote = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - navController = navController - ) - - NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) - } - } + NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } } } } +@Composable +private fun RenderLikeGallery( + likeEvents: List, + navController: NavController, + account: Account, + accountViewModel: AccountViewModel +) { + Row(Modifier.fillMaxWidth()) { + Box( + modifier = remember { + Modifier + .width(55.dp) + .padding(end = 5.dp) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = remember { + Modifier + .size(16.dp) + .align(Alignment.TopEnd) + }, + tint = Color.Unspecified + ) + } + + AuthorGallery(likeEvents, navController, account, accountViewModel) + } +} + +@Composable +private fun RenderZapGallery( + zapEvents: Map, + navController: NavController, + account: Account, + accountViewModel: AccountViewModel +) { + Row(Modifier.fillMaxWidth()) { + Box( + modifier = remember { + Modifier + .width(55.dp) + .padding(0.dp) + } + ) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = "Zaps", + tint = BitcoinOrange, + modifier = remember { + Modifier + .size(25.dp) + .align(Alignment.TopEnd) + } + ) + } + + AuthorGalleryZaps(zapEvents, navController, account, accountViewModel) + } +} + +@Composable +private fun RenderBoostGallery( + boostEvents: List, + navController: NavController, + account: Account, + accountViewModel: AccountViewModel +) { + Row(Modifier.fillMaxWidth()) { + Box( + modifier = remember { + Modifier + .width(55.dp) + .padding(end = 4.dp) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = remember { + Modifier + .size(18.dp) + .align(Alignment.TopEnd) + }, + tint = Color.Unspecified + ) + } + + AuthorGallery(boostEvents, navController, account, accountViewModel) + } +} + @Composable fun AuthorGalleryZaps( authorNotes: Map, @@ -197,13 +256,12 @@ fun AuthorGalleryZaps( account: Account, accountViewModel: AccountViewModel ) { - val accountState by account.userProfile().live().follows.observeAsState() - val accountUser = accountState?.user ?: return + val accountState = account.userProfile().live().follows.observeAsState() Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { authorNotes.forEach { - AuthorPictureAndComment(it.key, navController, accountUser, accountViewModel) + AuthorPictureAndComment(it.key, it.value, navController, accountState, accountViewModel) } } } @@ -212,59 +270,93 @@ fun AuthorGalleryZaps( @Composable private fun AuthorPictureAndComment( zapRequest: Note, + zapEvent: Note?, navController: NavController, - accountUser: User, + accountUser: State, accountViewModel: AccountViewModel ) { val author = zapRequest.author ?: return - var content by remember { mutableStateOf>(Pair(author, null)) } + var content by remember { mutableStateOf>(Triple(author, null, null)) } val scope = rememberCoroutineScope() - LaunchedEffect(key1 = zapRequest.idHex) { + LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { scope.launch(Dispatchers.IO) { (zapRequest.event as? LnZapRequestEvent)?.let { val decryptedContent = accountViewModel.decryptZap(zapRequest) + val amount = (zapEvent?.event as? LnZapEvent)?.amount if (decryptedContent != null) { - val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) - content = Pair(author, decryptedContent.content) + val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) + content = Triple(newAuthor, decryptedContent.content, amount) } else { - if (!zapRequest.event?.content().isNullOrBlank()) { - content = Pair(author, zapRequest.event?.content()) + if (!zapRequest.event?.content().isNullOrBlank() || amount != null) { + content = Triple(author, zapRequest.event?.content(), amount) } } } } } - AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel) + AuthorPictureAndComment( + author = content.first, + comment = content.second, + amount = showAmountAxis(content.third), + navController = navController, + accountUser = accountUser, + accountViewModel = accountViewModel + ) } @Composable private fun AuthorPictureAndComment( author: User, comment: String?, + amount: String?, navController: NavController, - accountUser: User, + accountUser: State, accountViewModel: AccountViewModel ) { - val modifier = if (!comment.isNullOrBlank()) { - Modifier.fillMaxWidth() - } else { - Modifier + val modifier = remember(comment) { + if (!comment.isNullOrBlank()) { + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("User/${author.pubkeyHex}") + } + } else { + Modifier.clickable { + navController.navigate("User/${author.pubkeyHex}") + } + } } Row( - modifier = modifier.clickable { - navController.navigate("User/${author.pubkeyHex}") - }, + modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { - FastNoteAuthorPicture( - author = author, - userAccount = accountUser, - size = 35.dp - ) + Box(modifier = remember { Modifier.size(35.dp) }, contentAlignment = Alignment.BottomCenter) { + FastNoteAuthorPicture( + author = author, + userAccount = accountUser, + size = 35.dp + ) + + amount?.let { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.background.copy(0.52f)), + contentAlignment = Alignment.BottomCenter + ) { + Text( + text = it, + fontWeight = FontWeight.Bold, + color = BitcoinOrange, + fontSize = 12.sp + ) + } + } + } if (!comment.isNullOrBlank()) { Spacer(modifier = Modifier.width(5.dp)) @@ -288,20 +380,22 @@ fun AuthorGallery( account: Account, accountViewModel: AccountViewModel ) { - val accountState by account.userProfile().live().follows.observeAsState() - val accountUser = accountState?.user ?: return + val accountState = account.userProfile().live().follows.observeAsState() + val listToRender = remember { + Pair( + authorNotes.take(50).mapNotNull { it.author }, + authorNotes.size + ) + } Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { - authorNotes.take(50).forEach { - val author = it.author - if (author != null) { - AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel) - } + listToRender.first.forEach { author -> + AuthorPictureAndComment(author, null, null, navController, accountState, accountViewModel) } - if (authorNotes.size > 50) { - Text(" and ${authorNotes.size - 50} others") + if (listToRender.second > 50) { + Text(" and ${listToRender.second - 50} others") } } } @@ -310,18 +404,26 @@ fun AuthorGallery( @Composable fun FastNoteAuthorPicture( author: User, - userAccount: User, + userAccount: State, size: Dp, pictureModifier: Modifier = Modifier ) { val userState by author.live().metadata.observeAsState() - val user = userState?.user ?: return + val profilePicture = remember(userState) { + userState?.user?.profilePicture() + } - val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount + val authorPubKey = remember { + author.pubkeyHex + } + + val showFollowingMark = remember(userAccount.value) { + userAccount.value?.user?.isFollowingCached(author) == true || (author === userAccount.value?.user) + } UserPicture( - userHex = user.pubkeyHex, - userPicture = user.profilePicture(), + userHex = authorPubKey, + userPicture = profilePicture, showFollowingMark = showFollowingMark, size = size, modifier = pictureModifier 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 31dc03ec1d..4940819b7c 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 @@ -19,6 +19,7 @@ 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.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -34,38 +35,55 @@ import com.vitorpamplona.amethyst.model.UserMetadata import com.vitorpamplona.amethyst.service.Nip05Verifier import com.vitorpamplona.amethyst.ui.theme.Nip05 import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import java.util.Date @Composable fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State { - var nip05Verified = remember { mutableStateOf(null) } + val nip05Verified = remember(user) { + // starts with null if must verify or already filled in if verified in the last hour + val default = if ((user.nip05LastVerificationTime ?: 0) > (Date().time / 1000 - 60 * 60)) { // 1hour + user.nip05Verified + } else { + null + } + + mutableStateOf(default) + } + + val scope = rememberCoroutineScope() LaunchedEffect(key1 = user) { - withContext(Dispatchers.IO) { - user.nip05?.ifBlank { null }?.let { nip05 -> - val now = Date().time / 1000 - if ((user.nip05LastVerificationTime ?: 0) > (now - 60 * 60)) { // 1hour - nip05Verified.value = user.nip05Verified - } else { + if (nip05Verified.value == null) { + scope.launch(Dispatchers.IO) { + user.nip05?.ifBlank { null }?.let { nip05 -> Nip05Verifier().verifyNip05( nip05, onSuccess = { // Marks user as verified if (it == pubkeyHex) { user.nip05Verified = true - user.nip05LastVerificationTime = now - nip05Verified.value = true + user.nip05LastVerificationTime = Date().time / 1000 + + if (nip05Verified.value != true) { + nip05Verified.value = true + } } else { user.nip05Verified = false user.nip05LastVerificationTime = 0 - nip05Verified.value = false + + if (nip05Verified.value != false) { + nip05Verified.value = false + } } }, onError = { user.nip05LastVerificationTime = 0 user.nip05Verified = false - nip05Verified.value = false + + if (nip05Verified.value != false) { + nip05Verified.value = false + } } ) } @@ -90,16 +108,14 @@ fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifie @Composable fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) { val userState by baseUser.live().metadata.observeAsState() - val user = userState?.user ?: return + val user = remember(userState) { userState?.user } ?: return + val parts = remember(userState) { userState?.user?.nip05()?.split("@") } ?: return - user.nip05()?.let { nip05 -> - val parts = nip05.split("@") - if (parts.size == 2) { - val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) + if (parts.size == 2) { + val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) - Column(modifier = columnModifier) { - DisplayNIP05(parts[0], parts[1], nip05Verified) - } + Column(modifier = columnModifier) { + DisplayNIP05(parts[0], parts[1], nip05Verified) } } } 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 5c6bdc9591..7c23d16233 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -24,6 +25,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Divider import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem @@ -37,6 +40,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.lightColors import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -48,8 +52,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix @@ -81,7 +87,9 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent @@ -92,20 +100,25 @@ import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.Participant +import com.vitorpamplona.amethyst.service.model.PeopleListEvent +import com.vitorpamplona.amethyst.service.model.PinListEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent 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.ReportedKey import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.components.CreateClickableText +import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji 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.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.components.ZoomableContent import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage @@ -125,10 +138,9 @@ import nostr.postr.toNpub import java.io.File import java.math.BigDecimal import java.net.URL -import kotlin.time.ExperimentalTime -import kotlin.time.measureTimedValue +import java.util.Locale -@OptIn(ExperimentalTime::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun NoteCompose( baseNote: Note, @@ -142,244 +154,248 @@ fun NoteCompose( parentBackgroundColor: Color? = null, accountViewModel: AccountViewModel, navController: NavController -) { - val (value, elapsed) = measureTimedValue { - NoteComposeInner( - baseNote, - routeForLastRead, - modifier, - isBoostedNote, - isQuotedNote, - unPackReply, - makeItShort, - addMarginTop, - parentBackgroundColor, - accountViewModel, - navController - ) - } - - 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, ExperimentalTime::class) -@Composable -fun NoteComposeInner( - baseNote: Note, - routeForLastRead: String? = null, - modifier: Modifier = Modifier, - isBoostedNote: Boolean = false, - isQuotedNote: Boolean = false, - unPackReply: Boolean = true, - makeItShort: Boolean = false, - addMarginTop: Boolean = true, - parentBackgroundColor: Color? = null, - accountViewModel: AccountViewModel, - navController: NavController ) { val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - val loggedIn = account.userProfile() + val account = remember(accountState) { accountState?.account } ?: return + val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note + val note = remember(noteState) { noteState?.note } ?: return val noteReportsState by baseNote.live().reports.observeAsState() - val noteForReports = noteReportsState?.note ?: return + val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return + + val noteEvent = remember(noteState) { note.event } + val baseChannel = remember(noteState) { note.channel() } var popupExpanded by remember { mutableStateOf(false) } - var showHiddenNote by remember { mutableStateOf(false) } - - var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) } - - LaunchedEffect(key1 = noteReportsState) { - withContext(Dispatchers.IO) { - val newCanPreview = note?.author === loggedIn || - (note?.author?.let { loggedIn.isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - val newIsAcceptable = account.isAcceptable(noteForReports) - - if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) { - isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview) - } - } - } - - val noteEvent = note?.event - val baseChannel = note?.channel() if (noteEvent == null) { BlankNote( - modifier.combinedClickable( - onClick = { }, - onLongClick = { popupExpanded = true } - ), + remember { + modifier.combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true } + ) + }, isBoostedNote ) - } else if (!isAcceptableAndCanPreview.first && !showHiddenNote) { - if (!account.isHidden(noteForReports.author!!)) { + + note.let { + NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel) + } + } else if (account.isHidden(noteForReports.author!!)) { + // Does nothing + } else { + var showHiddenNote by remember { mutableStateOf(false) } + var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) } + + LaunchedEffect(key1 = noteReportsState, key2 = accountState) { + withContext(Dispatchers.IO) { + account.userProfile().let { loggedIn -> + val newCanPreview = note.author === loggedIn || + (note.author?.let { loggedIn.isFollowingCached(it) } ?: true) || + !(noteForReports.hasAnyReports()) + + val newIsAcceptable = account.isAcceptable(noteForReports) + + if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) { + isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview) + } + } + } + } + + if (!isAcceptableAndCanPreview.first && !showHiddenNote) { HiddenNote( account.getRelevantReports(noteForReports), - loggedIn, + account.userProfile(), modifier, isBoostedNote, navController, onClick = { showHiddenNote = true } ) - } - } else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) { - ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) - } else if (noteEvent is BadgeDefinitionEvent) { - BadgeDisplay(baseNote = note) - } else if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(note) - } else if (noteEvent is FileStorageHeaderEvent) { - FileStorageHeaderDisplay(note) - } else { - var isNew by remember { mutableStateOf(false) } - - val scope = rememberCoroutineScope() - - LaunchedEffect(key1 = routeForLastRead) { - scope.launch(Dispatchers.IO) { - routeForLastRead?.let { - val lastTime = NotificationCache.load(it) - - val createdAt = note.createdAt() - if (createdAt != null) { - NotificationCache.markAsRead(it, createdAt) - - val newIsNew = createdAt > lastTime - if (newIsNew != isNew) { - isNew = newIsNew - } - } - } - } - } - - val backgroundColor = if (isNew) { - val newColor = MaterialTheme.colors.primary.copy(0.12f) - if (parentBackgroundColor != null) { - newColor.compositeOver(parentBackgroundColor) - } else { - newColor.compositeOver(MaterialTheme.colors.background) - } + } else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) { + ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) + } else if (noteEvent is BadgeDefinitionEvent) { + BadgeDisplay(baseNote = note) + } else if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(note) } else { - parentBackgroundColor ?: MaterialTheme.colors.background - } + var isNew by remember { mutableStateOf(false) } - Column( - modifier = modifier - .combinedClickable( - onClick = { - scope.launch { - routeFor(note, loggedIn)?.let { navController.navigate(it) } + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = routeForLastRead) { + scope.launch(Dispatchers.IO) { + routeForLastRead?.let { + val lastTime = NotificationCache.load(it) + + val createdAt = note.createdAt() + if (createdAt != null) { + NotificationCache.markAsRead(it, createdAt) + + val newIsNew = createdAt > lastTime + if (newIsNew != isNew) { + isNew = newIsNew + } } - }, - onLongClick = { popupExpanded = true } - ) - .background(backgroundColor) - ) { - Row( - modifier = Modifier - .padding( - start = if (!isBoostedNote) 12.dp else 0.dp, - end = if (!isBoostedNote) 12.dp else 0.dp, - top = if (addMarginTop && !isBoostedNote) 10.dp else 0.dp - ) - ) { - if (!isBoostedNote && !isQuotedNote) { - DrawAuthorImages(baseNote, loggedIn, navController) + } } + } - Column( - modifier = Modifier - .padding(start = if (!isBoostedNote && !isQuotedNote) 10.dp else 0.dp) - ) { - FirstUserInfoRow( - baseNote = baseNote, - showAuthorPicture = isQuotedNote, - account = account, - accountViewModel = accountViewModel, - navController = navController + val primaryColor = MaterialTheme.colors.primary.copy(0.12f) + val defaultBackgroundColor = MaterialTheme.colors.background + + val backgroundColor = remember(isNew, parentBackgroundColor) { + if (isNew) { + if (parentBackgroundColor != null) { + primaryColor.compositeOver(parentBackgroundColor) + } else { + primaryColor.compositeOver(defaultBackgroundColor) + } + } else { + parentBackgroundColor ?: defaultBackgroundColor + } + } + + val columnModifier = remember(backgroundColor) { + modifier + .combinedClickable( + onClick = { + scope.launch { + routeFor(note, loggedIn)?.let { + if (note.idHex != navController.currentBackStackEntry?.arguments?.getString("id")) { + navController.navigate(it) + } else { + Log.d("Amethyst-Navigation", "Note already exists in the backstack!") + } + } + } + }, + onLongClick = { popupExpanded = true } ) + .background(backgroundColor) + } - if (noteEvent !is RepostEvent && !makeItShort && !isQuotedNote) { - SecondUserInfoRow( - note, - account, - navController - ) + Column(modifier = columnModifier) { + Row( + modifier = remember { + Modifier + .padding( + start = if (!isBoostedNote) 12.dp else 0.dp, + end = if (!isBoostedNote) 12.dp else 0.dp, + top = if (addMarginTop && !isBoostedNote) 10.dp else 0.dp + ) + } + ) { + if (!isBoostedNote && !isQuotedNote) { + DrawAuthorImages(baseNote, loggedIn, navController) } - Spacer(modifier = Modifier.height(2.dp)) - - if (!makeItShort) { - ReplyRow( - note, - unPackReply, - backgroundColor, - account, - accountViewModel, - navController + Column( + modifier = remember { + Modifier + .padding(start = if (!isBoostedNote && !isQuotedNote) 10.dp else 0.dp) + } + ) { + FirstUserInfoRow( + baseNote = baseNote, + showAuthorPicture = isQuotedNote, + account = account, + accountViewModel = accountViewModel, + navController = navController ) - } - when (noteEvent) { - is ReactionEvent -> { - RenderReaction(note, backgroundColor, accountViewModel, navController) - } - - is RepostEvent -> { - RenderRepost(note, backgroundColor, accountViewModel, navController) - } - - is ReportEvent -> { - RenderReport(note) - } - - is LongTextNoteEvent -> { - RenderLongFormContent(note, loggedIn, accountViewModel, navController) - } - - is BadgeAwardEvent -> { - RenderBadgeAward(note, backgroundColor, accountViewModel, navController) - } - - is PrivateDmEvent -> { - RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController) - } - - is HighlightEvent -> { - RenderHighlight(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController) - } - - is PollNoteEvent -> { - RenderPoll( + if (noteEvent !is RepostEvent && !makeItShort && !isQuotedNote) { + SecondUserInfoRow( note, - makeItShort, - isAcceptableAndCanPreview.second, + account, + navController + ) + } + + Spacer(modifier = Modifier.height(2.dp)) + + if (!makeItShort) { + ReplyRow( + note, + unPackReply, backgroundColor, + account, accountViewModel, navController ) } - else -> { - RenderTextEvent( - note, - makeItShort, - isAcceptableAndCanPreview.second, - backgroundColor, - accountViewModel, - navController - ) - } - } + when (noteEvent) { + is ReactionEvent -> { + RenderReaction(note, backgroundColor, accountViewModel, navController) + } - NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + is RepostEvent -> { + RenderRepost(note, backgroundColor, accountViewModel, navController) + } + + is ReportEvent -> { + RenderReport(note) + } + + is LongTextNoteEvent -> { + RenderLongFormContent(note, loggedIn, accountViewModel, navController) + } + + is BadgeAwardEvent -> { + RenderBadgeAward(note, backgroundColor, accountViewModel, navController) + } + + is PeopleListEvent -> { + RenderPeopleList(noteState, backgroundColor, accountViewModel, navController) + } + + is AudioTrackEvent -> { + RenderAudioTrack(note, loggedIn, accountViewModel, navController) + } + + is PinListEvent -> { + RenderPinListEvent(noteState, backgroundColor, accountViewModel, navController) + } + + is PrivateDmEvent -> { + RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController) + } + + is HighlightEvent -> { + RenderHighlight(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController) + } + + is PollNoteEvent -> { + RenderPoll( + note, + makeItShort, + isAcceptableAndCanPreview.second, + backgroundColor, + accountViewModel, + navController + ) + } + + else -> { + RenderTextEvent( + note, + makeItShort, + isAcceptableAndCanPreview.second, + backgroundColor, + accountViewModel, + navController + ) + } + } + + NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + } } } } @@ -424,13 +440,14 @@ private fun RenderTextEvent( accountViewModel: AccountViewModel, navController: NavController ) { - val noteEvent = note.event ?: return - - val eventContent = remember { accountViewModel.decrypt(note) } - val modifier = remember { Modifier.fillMaxWidth() } + val tags = remember(note.event?.id()) { note.event?.tags() } + val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() } + val eventContent = remember(note.event?.id()) { accountViewModel.decrypt(note) } + val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } + val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } if (eventContent != null) { - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + if (makeItShort && isAuthorTheLoggedUser) { Text( text = eventContent, color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), @@ -442,13 +459,13 @@ private fun RenderTextEvent( content = eventContent, canPreview = canPreview && !makeItShort, modifier = modifier, - tags = noteEvent.tags(), + tags = tags, backgroundColor = backgroundColor, accountViewModel = accountViewModel, navController = navController ) - DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + DisplayUncitedHashtags(hashtags, eventContent, navController) } } @@ -610,6 +627,114 @@ private fun RenderPrivateMessage( ) } +@Composable +fun RenderPeopleList( + noteState: NoteState?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + DisplayPeopleList(noteState, backgroundColor, accountViewModel, navController) + + noteState?.note?.let { + ReactionsRow(it, accountViewModel, navController) + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + +@Composable +fun DisplayPeopleList( + noteState: NoteState?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val note = remember(noteState) { noteState?.note } ?: return + val noteEvent = note.event as? PeopleListEvent ?: return + + var members by remember { mutableStateOf>(listOf()) } + + val account = accountViewModel.userProfile() + var expanded by remember { + mutableStateOf(false) + } + + val toMembersShow = if (expanded) { + members + } else { + members.take(3) + } + + Text( + text = "#${noteEvent.dTag()}", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center + ) + + LaunchedEffect(key1 = noteState) { + withContext(Dispatchers.IO) { + members = noteEvent.bookmarkedPeople().mapNotNull { hex -> + LocalCache.checkGetOrCreateUser(hex) + }.sortedBy { account.isFollowing(it) }.reversed() + } + } + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { user -> + Row(modifier = Modifier.fillMaxWidth()) { + UserCompose( + user, + overallModifier = Modifier, + accountViewModel = accountViewModel, + navController = navController + ) + } + } + } + + if (members.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + backgroundColor.copy(alpha = 0f), + backgroundColor + ) + ) + ) + ) { + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = { expanded = !expanded }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + .compositeOver(MaterialTheme.colors.background) + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.show_more), color = Color.White) + } + } + } + } +} + @Composable private fun RenderBadgeAward( note: Note, @@ -637,9 +762,11 @@ private fun RenderBadgeAward( FlowRow(modifier = Modifier.padding(top = 5.dp)) { awardees.take(100).forEach { user -> Row( - modifier = Modifier.size(size = 35.dp).clickable { - navController.navigate("User/${user.pubkeyHex}") - }, + modifier = Modifier + .size(size = 35.dp) + .clickable { + navController.navigate("User/${user.pubkeyHex}") + }, verticalAlignment = Alignment.CenterVertically ) { UserPicture( @@ -711,7 +838,11 @@ private fun RenderRepost( accountViewModel: AccountViewModel, navController: NavController ) { - note.replyTo?.lastOrNull()?.let { + val boostedNote = remember { + note.replyTo?.lastOrNull() + } + + boostedNote?.let { NoteCompose( it, modifier = Modifier, @@ -724,6 +855,135 @@ private fun RenderRepost( } } +@Composable +private fun RenderPinListEvent( + noteState: NoteState?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = noteState?.note?.event as? PinListEvent ?: return + + PinListHeader(noteState, backgroundColor, accountViewModel, navController) + + ReactionsRow(noteState?.note, accountViewModel, navController) + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + +@Composable +fun PinListHeader( + noteState: NoteState?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val note = remember(noteState) { noteState?.note } ?: return + val noteEvent = note.event as? PinListEvent ?: return + + var pins by remember { mutableStateOf>(noteEvent.pins()) } + + var expanded by remember { + mutableStateOf(false) + } + + val pinsToShow = if (expanded) { + pins + } else { + pins.take(3) + } + + Text( + text = "#${noteEvent.dTag()}", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center + ) + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + pinsToShow.forEach { pin -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = null, + tint = MaterialTheme.colors.onBackground.copy(0.32f), + modifier = Modifier.size(15.dp) + ) + + Spacer(modifier = Modifier.width(5.dp)) + + TranslatableRichTextViewer( + content = pin, + canPreview = true, + tags = emptyList(), + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) + } + } + } + + if (pins.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + backgroundColor.copy(alpha = 0f), + backgroundColor + ) + ) + ) + ) { + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = { expanded = !expanded }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + .compositeOver(MaterialTheme.colors.background) + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.show_more), color = Color.White) + } + } + } + } +} + +@Composable +private fun RenderAudioTrack( + note: Note, + loggedIn: User, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event as? AudioTrackEvent ?: return + + AudioTrackHeader(noteEvent, note, loggedIn, navController) + + ReactionsRow(note, accountViewModel, navController) + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) +} + @Composable private fun RenderLongFormContent( note: Note, @@ -745,9 +1005,16 @@ private fun RenderLongFormContent( @Composable private fun RenderReport(note: Note) { - val noteEvent = note.event as? ReportEvent ?: return + val base = remember { + val noteEvent = note.event as? ReportEvent + if (noteEvent == null) { + emptyList() + } else { + (noteEvent.reportedPost() + noteEvent.reportedAuthor()) + } + } - val reportType = (noteEvent.reportedPost() + noteEvent.reportedAuthor()).map { + val reportType = base.map { when (it.reportType) { ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) @@ -780,7 +1047,9 @@ private fun ReplyRow( val noteEvent = note.event if (noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) { - val replyingDirectlyTo = note.replyTo?.lastOrNull() + val replyingDirectlyTo = remember { + note.replyTo?.lastOrNull() + } if (replyingDirectlyTo != null && unPackReply) { NoteCompose( baseNote = replyingDirectlyTo, @@ -821,18 +1090,18 @@ private fun SecondUserInfoRow( account: Account, navController: NavController ) { - val noteEvent = note.event ?: return - val noteAuthor = note.author ?: return + val noteEvent = remember { note.event } ?: return + val noteAuthor = remember { note.author } ?: return Row(verticalAlignment = Alignment.CenterVertically) { ObserveDisplayNip05Status(noteAuthor, Modifier.weight(1f)) - val baseReward = noteEvent.getReward() + val baseReward = remember { noteEvent.getReward() } if (baseReward != null) { DisplayReward(baseReward, note, account, navController) } - val pow = noteEvent.getPoWRank() + val pow = remember { noteEvent.getPoWRank() } if (pow > 20) { DisplayPoW(pow) } @@ -848,14 +1117,17 @@ private fun FirstUserInfoRow( navController: NavController ) { var moreActionsExpanded by remember { mutableStateOf(false) } - val eventNote = baseNote.event ?: return - val time = baseNote.createdAt() ?: return - val loggedIn = account.userProfile() + val eventNote = remember { baseNote.event } ?: return + val time = remember { baseNote.createdAt() } ?: return + val loggedIn = remember { account.userProfile() } + val padding = remember { + Modifier.padding(horizontal = 5.dp) + } Row(verticalAlignment = Alignment.CenterVertically) { if (showAuthorPicture) { NoteAuthorPicture(baseNote, navController, loggedIn, 25.dp) - Spacer(Modifier.padding(horizontal = 5.dp)) + Spacer(padding) NoteUsernameDisplay(baseNote, Modifier.weight(1f)) } else { NoteUsernameDisplay(baseNote, Modifier.weight(1f)) @@ -897,12 +1169,14 @@ private fun FirstUserInfoRow( @Composable fun TimeAgo(time: Long) { val context = LocalContext.current - var timeStr by remember { mutableStateOf("") } LaunchedEffect(key1 = time) { withContext(Dispatchers.IO) { - timeStr = timeAgo(time, context = context) + val newTimeStr = timeAgo(time, context = context) + if (newTimeStr != timeStr) { + timeStr = newTimeStr + } } } @@ -916,10 +1190,11 @@ fun TimeAgo(time: Long) { @Composable private fun DrawAuthorImages(baseNote: Note, loggedIn: User, navController: NavController) { val baseChannel = remember { baseNote.channel() } + val modifier = remember { Modifier.width(55.dp) } - Column(Modifier.width(55.dp)) { + Column(modifier) { // Draws the boosted picture outside the boosted card. - Box(modifier = Modifier.width(55.dp), contentAlignment = Alignment.BottomEnd) { + Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) { NoteAuthorPicture(baseNote, navController, loggedIn, 55.dp) if (baseNote.event is RepostEvent) { @@ -932,7 +1207,10 @@ private fun DrawAuthorImages(baseNote: Note, loggedIn: User, navController: NavC } if (baseNote.event is RepostEvent) { - baseNote.replyTo?.lastOrNull()?.let { + val baseReply = remember { + baseNote.replyTo?.lastOrNull() + } + baseReply?.let { RelayBadges(it) } } else { @@ -946,20 +1224,30 @@ private fun ChannelNotePicture(baseChannel: Channel) { val channelState by baseChannel.live.observeAsState() val channel = channelState?.channel + val modifier = remember { + Modifier + .width(30.dp) + .height(30.dp) + .clip(shape = CircleShape) + } + + val boxModifier = remember { + Modifier + .width(30.dp) + .height(30.dp) + } + if (channel != null) { - Box( - Modifier - .width(30.dp) - .height(30.dp) - ) { + val model = remember(channelState) { + ResizeImage(channel.profilePicture(), 30.dp) + } + + Box(boxModifier) { RobohashAsyncImageProxy( robot = channel.idHex, - model = ResizeImage(channel.profilePicture(), 30.dp), + model = model, contentDescription = stringResource(R.string.group_picture), - modifier = Modifier - .width(30.dp) - .height(30.dp) - .clip(shape = CircleShape) + modifier = modifier .background(MaterialTheme.colors.background) .border( 2.dp, @@ -977,12 +1265,16 @@ private fun RepostNoteAuthorPicture( navController: NavController, loggedIn: User ) { - baseNote.replyTo?.lastOrNull()?.let { - Box( - Modifier - .width(30.dp) - .height(30.dp) - ) { + val baseRepost = remember { baseNote.replyTo?.lastOrNull() } + + val modifier = remember { + Modifier + .width(30.dp) + .height(30.dp) + } + + baseRepost?.let { + Box(modifier) { NoteAuthorPicture( it, navController, @@ -1041,25 +1333,30 @@ fun DisplayHighlight( authorHex?.let { authorHex -> userBase?.let { userBase -> val userState by userBase.live().metadata.observeAsState() - val user = userState?.user + val route = remember { "User/${userBase.pubkeyHex}" } + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } - if (user != null) { - CreateClickableText( - user.toBestDisplayName(), - "", - "User/${user.pubkeyHex}", - navController + if (userDisplayName != null) { + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + suffix = " ", + tags = userTags, + route = route, + navController = navController ) } } } url?.let { url -> - val validatedUrl = try { - URL(url) - } catch (e: Exception) { - Log.w("Note Compose", "Invalid URI: $url") - null + val validatedUrl = remember { + try { + URL(url) + } catch (e: Exception) { + Log.w("Note Compose", "Invalid URI: $url") + null + } } validatedUrl?.host?.let { host -> @@ -1334,45 +1631,124 @@ fun FileStorageHeaderDisplay(baseNote: Note) { } } - val noteState = fileNote?.live()?.metadata?.observeAsState() - val note = noteState?.value?.note + fileNote?.let { fileNote2 -> + val noteState by fileNote2.live().metadata.observeAsState() + val note = remember(noteState) { noteState?.note } - var content by remember { mutableStateOf(null) } + var content by remember { mutableStateOf(null) } - LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) { - withContext(Dispatchers.IO) { - val uri = "nostr:" + baseNote.toNEvent() - val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) } - val blurHash = eventHeader.blurhash() - val dimensions = eventHeader.dimensions() - val description = eventHeader.content - val mimeType = eventHeader.mimeType() + LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) { + withContext(Dispatchers.IO) { + val uri = "nostr:" + baseNote.toNEvent() + val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) } + val blurHash = eventHeader.blurhash() + val dimensions = eventHeader.dimensions() + val description = eventHeader.content + val mimeType = eventHeader.mimeType() - content = if (mimeType?.startsWith("image") == true) { - ZoomableLocalImage( - localFile = localDir, - mimeType = mimeType, - description = description, - blurhash = blurHash, - dim = dimensions, - isVerified = true, - uri = uri - ) - } else { - ZoomableLocalVideo( - localFile = localDir, - mimeType = mimeType, - description = description, - dim = dimensions, - isVerified = true, - uri = uri - ) + content = if (mimeType?.startsWith("image") == true) { + ZoomableLocalImage( + localFile = localDir, + mimeType = mimeType, + description = description, + blurhash = blurHash, + dim = dimensions, + isVerified = true, + uri = uri + ) + } else { + ZoomableLocalVideo( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + isVerified = true, + uri = uri + ) + } } } + + content?.let { + ZoomableContentView(content = it, listOf(it)) + } + } +} + +@Composable +fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, loggedIn: User, navController: NavController) { + val media = remember { noteEvent.media() } + val cover = remember { noteEvent.cover() } + val subject = remember { noteEvent.subject() } + val content = remember { noteEvent.content() } + val participants = remember { noteEvent.participants() } + + var participantUsers by remember { mutableStateOf>>(emptyList()) } + + LaunchedEffect(key1 = participants) { + withContext(Dispatchers.IO) { + participantUsers = participants.mapNotNull { part -> LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } } + } } - content?.let { - ZoomableContentView(content = it, listOf(it)) + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Row() { + subject?.let { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(top = 5.dp, start = 10.dp, end = 10.dp) + .clickable { + navController.navigate("User/${it.second.pubkeyHex}") + } + ) { + UserPicture(it.second, loggedIn, 25.dp) + Spacer(Modifier.width(5.dp)) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(Modifier.width(5.dp)) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1 + ) + } + } + } + + media?.let { media -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(10.dp) + ) { + cover?.let { cover -> + VideoView( + videoUri = media, + description = noteEvent.subject(), + thumbUri = cover + ) + } + ?: VideoView( + videoUri = media, + noteEvent.subject() + ) + } + } + } } } @@ -1468,7 +1844,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U @Composable private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() - val noteRelays = noteRelaysState?.note ?: return + val noteRelays = remember(noteRelaysState) { noteRelaysState?.note } ?: return var expanded by remember { mutableStateOf(false) } var showShowMore by remember { mutableStateOf(false) } @@ -1524,44 +1900,59 @@ private fun VerticalRelayPanelWithFlow( private fun RelayIconCompose(url: String) { val uri = LocalUriHandler.current - Box( + val model = remember(url) { "https://$url/favicon.ico" } + + val boxModifier = remember { Modifier .padding(1.dp) .size(15.dp) - ) { + } + val iconModifier = remember(url) { + Modifier + .width(13.dp) + .height(13.dp) + .clip(shape = CircleShape) + .clickable(onClick = { uri.openUri("https://$url") }) + } + val colorFilter = remember { + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) }) + } + + Box(boxModifier) { RobohashFallbackAsyncImage( - robot = "https://$url/favicon.ico", + robot = model, robotSize = 15.dp, - model = "https://$url/favicon.ico", + model = model, contentDescription = stringResource(R.string.relay_icon), - colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), - modifier = Modifier - .width(13.dp) - .height(13.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://$url") }) + colorFilter = colorFilter, + modifier = iconModifier.background(MaterialTheme.colors.background) ) } } @Composable private fun ShowMoreRelaysButton(onClick: () -> Unit) { - Row( + val boxModifier = remember { Modifier .fillMaxWidth() - .height(25.dp), + .height(25.dp) + } + val iconButtonModifier = remember { Modifier.size(24.dp) } + val iconModifier = remember { Modifier.size(15.dp) } + + Row( + boxModifier, horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top ) { IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), + modifier = iconButtonModifier, onClick = onClick ) { Icon( imageVector = Icons.Default.ExpandMore, null, - modifier = Modifier.size(15.dp), + modifier = iconModifier, tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @@ -1577,7 +1968,11 @@ fun NoteAuthorPicture( pictureModifier: Modifier = Modifier ) { NoteAuthorPicture(baseNote, userAccount, size, pictureModifier) { - navController.navigate("User/${it.pubkeyHex}") + if (it.pubkeyHex != navController.currentBackStackEntry?.arguments?.getString("id")) { + navController.navigate("User/${it.pubkeyHex}") + } else { + Log.d("Amethyst-Navigation", "Profile destination already exists in the backstack!") + } } } @@ -1590,25 +1985,30 @@ fun NoteAuthorPicture( onClick: ((User) -> Unit)? = null ) { val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note ?: return + val author = remember(noteState) { + noteState?.note?.author + } - val author = note.author - - Box( + val boxModifier = remember { Modifier .width(size) .height(size) - ) { + } + + val nullModifier = remember { + modifier + .width(size) + .height(size) + .clip(shape = CircleShape) + } + + Box(boxModifier) { if (author == null) { RobohashAsyncImage( robot = "authornotfound", robotSize = size, contentDescription = stringResource(R.string.unknown_author), - modifier = modifier - .width(size) - .height(size) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.background) + modifier = nullModifier.background(MaterialTheme.colors.background) ) } else { UserPicture(author, baseUserAccount, size, modifier, onClick) @@ -1625,7 +2025,11 @@ fun UserPicture( pictureModifier: Modifier = Modifier ) { UserPicture(user, userAccount, size, pictureModifier) { - navController.navigate("User/${it.pubkeyHex}") + if (it.pubkeyHex != navController.currentBackStackEntry?.arguments?.getString("id")) { + navController.navigate("User/${it.pubkeyHex}") + } else { + Log.d("Amethyst-Navigation", "Profile destination already exists in the backstack!") + } } } @@ -1640,31 +2044,38 @@ fun UserPicture( onLongClick: ((User) -> Unit)? = null ) { val userState by baseUser.live().metadata.observeAsState() - val user = userState?.user ?: return - val accountState by baseUserAccount.live().follows.observeAsState() - val accountUser = accountState?.user ?: return - val showFollowingMark = accountUser.isFollowingCached(user) || user == accountUser + val userPubkey = remember { + baseUser.pubkeyHex + } - Row( - modifier = Modifier - .run { - if (onClick != null && onLongClick != null) { - this.combinedClickable( - onClick = { onClick(user) }, - onLongClick = { onLongClick(user) } - ) - } else if (onClick != null) { - this.clickable(onClick = { onClick(user) }) - } else { - this - } - } - ) { + val userProfile = remember(userState) { + userState?.user?.profilePicture() + } + + val showFollowingMark = remember(accountState) { + accountState?.user?.isFollowingCached(baseUser) == true || baseUser === accountState?.user + } + + // BaseUser is the same reference as accountState.user + val myModifier = remember { + if (onClick != null && onLongClick != null) { + Modifier.combinedClickable( + onClick = { onClick(baseUser) }, + onLongClick = { onLongClick(baseUser) } + ) + } else if (onClick != null) { + Modifier.clickable(onClick = { onClick(baseUser) }) + } else { + Modifier + } + } + + Row(modifier = myModifier) { UserPicture( - userHex = user.pubkeyHex, - userPicture = user.profilePicture(), + userHex = userPubkey, + userPicture = userProfile, showFollowingMark = showFollowingMark, size = size, modifier = modifier @@ -1680,43 +2091,59 @@ fun UserPicture( size: Dp, modifier: Modifier = Modifier ) { - Box( + val myBoxModifier = remember { Modifier .width(size) .height(size) - ) { + } + + val myImageModifier = remember { + modifier + .width(size) + .height(size) + .clip(shape = CircleShape) + } + + val myResizeImage = remember(userPicture) { + ResizeImage(userPicture, size) + } + + Box(myBoxModifier) { RobohashAsyncImageProxy( robot = userHex, - model = ResizeImage(userPicture, size), + model = myResizeImage, contentDescription = stringResource(id = R.string.profile_image), - modifier = modifier - .width(size) - .height(size) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.background) + modifier = myImageModifier.background(MaterialTheme.colors.background) ) if (showFollowingMark) { - Box( + val myIconBoxModifier = remember { Modifier .width(size.div(3.5f)) .height(size.div(3.5f)) - .align(Alignment.TopEnd), - contentAlignment = Alignment.Center - ) { - // Background for the transparent checkmark + .align(Alignment.TopEnd) + } + + val myIconBackgroundModifier = remember { + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + } + + val myIconModifier = remember { + Modifier.width(size.div(3.5f)).height(size.div(3.5f)) + } + + Box(myIconBoxModifier, contentAlignment = Alignment.Center) { Box( - Modifier - .clip(CircleShape) - .fillMaxSize(0.6f) - .align(Alignment.Center) - .background(MaterialTheme.colors.background) + myIconBackgroundModifier.background(MaterialTheme.colors.background) ) Icon( painter = painterResource(R.drawable.ic_verified), stringResource(id = R.string.following), - modifier = Modifier.fillMaxSize(), + modifier = myIconModifier, tint = Following ) } @@ -1738,13 +2165,15 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, val actContext = LocalContext.current var reportDialogShowing by remember { mutableStateOf(false) } + val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() + var state by remember { mutableStateOf( DropDownParams(false, false, false, false) ) } - LaunchedEffect(key1 = note) { + LaunchedEffect(key1 = note, key2 = bookmarkState) { withContext(Dispatchers.IO) { state = DropDownParams( accountViewModel.isFollowing(note.author), 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 e5271bc0f4..73c75812c2 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 @@ -62,6 +62,9 @@ import androidx.core.graphics.ColorUtils import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent +import com.vitorpamplona.amethyst.service.model.FileHeaderEvent +import com.vitorpamplona.amethyst.service.model.PeopleListEvent import com.vitorpamplona.amethyst.ui.components.SelectTextDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog @@ -81,11 +84,19 @@ val externalLinkForNote = { note: Note -> if (note is AddressableNote) { if (note.event?.getReward() != null) { "https://nostrbounties.com/b/${note.address().toNAddr()}" + } else if (note.event is PeopleListEvent) { + "https://listr.lol/a/${note.address()?.toNAddr()}" + } else if (note.event is AudioTrackEvent) { + "https://zapstr.live/?track=${note.address()?.toNAddr()}" } else { - "https://habla.news/a/${note.address().toNAddr()}" + "https://habla.news/a/${note.address()?.toNAddr()}" } } else { - "https://snort.social/e/${note.toNEvent()}" + if (note.event is FileHeaderEvent) { + "https://filestr.vercel.app/e/${note.toNEvent()}" + } else { + "https://snort.social/e/${note.toNEvent()}" + } } } @@ -128,7 +139,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni if (popupExpanded) { val isOwnNote = accountViewModel.isLoggedUser(note.author) - val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!) + val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) Popup(onDismissRequest = onDismiss) { Card( @@ -206,10 +217,11 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni VerticalDivider(primaryLight) NoteQuickActionItem( - icon = ImageVector.vectorResource(id = R.drawable.text_select_move_forward_character), - label = stringResource(R.string.quick_action_select) + icon = ImageVector.vectorResource(id = R.drawable.relays), + label = stringResource(R.string.broadcast) ) { - showSelectTextDialog = true + accountViewModel.broadcast(note) + // showSelectTextDialog = true onDismiss() } VerticalDivider(primaryLight) @@ -354,9 +366,7 @@ private fun QuickActionAlertDialog( }, buttons = { Row( - modifier = Modifier - .padding(all = 8.dp) - .fillMaxWidth(), + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { TextButton(onClick = onClickDontShowAgain) { 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 index d5cbcd80b1..12342c2925 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -28,6 +28,7 @@ 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.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -37,7 +38,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.math.BigDecimal import java.util.* import kotlin.math.roundToInt @@ -49,19 +49,45 @@ fun PollNote( accountViewModel: AccountViewModel, navController: NavController ) { - val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note ?: return - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return - val pollViewModel = PollNoteViewModel() - pollViewModel.load(account, zappedNote) + val pollViewModel: PollNoteViewModel = viewModel() - pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> + LaunchedEffect(key1 = baseNote) { + pollViewModel.load(account, baseNote) + } + + PollNote( + baseNote = baseNote, + pollViewModel = pollViewModel, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + navController = navController + ) +} + +@Composable +fun PollNote( + baseNote: Note, + pollViewModel: PollNoteViewModel, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val zapsState by baseNote.live().zaps.observeAsState() + + LaunchedEffect(key1 = zapsState) { + withContext(Dispatchers.IO) { + pollViewModel.refreshTallies() + } + } + + pollViewModel.tallies.forEach { poll_op -> OptionNote( - poll_op.key, - poll_op.value, + poll_op, pollViewModel, baseNote, accountViewModel, @@ -74,8 +100,7 @@ fun PollNote( @Composable private fun OptionNote( - optionNumber: Int, - optionText: String, + poolOption: PollOption, pollViewModel: PollNoteViewModel, baseNote: Note, accountViewModel: AccountViewModel, @@ -88,32 +113,17 @@ private fun OptionNote( modifier = Modifier.padding(vertical = 3.dp) ) { if (!pollViewModel.canZap()) { - val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) - var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) } - - LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) { - withContext(Dispatchers.IO) { - val myTally = pollViewModel.optionVoteTally(optionNumber) - val color = if ( - pollViewModel.consensusThreshold != null && - myTally >= pollViewModel.consensusThreshold!! - ) { - Color.Green.copy(alpha = 0.32f) - } else { - defaultColor - } - - if (myTally > optionTally.first || color != optionTally.second) { - optionTally = Pair(myTally, color) - } - } + val color = if (poolOption.consensusThreadhold) { + Color.Green.copy(alpha = 0.32f) + } else { + MaterialTheme.colors.primary.copy(alpha = 0.32f) } ZapVote( baseNote, + poolOption, accountViewModel, pollViewModel, - optionNumber, nonClickablePrepend = { Box( Modifier @@ -121,14 +131,14 @@ private fun OptionNote( .clip(shape = RoundedCornerShape(15.dp)) .border( 2.dp, - optionTally.second, + color, RoundedCornerShape(15.dp) ) ) { LinearProgressIndicator( modifier = Modifier.matchParentSize(), - color = optionTally.second, - progress = optionTally.first.toFloat() + color = color, + progress = poolOption.tally.toFloat() ) Row( @@ -141,7 +151,7 @@ private fun OptionNote( .width(40.dp) ) { Text( - text = "${(optionTally.first.toFloat() * 100).roundToInt()}%", + text = "${(poolOption.tally.toFloat() * 100).roundToInt()}%", fontWeight = FontWeight.Bold ) } @@ -152,7 +162,7 @@ private fun OptionNote( .padding(15.dp) ) { TranslatableRichTextViewer( - optionText, + poolOption.descriptor, canPreview, Modifier, pollViewModel.pollEvent?.tags(), @@ -170,9 +180,9 @@ private fun OptionNote( } else { ZapVote( baseNote, + poolOption, accountViewModel, pollViewModel, - optionNumber, nonClickablePrepend = {}, clickablePrepend = { Box( @@ -186,7 +196,7 @@ private fun OptionNote( ) ) { TranslatableRichTextViewer( - optionText, + poolOption.descriptor, canPreview, Modifier.padding(15.dp), pollViewModel.pollEvent?.tags(), @@ -205,9 +215,9 @@ private fun OptionNote( @OptIn(ExperimentalFoundationApi::class) fun ZapVote( baseNote: Note, + poolOption: PollOption, accountViewModel: AccountViewModel, pollViewModel: PollNoteViewModel, - pollOption: Int, modifier: Modifier = Modifier, nonClickablePrepend: @Composable () -> Unit, clickablePrepend: @Composable () -> Unit @@ -264,7 +274,7 @@ fun ZapVote( ) .show() } - } else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) { + } else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) { // only allow one vote per option when min==max, i.e. atomic vote amount specified scope.launch { Toast @@ -281,7 +291,7 @@ fun ZapVote( accountViewModel.zap( baseNote, account.zapAmountChoices.first() * 1000, - pollOption, + poolOption.option, "", context, onError = { @@ -311,7 +321,7 @@ fun ZapVote( baseNote, accountViewModel, pollViewModel, - pollOption, + poolOption.option, onDismiss = { wantsToZap = false zappingProgress = 0f @@ -335,17 +345,7 @@ fun ZapVote( clickablePrepend() - var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = zapsState) { - withContext(Dispatchers.IO) { - if (!optionWasZappedByLoggedInUser) { - optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile()) - } - } - } - - if (optionWasZappedByLoggedInUser) { + if (poolOption.zappedByLoggedIn) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, @@ -372,20 +372,10 @@ fun ZapVote( } } - var wasZappedByLoggedInUser by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = zapsState) { - withContext(Dispatchers.IO) { - if (!wasZappedByLoggedInUser) { - wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) - } - } - } - // only show tallies after a user has zapped note - if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) { + if (baseNote.author == accountViewModel.userProfile() || pollViewModel.wasZappedByLoggedInAccount) { Text( - showAmount(pollViewModel.zappedPollOptionAmount(pollOption)), + showAmount(poolOption.zappedValue), fontSize = 14.sp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = modifier 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 index 73c2738062..fa142446ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -1,5 +1,9 @@ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -8,19 +12,30 @@ import java.math.BigDecimal import java.math.RoundingMode import java.util.* -class PollNoteViewModel { +data class PollOption( + val option: Int, + val descriptor: String, + val zappedValue: BigDecimal, + val tally: BigDecimal, + val consensusThreadhold: Boolean, + val zappedByLoggedIn: Boolean +) + +class PollNoteViewModel : ViewModel() { var account: Account? = null private var pollNote: Note? = null var pollEvent: PollNoteEvent? = null - private var pollOptions: Map? = null + 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 - var wasZappedByAuthor: Boolean = false + var wasZappedByLoggedInAccount: Boolean = false + + var tallies by mutableStateOf>(emptyList()) fun load(acc: Account, note: Note?) { account = acc @@ -32,15 +47,35 @@ class PollNoteViewModel { consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() closedAt = pollEvent?.getTagInt(CLOSED_AT) + refreshTallies() + } + + fun refreshTallies() { totalZapped = totalZapped() - wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false + wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false + + tallies = pollOptions?.keys?.map { + val zappedInOption = zappedPollOptionAmount(it) + + val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { + zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP) + } else { + BigDecimal.ZERO + } + + val zappedByLoggedIn = account?.userProfile()?.let { it1 -> isPollOptionZappedBy(it, it1) } ?: false + + val consensus = consensusThreshold != null && myTally >= consensusThreshold!! + + PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn) + } ?: emptyList() } fun canZap(): Boolean { val account = account ?: return false val user = account.userProfile() ?: return false val note = pollNote ?: return false - return user != note.author && !wasZappedByAuthor + return user != note.author && !wasZappedByLoggedInAccount } fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum @@ -88,14 +123,6 @@ class PollNoteViewModel { 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 { return pollNote!!.zaps .any { @@ -105,7 +132,7 @@ class PollNoteViewModel { } } - fun zappedPollOptionAmount(option: Int): BigDecimal { + private fun zappedPollOptionAmount(option: Int): BigDecimal { return pollNote?.zaps?.values?.sumOf { val event = it?.event as? LnZapEvent if (event?.zappedPollOption() == option) { @@ -116,7 +143,7 @@ class PollNoteViewModel { } ?: BigDecimal(0) } - fun totalZapped(): BigDecimal { + private fun totalZapped(): BigDecimal { return pollNote?.zaps?.values?.sumOf { val zapEvent = (it?.event as? LnZapEvent) 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 77cf78c930..f4fbf8f413 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 @@ -68,7 +68,7 @@ import kotlin.math.roundToInt @Composable fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) @@ -123,13 +123,21 @@ fun ReplyReaction( onPress: () -> Unit ) { val repliesState by baseNote.live().replies.observeAsState() - val replies = repliesState?.note?.replies ?: emptySet() + val replies = remember(repliesState) { repliesState?.note?.replies } ?: emptySet() val context = LocalContext.current val scope = rememberCoroutineScope() + val iconButtonModifier = remember { + Modifier.size(iconSize) + } + + val iconModifier = remember { + Modifier.size(iconSize) + } + IconButton( - modifier = Modifier.size(iconSize), + modifier = iconButtonModifier, onClick = { if (accountViewModel.isWriteable()) { onPress() @@ -147,7 +155,7 @@ fun ReplyReaction( Icon( painter = painterResource(R.drawable.ic_comment), null, - modifier = Modifier.size(iconSize), + modifier = iconModifier, tint = grayTint ) } @@ -162,7 +170,7 @@ fun ReplyReaction( } @Composable -public fun BoostReaction( +fun BoostReaction( baseNote: Note, grayTint: Color, accountViewModel: AccountViewModel, @@ -170,17 +178,32 @@ public fun BoostReaction( onQuotePress: () -> Unit ) { val boostsState by baseNote.live().boosts.observeAsState() - val boostedNote = boostsState?.note + val boostedNote = remember(boostsState) { boostsState?.note } ?: return + + val hasBoosted = remember(boostsState) { accountViewModel.hasBoosted(baseNote) } + val wasBoostedByLoggedIn = remember(boostsState) { boostedNote.isBoostedBy(accountViewModel.userProfile()) } + val isWriteable = remember { accountViewModel.isWriteable() } + + val boostCount = remember(boostsState) { showCount(boostedNote.boosts.size) } + val context = LocalContext.current val scope = rememberCoroutineScope() var wantsToBoost by remember { mutableStateOf(false) } + val iconButtonModifier = remember { + Modifier.size(iconSize) + } + + val iconModifier = remember { + Modifier.size(iconSize) + } + IconButton( - modifier = Modifier.then(Modifier.size(iconSize)), + modifier = iconButtonModifier, onClick = { - if (accountViewModel.isWriteable()) { - if (accountViewModel.hasBoosted(baseNote)) { + if (isWriteable) { + if (hasBoosted) { accountViewModel.deleteBoostsTo(baseNote) } else { wantsToBoost = true @@ -210,25 +233,16 @@ public fun BoostReaction( ) } - if (boostedNote?.isBoostedBy(accountViewModel.userProfile()) == true) { - Icon( - painter = painterResource(R.drawable.ic_retweeted), - null, - modifier = Modifier.size(iconSize), - tint = Color.Unspecified - ) - } else { - Icon( - painter = painterResource(R.drawable.ic_retweet), - null, - modifier = Modifier.size(iconSize), - tint = grayTint - ) - } + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = iconModifier, + tint = if (wasBoostedByLoggedIn) Color.Unspecified else grayTint + ) } Text( - " ${showCount(boostedNote?.boosts?.size)}", + " $boostCount", fontSize = 14.sp, color = grayTint ) @@ -243,16 +257,30 @@ fun LikeReaction( heartSize: Dp = 16.dp ) { val reactionsState by baseNote.live().reactions.observeAsState() - val reactedNote = reactionsState?.note ?: return + val reactedNote = remember(reactionsState) { reactionsState?.note } ?: return + + val hasReacted = remember(reactionsState) { accountViewModel.hasReactedTo(baseNote) } + val wasReactedByLoggedIn = remember(reactionsState) { reactedNote.isReactedBy(accountViewModel.userProfile()) } + val isWriteable = remember { accountViewModel.isWriteable() } + + val reactionCount = remember(reactionsState) { showCount(reactedNote.reactions.size) } val context = LocalContext.current val scope = rememberCoroutineScope() + val iconButtonModifier = remember { + Modifier.size(iconSize) + } + + val iconModifier = remember { + Modifier.size(heartSize) + } + IconButton( - modifier = Modifier.then(Modifier.size(iconSize)), + modifier = iconButtonModifier, onClick = { - if (accountViewModel.isWriteable()) { - if (accountViewModel.hasReactedTo(baseNote)) { + if (isWriteable) { + if (hasReacted) { accountViewModel.deleteReactionTo(baseNote) } else { accountViewModel.reactTo(baseNote) @@ -268,25 +296,25 @@ fun LikeReaction( } } ) { - if (reactedNote.isReactedBy(accountViewModel.userProfile())) { + if (wasReactedByLoggedIn) { Icon( painter = painterResource(R.drawable.ic_liked), null, - modifier = Modifier.size(heartSize), + modifier = iconModifier, tint = Color.Unspecified ) } else { Icon( painter = painterResource(R.drawable.ic_like), null, - modifier = Modifier.size(heartSize), + modifier = iconModifier, tint = grayTint ) } } Text( - " ${showCount(reactedNote.reactions.size)}", + " $reactionCount", fontSize = 14.sp, color = grayTint ) @@ -303,15 +331,15 @@ fun ZapReaction( animationSize: Dp = 14.dp ) { val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note ?: return - val zapMessage = "" + val zappedNote = remember(zapsState) { zapsState?.note } ?: return var wantsToZap by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) } + val context = LocalContext.current val scope = rememberCoroutineScope() @@ -327,6 +355,10 @@ fun ZapReaction( } zapAmountTxt = showAmount(account.calculateZappedAmount(zappedNote)) + + if (wasZappedByLoggedInUser) { + zappingProgress = 1f + } } } @@ -365,7 +397,7 @@ fun ZapReaction( baseNote, account.zapAmountChoices.first() * 1000, null, - zapMessage, + "", context, onError = { scope.launch { @@ -429,7 +461,6 @@ fun ZapReaction( } if (wasZappedByLoggedInUser) { - zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, contentDescription = stringResource(R.string.zaps), @@ -472,6 +503,19 @@ public fun ViewCountReaction( numberSize: Dp = 24.dp ) { val uri = LocalUriHandler.current + val context = LocalContext.current + + val iconButtonModifier = remember { + Modifier.size(barChartSize) + } + + val iconModifier = remember { + Modifier.height(numberSize) + } + + val colorFilter = remember { + ColorFilter.tint(grayTint) + } IconButton( modifier = Modifier.size(iconSize), @@ -480,21 +524,25 @@ public fun ViewCountReaction( Icon( imageVector = Icons.Outlined.BarChart, null, - modifier = Modifier.size(barChartSize), + modifier = iconButtonModifier, tint = grayTint ) } - Row() { + val request = remember { + ImageRequest.Builder(context) + .data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000") + .diskCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .build() + } + + Row { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000") - .diskCachePolicy(CachePolicy.DISABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - .build(), + model = request, contentDescription = stringResource(R.string.view_count), - modifier = Modifier.height(numberSize), - colorFilter = ColorFilter.tint(grayTint) + modifier = iconModifier, + colorFilter = colorFilter ) } } 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 18446bf039..2d7ab31a40 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 @@ -18,6 +18,7 @@ import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -59,8 +60,9 @@ fun ReplyInformation(replyTo: List?, dupMentions: List?, account: Ac val innerUser = innerUserState?.user innerUser?.let { myUser -> - ClickableText( - AnnotatedString("$prefix@${myUser.toBestDisplayName()}"), + CreateClickableTextWithEmoji( + clickablePart = "$prefix@${myUser.toBestDisplayName()}", + tags = myUser.info?.latestMetadata?.tags, style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), onClick = { onUserTagClick(myUser) } ) @@ -196,8 +198,9 @@ fun ReplyInformationChannel( val innerUser = innerUserState?.user innerUser?.let { myUser -> - ClickableText( - AnnotatedString("$prefix@${myUser.toBestDisplayName()}"), + CreateClickableTextWithEmoji( + clickablePart = "$prefix@${myUser.toBestDisplayName()}", + tags = myUser.info?.latestMetadata?.tags, style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp), onClick = { onUserTagClick(myUser) } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index ebcaca5466..1164d90677 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -25,7 +25,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { +fun UserCompose( + baseUser: User, + overallModifier: Modifier = Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp + ), + accountViewModel: AccountViewModel, + navController: NavController +) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -41,12 +51,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle ) ) { Row( - modifier = Modifier - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ), + modifier = overallModifier, verticalAlignment = Alignment.CenterVertically ) { UserPicture(baseUser, navController, account.userProfile(), 55.dp) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt new file mode 100644 index 0000000000..b6d243aa58 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -0,0 +1,353 @@ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel +import com.patrykandpatrick.vico.core.entry.ChartEntryModel +import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer +import com.patrykandpatrick.vico.core.entry.composed.plus +import com.patrykandpatrick.vico.core.entry.entryOf +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.ReactionEvent +import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun UserReactionsRow(model: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController, onClick: () -> Unit) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier + .clickable(onClick = onClick) + .padding(10.dp) + ) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.width(68.dp)) { + Text( + text = stringResource(id = R.string.today), + fontWeight = FontWeight.Bold + ) + + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserReplyReaction(model.replies[model.today()]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserBoostReaction(model.boosts[model.today()]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserLikeReaction(model.reactions[model.today()]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserZapReaction(model.zaps[model.today()]) + } + } +} + +class UserReactionsViewModel : ViewModel() { + var user: User? = null + + var reactions by mutableStateOf>(emptyMap()) + var boosts by mutableStateOf>(emptyMap()) + var zaps by mutableStateOf>(emptyMap()) + var replies by mutableStateOf>(emptyMap()) + + var takenIntoAccount = setOf() + + val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + + var chartModel by mutableStateOf?>(null) + var axisLabels by mutableStateOf>(emptyList()) + + fun load(baseUser: User) { + user = baseUser + reactions = emptyMap() + boosts = emptyMap() + zaps = emptyMap() + replies = emptyMap() + takenIntoAccount = emptySet() + } + + fun formatDate(createAt: Long): String { + return sdf.format( + Instant.ofEpochSecond(createAt) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + ) + } + + fun today() = sdf.format(LocalDateTime.now()) + + fun initializeSuspend() { + val currentUser = user?.pubkeyHex ?: return + + val reactions = mutableMapOf() + val boosts = mutableMapOf() + val zaps = mutableMapOf() + val replies = mutableMapOf() + val takenIntoAccount = mutableSetOf() + + LocalCache.notes.values.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is RepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is LnZapEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } + } + } + + this.takenIntoAccount = takenIntoAccount + this.reactions = reactions + this.replies = replies + this.zaps = zaps + this.boosts = boosts + + refreshChartModel() + } + + fun addToStatsSuspend(newNotes: Set) { + val currentUser = user?.pubkeyHex ?: return + + val reactions = this.reactions.toMutableMap() + val boosts = this.boosts.toMutableMap() + val zaps = this.zaps.toMutableMap() + val replies = this.replies.toMutableMap() + val takenIntoAccount = this.takenIntoAccount.toMutableSet() + var hasNewElements = false + + newNotes.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is RepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is LnZapEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } + } + } + + if (hasNewElements) { + this.takenIntoAccount = takenIntoAccount + this.reactions = reactions + this.replies = replies + this.zaps = zaps + this.boosts = boosts + + refreshChartModel() + } + } + + private fun refreshChartModel() { + val day = 24 * 60 * 60L + val now = LocalDateTime.now() + val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") + + val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } + + val listOfCountCurves = listOf( + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, replies[dateStr]?.toFloat() ?: 0f) }, + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, boosts[dateStr]?.toFloat() ?: 0f) }, + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, reactions[dateStr]?.toFloat() ?: 0f) } + ) + + val listOfValueCurves = listOf( + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, zaps[dateStr]?.toFloat() ?: 0f) } + ) + + val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() + val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() + + this.axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) } + this.chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2) + } + + var collectorJob: Job? = null + + init { + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + addToStatsSuspend(newNotes) + } + } + } + + override fun onCleared() { + collectorJob?.cancel() + super.onCleared() + } +} + +@Composable +fun UserReplyReaction( + replyCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(20.dp), + tint = Color.Cyan + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(replyCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserBoostReaction( + boostCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(boostCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserLikeReaction( + likeCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(likeCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserZapReaction( + amount: BigDecimal? +) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(24.dp), + tint = BitcoinOrange + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + showAmountAxis(amount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 836d1aa8bc..a6a59f32b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -5,34 +5,31 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @Composable fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) { val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note ?: return + val author = remember(noteState) { noteState?.note?.author } ?: return - val author = note.author - - if (author != null) { - UsernameDisplay(author, weight) - } + UsernameDisplay(author, weight) } @Composable fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) { val userState by baseUser.live().metadata.observeAsState() - val user = userState?.user ?: return + val bestUserName = remember(userState) { userState?.user?.bestUsername() } + val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() } + val npubDisplay = remember { baseUser.pubkeyDisplayHex() } + val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } - val bestUserName = user.bestUsername() - val bestDisplayName = user.bestDisplayName() - val npubDisplay = user.pubkeyDisplayHex() - - UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight) + UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight) } @Composable @@ -40,31 +37,36 @@ private fun UserNameDisplay( bestUserName: String?, bestDisplayName: String?, npubDisplay: String, + tags: List>?, modifier: Modifier ) { if (bestUserName != null && bestDisplayName != null) { - Text( - bestDisplayName, + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, fontWeight = FontWeight.Bold ) - Text( - "@$bestUserName", + CreateTextWithEmoji( + text = "@$bestUserName", + tags = tags, color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier ) } else if (bestDisplayName != null) { - Text( - bestDisplayName, + CreateTextWithEmoji( + text = bestDisplayName, + tags = tags, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier ) } else if (bestUserName != null) { - Text( - "@$bestUserName", + CreateTextWithEmoji( + text = "@$bestUserName", + tags = tags, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 30a7ba6b9a..32d64d4da4 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 @@ -34,6 +34,7 @@ import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner @@ -83,10 +84,10 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { .background(MaterialTheme.colors.background) ) } - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text( - user.bestDisplayName() ?: "", - modifier = Modifier.padding(top = 5.dp), + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) { + CreateTextWithEmoji( + text = user.bestDisplayName() ?: user.bestUsername() ?: "", + tags = user.info?.latestMetadata?.tags, fontWeight = FontWeight.Bold, fontSize = 18.sp ) 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 04a81c7906..bf25694a2e 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 @@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.hexToByteArray +import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.nip19.Nip19 import fr.acinq.secp256k1.Hex import kotlinx.coroutines.CoroutineScope @@ -39,21 +40,22 @@ class AccountStateViewModel() : ViewModel() { } } - fun startUI(key: String) { + fun startUI(key: String, useProxy: Boolean, proxyPort: Int) { val pattern = Pattern.compile(".+@.+\\.[a-z]+") val parsed = Nip19.uriToRoute(key) val pubKeyParsed = parsed?.hex?.hexToByteArray() + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) val account = if (key.startsWith("nsec")) { - Account(Persona(privKey = key.bechToBytes())) + Account(Persona(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort) } else if (pubKeyParsed != null) { - Account(Persona(pubKey = pubKeyParsed)) + Account(Persona(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort) } else if (pattern.matcher(key).matches()) { // Evaluate NIP-5 - Account(Persona()) + Account(Persona(), proxy = proxy, proxyPort = proxyPort) } else { - Account(Persona(Hex.decode(key))) + Account(Persona(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort) } LocalPreferences.updatePrefsForLogin(account) @@ -66,8 +68,9 @@ class AccountStateViewModel() : ViewModel() { tryLoginExistingAccount() } - fun newKey() { - val account = Account(Persona()) + fun newKey(useProxy: Boolean, proxyPort: Int) { + val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) + val account = Account(Persona(), proxy = proxy, proxyPort = proxyPort) // saves to local preferences LocalPreferences.updatePrefsForLogin(account) startUI(account) 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 3f374730c2..df080b9f62 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -9,6 +10,7 @@ abstract class Card() { abstract fun id(): String } +@Immutable class BadgeCard(val note: Note) : Card() { override fun createdAt(): Long { return note.createdAt() ?: 0 @@ -17,6 +19,7 @@ class BadgeCard(val note: Note) : Card() { override fun id() = note.idHex } +@Immutable class NoteCard(val note: Note) : Card() { override fun createdAt(): Long { return note.createdAt() ?: 0 @@ -25,6 +28,7 @@ class NoteCard(val note: Note) : Card() { override fun id() = note.idHex } +@Immutable class LikeSetCard(val note: Note, val likeEvents: List) : Card() { val createdAt = likeEvents.maxOf { it.createdAt() ?: 0 } override fun createdAt(): Long { @@ -33,6 +37,7 @@ class LikeSetCard(val note: Note, val likeEvents: List) : Card() { override fun id() = note.idHex + "L" + createdAt } +@Immutable class ZapSetCard(val note: Note, val zapEvents: Map) : Card() { val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 } override fun createdAt(): Long { @@ -41,6 +46,7 @@ class ZapSetCard(val note: Note, val zapEvents: Map) : Card() { override fun id() = note.idHex + "Z" + createdAt } +@Immutable class ZapUserSetCard(val user: User, val zapEvents: Map) : Card() { val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 } override fun createdAt(): Long { @@ -49,6 +55,7 @@ class ZapUserSetCard(val user: User, val zapEvents: Map) : Card() { override fun id() = user.pubkeyHex + "U" + createdAt } +@Immutable 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, @@ -62,6 +69,7 @@ class MultiSetCard(val note: Note, val boostEvents: List, val likeEvents: override fun id() = note.idHex + "X" + createdAt } +@Immutable class BoostSetCard(val note: Note, val boostEvents: List) : Card() { val createdAt = boostEvents.maxOf { it.createdAt() ?: 0 } @@ -72,6 +80,7 @@ class BoostSetCard(val note: Note, val boostEvents: List) : Card() { override fun id() = note.idHex + "B" + createdAt } +@Immutable class MessageSetCard(val note: Note) : Card() { override fun createdAt(): Long { return note.createdAt() ?: 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 b7d1344bb2..6b1d28edc3 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 @@ -67,7 +67,10 @@ fun CardFeedView( } } is CardFeedState.Loaded -> { - refreshing = false + if (refreshing) { + refreshing = false + } + FeedLoaded( state = state, accountViewModel = accountViewModel, 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 70c152010d..40ed18d80e 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 @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -36,7 +37,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { val feedContent = _feedContent.asStateFlow() private var lastAccount: Account? = null - private var lastNotes: List? = null + private var lastNotes: Set? = null fun refresh() { val scope = CoroutineScope(Job() + Dispatchers.Default) @@ -47,7 +48,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { @Synchronized private fun refreshSuspended() { - val notes = localFilter.loadTop() + val notes = localFilter.feed() val thisAccount = (localFilter as? NotificationFeedFilter)?.account val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null @@ -56,13 +57,18 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { val newCards = convertToCard(notes.minus(lastNotesCopy)) if (newCards.isNotEmpty()) { - lastNotes = notes + lastNotes = notes.toSet() lastAccount = (localFilter as? NotificationFeedFilter)?.account - updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) + val singleList = (oldNotesState.feed.value + newCards) + .distinctBy { it.id() } + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + .take(1000) + updateFeed(singleList) } } else { val cards = convertToCard(notes) - lastNotes = notes + lastNotes = notes.toSet() lastAccount = (localFilter as? NotificationFeedFilter)?.account updateFeed(cards) } @@ -128,7 +134,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() val zapsInCard = zapsPerEvent[baseNote] ?: emptyMap() - val singleList = (boostsInCard + zapsInCard.values + reactionsInCard).sortedBy { it.createdAt() }.reversed() + val singleList = (boostsInCard + zapsInCard.values + reactionsInCard).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() singleList.chunked(50).map { chunk -> MultiSetCard( baseNote, @@ -156,7 +162,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - return (multiCards + textNoteCards + userZaps).sortedBy { it.createdAt() }.reversed() + return (multiCards + textNoteCards + userZaps).sortedWith(compareBy({ it.createdAt() }, { it.id() })).reversed() } private fun updateFeed(notes: List) { @@ -183,13 +189,26 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) { val filteredNewList = localFilter.applyFilter(newItems) + + if (filteredNewList.isEmpty()) return + val actuallyNew = filteredNewList.minus(lastNotesCopy) + if (actuallyNew.isEmpty()) return + val newCards = convertToCard(actuallyNew) + if (newCards.isNotEmpty()) { - lastNotes = lastNotesCopy + newItems + lastNotes = lastNotesCopy + actuallyNew lastAccount = (localFilter as? NotificationFeedFilter)?.account - updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) + + val updatedCards = (oldNotesState.feed.value + newCards) + .distinctBy { it.id() } + .sortedWith(compareBy({ it.createdAt() }, { it.id() })) + .reversed() + .take(1000) + + updateFeed(updatedCards) } } else { // Refresh Everything @@ -215,24 +234,29 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { @OptIn(ExperimentalTime::class) fun invalidateInsertData(newItems: Set) { bundlerInsert.invalidateList(newItems) { + val newObjects = it.flatten().toSet() val (value, elapsed) = measureTimedValue { - refreshFromOldState(it.flatten().toSet()) + if (newObjects.isNotEmpty()) { + refreshFromOldState(newObjects) + } } - Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed") + Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed. ${newObjects.size}") } } - private val cacheListener: (Set) -> Unit = { newNotes -> - if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { - invalidateInsertData(newNotes) - } else { - // Refresh Everything - invalidateData() - } - } + var collectorJob: Job? = null init { - LocalCache.live.observeForever(cacheListener) + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } + } + } } fun clear() { @@ -242,7 +266,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { override fun onCleared() { clear() - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index 8238ad33ee..60905dbc39 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -65,7 +65,9 @@ fun ChatroomListFeedView( } is FeedState.Loaded -> { - refreshing = false + if (refreshing) { + refreshing = false + } FeedLoaded(state, accountViewModel, navController, markAsRead) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 1b78c427c7..e5faa3fdc5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -66,18 +66,20 @@ fun FeedView( when (state) { is FeedState.Empty -> { FeedEmpty { - refreshing = true + refresh() } } is FeedState.FeedError -> { FeedError(state.errorMessage) { - refreshing = true + refresh() } } is FeedState.Loaded -> { - refreshing = false + if (refreshing) { + refreshing = false + } FeedLoaded( state, routeForLastRead, @@ -122,6 +124,10 @@ private fun FeedLoaded( } } + val baseModifier = remember { + Modifier + } + LazyColumn( contentPadding = PaddingValues( top = 10.dp, @@ -132,8 +138,9 @@ private fun FeedLoaded( itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> NoteCompose( item, - isBoostedNote = false, routeForLastRead = routeForLastRead, + modifier = baseModifier, + isBoostedNote = false, accountViewModel = accountViewModel, navController = navController ) 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 1290d4ed4b..a3f3da1a54 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 @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BundledInsert @@ -98,7 +99,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { val oldNotesState = _feedContent.value if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) { val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()) - updateFeed(newList) + if (newList !== oldNotesState.feed.value) { + updateFeed(newList) + } } else { // Refresh Everything refreshSuspended() @@ -122,21 +125,23 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - private val cacheListener: (Set) -> Unit = { newNotes -> - if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) { - invalidateInsertData(newNotes) - } else { - // Refresh Everything - invalidateData() + var collectorJob: Job? = null + + init { + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } + } } } - init { - LocalCache.live.observeForever(cacheListener) - } - override fun onCleared() { - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt index ab8e591a22..f6811611bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt @@ -60,7 +60,10 @@ fun LnZapFeedView( } } is LnZapFeedState.Loaded -> { - refreshing = false + if (refreshing) { + refreshing = false + } + LnZapFeedLoaded(state, accountViewModel, navController) } is LnZapFeedState.Loading -> { 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 8c6806de81..f89aaaeb65 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 @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BundledUpdate @@ -67,16 +68,18 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi bundler.invalidate() } - private val cacheListener: (Set) -> Unit = { newNotes -> - invalidateData() - } + var collectorJob: Job? = null init { - LocalCache.live.observeForever(cacheListener) + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + invalidateData() + } + } } override fun onCleared() { - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } 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 0fbcd2eccc..cc1d47a351 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 @@ -53,9 +53,12 @@ import androidx.navigation.NavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.AudioTrackEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PeopleListEvent +import com.vitorpamplona.amethyst.service.model.PinListEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -349,7 +352,13 @@ fun NoteMaster( ) ) { Column() { - if (noteEvent is HighlightEvent) { + if (noteEvent is PeopleListEvent) { + DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController) + } else if (noteEvent is AudioTrackEvent) { + AudioTrackHeader(noteEvent, note, account.userProfile(), navController) + } else if (noteEvent is PinListEvent) { + PinListHeader(noteState, MaterialTheme.colors.background, accountViewModel, navController) + } else if (noteEvent is HighlightEvent) { DisplayHighlight( noteEvent.quote(), noteEvent.author(), 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 aae7fd3581..3b0173753f 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 @@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache -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 @@ -72,16 +72,18 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { bundler.invalidate() } - private val cacheListener: (Set) -> Unit = { newNotes -> - invalidateData() - } + var collectorJob: Job? = null init { - LocalCache.live.observeForever(cacheListener) + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + invalidateData() + } + } } override fun onCleared() { - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } 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 63e0e4d928..1af7f02c39 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 @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.widget.Toast import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -253,7 +254,11 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont val context = LocalContext.current.applicationContext - Column() { + Column( + Modifier.clickable { + navController.navigate("Channel/${baseChannel.idHex}") + } + ) { Column(modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { RobohashAsyncImageProxy( @@ -344,7 +349,7 @@ private fun NoteCopyButton( expanded = popupExpanded, onDismissRequest = { popupExpanded = false } ) { - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); popupExpanded = false }) { + DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:" + note.idNote())); popupExpanded = false }) { Text(stringResource(R.string.copy_channel_id_note_to_the_clipboard)) } } @@ -386,7 +391,6 @@ private fun JoinButton(account: Account, channel: Channel, navController: NavCon modifier = Modifier.padding(horizontal = 3.dp), onClick = { account.joinChannel(channel.idHex) - navController.navigate(Route.Message.route) }, shape = RoundedCornerShape(20.dp), colors = ButtonDefaults diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt new file mode 100644 index 0000000000..c7b3a1ebb3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt @@ -0,0 +1,152 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.rememberCoroutineScope +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.SpanStyle +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.halilibo.richtext.markdown.Markdown +import com.halilibo.richtext.ui.material.MaterialRichText +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.components.richTextDefaults +import kotlinx.coroutines.launch + +@Composable +fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: MutableState) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false) + ) { + Surface() { + Column( + modifier = Modifier.padding(10.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + CloseButton(onCancel = { + onClose() + }) + + val toastMessage = stringResource(R.string.invalid_port_number) + + UseOrbotButton( + onPost = { + try { + Integer.parseInt(portNumber.value) + } catch (_: Exception) { + scope.launch { + Toast.makeText( + context, + toastMessage, + Toast.LENGTH_LONG + ).show() + } + return@UseOrbotButton + } + + onPost() + }, + isActive = true + ) + } + + Column( + modifier = Modifier.padding(30.dp) + ) { + val myMarkDownStyle = richTextDefaults.copy( + stringStyle = richTextDefaults.stringStyle?.copy( + linkStyle = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colors.primary + ) + ) + ) + + Row() { + MaterialRichText( + style = myMarkDownStyle + ) { + Markdown( + content = stringResource(R.string.connect_through_your_orbot_setup_markdown) + ) + } + } + + Spacer(modifier = Modifier.height(15.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + OutlinedTextField( + value = portNumber.value, + onValueChange = { portNumber.value = it }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number + ), + label = { Text(text = stringResource(R.string.orbot_socks_port)) }, + placeholder = { + Text( + text = "9050", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + ) + } + } + } + } + } +} + +@Composable +fun UseOrbotButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) { + 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.use_orbot), color = Color.White) + } +} 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 94c9604935..e230e9d42a 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 @@ -20,11 +20,14 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController -import com.vitorpamplona.amethyst.buttons.NewChannelButton +import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton import com.vitorpamplona.amethyst.ui.navigation.* import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet @@ -50,6 +53,12 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun skipHalfExpanded = true ) + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = remember(accountState) { accountState?.account } + + val followLists: FollowListViewModel = viewModel() + followLists.load(account) + ModalBottomSheetLayout( sheetState = sheetState, sheetContent = { @@ -64,7 +73,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun AppBottomBar(navController, accountViewModel) }, topBar = { - AppTopBar(navController, scaffoldState, accountViewModel) + AppTopBar(followLists, navController, scaffoldState, accountViewModel) }, drawerContent = { DrawerContent(navController, scaffoldState, sheetState, accountViewModel) @@ -101,7 +110,7 @@ fun FloatingButtons(navController: NavHostController, accountViewModel: AccountV NewNoteButton(state.account, accountViewModel, navController) } if (currentRoute(navController) == Route.Message.base) { - NewChannelButton(state.account) + ChannelFabColumn(state.account, navController) } if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) { NewImageButton(accountViewModel, navController) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 9046a15694..79843b1215 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -1,29 +1,65 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis +import com.patrykandpatrick.vico.compose.axis.vertical.endAxis +import com.patrykandpatrick.vico.compose.axis.vertical.startAxis +import com.patrykandpatrick.vico.compose.chart.Chart +import com.patrykandpatrick.vico.compose.chart.line.lineChart +import com.patrykandpatrick.vico.compose.component.shape.shader.fromBrush +import com.patrykandpatrick.vico.compose.style.ProvideChartStyle +import com.patrykandpatrick.vico.core.DefaultAlpha +import com.patrykandpatrick.vico.core.axis.AxisPosition +import com.patrykandpatrick.vico.core.axis.formatter.AxisValueFormatter +import com.patrykandpatrick.vico.core.chart.composed.plus +import com.patrykandpatrick.vico.core.chart.line.LineChart +import com.patrykandpatrick.vico.core.chart.values.ChartValues +import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders +import com.patrykandpatrick.vico.core.entry.composed.plus import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.OneGiga +import com.vitorpamplona.amethyst.ui.note.OneKilo +import com.vitorpamplona.amethyst.ui.note.OneMega +import com.vitorpamplona.amethyst.ui.note.UserReactionsRow +import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel +import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.screen.CardFeedView import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.roundToInt @Composable fun NotificationScreen( notifFeedViewModel: NotificationViewModel, + userReactionsStatsModel: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController, scrollToTop: Boolean = false @@ -39,7 +75,7 @@ fun NotificationScreen( NostrAccountDataSource.resetFilters() NotificationFeedFilter.account = account notifFeedViewModel.clear() - notifFeedViewModel.refresh() + notifFeedViewModel.invalidateData() } val lifeCycleOwner = LocalLifecycleOwner.current @@ -61,6 +97,7 @@ fun NotificationScreen( Column( modifier = Modifier.padding(vertical = 0.dp) ) { + SummaryBar(userReactionsStatsModel, accountViewModel, navController) CardFeedView( viewModel = notifFeedViewModel, accountViewModel = accountViewModel, @@ -72,3 +109,121 @@ fun NotificationScreen( } } } + +@Composable +fun SummaryBar(model: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController) { + var showChart by remember { + mutableStateOf(false) + } + + UserReactionsRow(model, accountViewModel, navController) { + showChart = !showChart + } + + val lineChartCount = + lineChart( + lines = listOf(Color.Cyan, Color.Green, Color.Red).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END) + ) + ) + ) + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.Start + ) + + val lineChartZaps = + lineChart( + lines = listOf(BitcoinOrange).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END) + ) + ) + ) + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.End + ) + + model.chartModel?.let { + if (showChart) { + Row( + modifier = Modifier + .padding(vertical = 10.dp, horizontal = 20.dp) + .clickable(onClick = { showChart = !showChart }) + ) { + ProvideChartStyle() { + Chart( + chart = remember(lineChartCount, lineChartZaps) { + lineChartCount.plus(lineChartZaps) + }, + model = it, + startAxis = startAxis( + valueFormatter = CountAxisValueFormatter() + ), + endAxis = endAxis( + valueFormatter = AmountAxisValueFormatter() + ), + bottomAxis = bottomAxis( + valueFormatter = LabelValueFormatter(model.axisLabels) + ) + ) + } + } + } + } + + Divider( + thickness = 0.25.dp + ) +} + +class LabelValueFormatter(val axisLabels: List) : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return axisLabels[value.roundToInt()] + } +} + +class CountAxisValueFormatter() : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return showCount(value.roundToInt()) + } +} + +class AmountAxisValueFormatter() : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return showAmountAxis(value.toBigDecimal()) + } +} + +fun showAmountAxis(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= OneGiga -> "%.0fG".format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> "%.0fM".format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneKilo -> "%.0fk".format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> "%.0f".format(amount) + } +} 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 cd6379cca1..485892a660 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,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast @@ -18,7 +19,6 @@ import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Share import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.ResizeImage @@ -95,6 +96,7 @@ import com.vitorpamplona.amethyst.ui.screen.RelayFeedView import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel import com.vitorpamplona.amethyst.ui.screen.UserFeedView import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -125,7 +127,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro @Composable fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return UserProfileNewThreadFeedFilter.loadUserProfile(account, user) UserProfileConversationsFeedFilter.loadUserProfile(account, user) @@ -139,6 +141,10 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: val lifeCycleOwner = LocalLifecycleOwner.current + LaunchedEffect(Unit) { + NostrUserProfileDataSource.start() + } + DisposableEffect(accountViewModel) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { @@ -214,64 +220,14 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: } ) { val tabs = listOf<@Composable() (() -> Unit)?>( - { - Text(text = stringResource(R.string.notes)) - }, - { - Text(text = stringResource(R.string.replies)) - }, - { - val userState by baseUser.live().follows.observeAsState() - val userFollows = userState?.user?.transientFollowCount() ?: "--" - - Text(text = "$userFollows ${stringResource(R.string.follows)}") - }, - { - val userState by baseUser.live().follows.observeAsState() - val userFollowers = userState?.user?.transientFollowerCount() ?: "--" - - Text(text = "$userFollowers ${stringResource(id = R.string.followers)}") - }, - { - val userState by baseUser.live().zaps.observeAsState() - val userZaps = userState?.user - - var zapAmount by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = userState) { - withContext(Dispatchers.IO) { - val tempAmount = userZaps?.zappedAmount() - withContext(Dispatchers.Main) { - zapAmount = tempAmount - } - } - } - - Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}") - }, - { - val userState by baseUser.live().bookmarks.observeAsState() - val bookmarkList = userState?.user?.latestBookmarkList - val userBookmarks = - (bookmarkList?.taggedEvents()?.count() ?: 0) + (bookmarkList?.taggedAddresses()?.count() ?: 0) - - Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}") - }, - { - val userState by baseUser.live().reports.observeAsState() - val userReports = userState?.user?.reports?.values?.flatten()?.count() - - Text(text = "$userReports ${stringResource(R.string.reports)}") - }, - { - val userState by baseUser.live().relays.observeAsState() - val userRelaysBeingUsed = userState?.user?.relaysBeingUsed?.size ?: "--" - - val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() - val userRelays = userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" - - Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}") - } + { Text(text = stringResource(R.string.notes)) }, + { Text(text = stringResource(R.string.replies)) }, + { FollowTabHeader(baseUser) }, + { FollowersTabHeader(baseUser) }, + { ZapTabHeader(baseUser) }, + { BookmarkTabHeader(baseUser) }, + { ReportsTabHeader(baseUser) }, + { RelaysTabHeader(baseUser) } ) tabs.forEachIndexed { index, function -> @@ -306,6 +262,72 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: } } +@Composable +private fun RelaysTabHeader(baseUser: User) { + val userState by baseUser.live().relays.observeAsState() + val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" } + + val userStateRelayInfo by baseUser.live().relayInfo.observeAsState() + val userRelays = remember(userStateRelayInfo) { userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" } + + Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}") +} + +@Composable +private fun ReportsTabHeader(baseUser: User) { + val userState by baseUser.live().reports.observeAsState() + val userReports = remember(userState) { userState?.user?.reports?.values?.flatten()?.count() } + + Text(text = "$userReports ${stringResource(R.string.reports)}") +} + +@Composable +private fun BookmarkTabHeader(baseUser: User) { + val userState by baseUser.live().bookmarks.observeAsState() + val userBookmarks = remember(userState) { + val bookmarkList = userState?.user?.latestBookmarkList + (bookmarkList?.taggedEvents()?.count() ?: 0) + ( + bookmarkList?.taggedAddresses()?.count() + ?: 0 + ) + } + + Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}") +} + +@Composable +private fun ZapTabHeader(baseUser: User) { + val userState by baseUser.live().zaps.observeAsState() + var zapAmount by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = userState) { + withContext(Dispatchers.IO) { + val tempAmount = baseUser.zappedAmount() + withContext(Dispatchers.Main) { + zapAmount = tempAmount + } + } + } + + Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}") +} + +@Composable +private fun FollowersTabHeader(baseUser: User) { + val userState by baseUser.live().follows.observeAsState() + val userFollowers = remember(userState) { userState?.user?.transientFollowerCount() ?: "--" } + + Text(text = "$userFollowers ${stringResource(id = R.string.followers)}") +} + +@Composable +private fun FollowTabHeader(baseUser: User) { + val userState by baseUser.live().follows.observeAsState() + val userFollows = remember(userState) { userState?.user?.transientFollowCount() ?: "--" } + + Text(text = "$userFollows ${stringResource(R.string.follows)}") +} + @Composable private fun ProfileHeader( baseUser: User, @@ -316,9 +338,6 @@ private fun ProfileHeader( var popupExpanded by remember { mutableStateOf(false) } var zoomImageDialogOpen by remember { mutableStateOf(false) } - val accountUserState by account.userProfile().live().follows.observeAsState() - val accountUser = accountUserState?.user ?: return - val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current @@ -398,23 +417,7 @@ private fun ProfileHeader( // No need for this button anymore // NPubCopyButton(baseUser) - if (accountUser == baseUser) { - EditButton(account) - } - - if (account.isHidden(baseUser)) { - ShowUserButton { - account.showUser(baseUser.pubkeyHex) - } - } else if (accountUser.isFollowingCached(baseUser)) { - UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } } - } else { - if (baseUser.isFollowingCached(accountUser)) { - FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, R.string.follow_back) - } else { - FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, R.string.follow) - } - } + ProfileActions(baseUser, account, coroutineScope) } } @@ -430,37 +433,74 @@ private fun ProfileHeader( } } -@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ProfileActions( + baseUser: User, + account: Account, + coroutineScope: CoroutineScope +) { + val accountUserState by account.userProfile().live().follows.observeAsState() + val accountUser = remember(accountUserState) { accountUserState?.user } ?: return + + if (accountUser == baseUser) { + EditButton(account) + } + + if (account.isHidden(baseUser)) { + ShowUserButton { + account.showUser(baseUser.pubkeyHex) + } + } else if (accountUser.isFollowingCached(baseUser)) { + UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } } + } else { + if (baseUser.isFollowingCached(accountUser)) { + FollowButton( + { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, + R.string.follow_back + ) + } else { + FollowButton( + { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, + R.string.follow + ) + } + } +} + @Composable private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) { val userState by baseUser.live().metadata.observeAsState() - val user = userState?.user ?: return - - val userBadgeState by baseUser.live().badges.observeAsState() - val userBadge = userBadgeState?.user ?: return + val user = remember(userState) { userState?.user } ?: return + val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current val context = LocalContext.current val scope = rememberCoroutineScope() - Row(verticalAlignment = Alignment.Bottom) { - user.bestDisplayName()?.let { - Text( - it, - modifier = Modifier.padding(top = 7.dp), + (user.bestDisplayName() ?: user.bestUsername())?.let { + Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { + CreateTextWithEmoji( + text = it, + tags = tags, fontWeight = FontWeight.Bold, fontSize = 25.sp ) } } - Row(verticalAlignment = Alignment.CenterVertically) { + + if (user.bestDisplayName() != null) { user.bestUsername()?.let { - Text( - "@$it", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 1.dp, bottom = 1.dp) - ) + ) { + CreateTextWithEmoji( + text = "@$it", + tags = tags, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } } } @@ -513,23 +553,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode } } - userBadge.acceptedBadges?.let { note -> - (note.event as? BadgeProfilesEvent)?.let { event -> - FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { - event.badgeAwardEvents().forEach { badgeAwardEvent -> - val baseNote = LocalCache.notes[badgeAwardEvent] - if (baseNote != null) { - val badgeAwardState by baseNote.live().metadata.observeAsState() - val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull() - - if (baseBadgeDefinition != null) { - BadgeThumb(baseBadgeDefinition, navController, 35.dp) - } - } - } - } - } - } + DisplayBadges(baseUser, navController) DisplayNip05ProfileStatus(user) @@ -552,9 +576,58 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode } } - var zapExpanded by remember { mutableStateOf(false) } + val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } + val pubkeyHex = remember { baseUser.pubkeyHex } + DisplayLNAddress(lud16, pubkeyHex, account, scope, context) - val lud16 = user.info?.lud16?.trim() ?: user.info?.lud06?.trim() + val identities = user.info?.latestMetadata?.identityClaims() + if (!identities.isNullOrEmpty()) { + identities.forEach { identity: IdentityClaim -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = Color.Unspecified, + painter = painterResource(id = identity.toIcon()), + contentDescription = stringResource(identity.toDescriptor()), + modifier = Modifier.size(16.dp) + ) + + ClickableText( + text = AnnotatedString(identity.identity), + onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), + modifier = Modifier + .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) + .weight(1f) + ) + } + } + } + + user.info?.about?.let { + Row( + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) + ) { + TranslatableRichTextViewer( + content = it, + canPreview = false, + tags = null, + backgroundColor = MaterialTheme.colors.background, + accountViewModel = accountViewModel, + navController = navController + ) + } + } +} + +@Composable +private fun DisplayLNAddress( + lud16: String?, + userHex: String, + account: Account, + scope: CoroutineScope, + context: Context +) { + var zapExpanded by remember { mutableStateOf(false) } if (!lud16.isNullOrEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -576,10 +649,13 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode } if (zapExpanded) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 5.dp) + ) { InvoiceRequest( lud16, - baseUser.pubkeyHex, + userHex, account, onSuccess = { // pay directly @@ -619,44 +695,34 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode } } } +} - val identities = user.info?.latestMetadata?.identityClaims() - if (!identities.isNullOrEmpty()) { - identities.forEach { identity: IdentityClaim -> - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - tint = Color.Unspecified, - painter = painterResource(id = identity.toIcon()), - contentDescription = stringResource(identity.toDescriptor()), - modifier = Modifier.size(16.dp) - ) +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun DisplayBadges( + baseUser: User, + navController: NavController +) { + val userBadgeState by baseUser.live().badges.observeAsState() + val userBadge = remember(userBadgeState) { userBadgeState?.user } ?: return - ClickableText( - text = AnnotatedString(identity.identity), - onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), - modifier = Modifier - .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - .weight(1f) - ) + userBadge.acceptedBadges?.let { note -> + (note.event as? BadgeProfilesEvent)?.let { event -> + FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { + event.badgeAwardEvents().forEach { badgeAwardEvent -> + val baseNote = LocalCache.notes[badgeAwardEvent] + if (baseNote != null) { + val badgeAwardState by baseNote.live().metadata.observeAsState() + val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull() + + if (baseBadgeDefinition != null) { + BadgeThumb(baseBadgeDefinition, navController, 35.dp) + } + } + } } } } - - user.info?.about?.let { - Row( - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) - ) { - TranslatableRichTextViewer( - content = it, - canPreview = false, - tags = null, - backgroundColor = MaterialTheme.colors.background, - accountViewModel = accountViewModel, - navController = navController - ) - } - } } @Composable @@ -727,9 +793,8 @@ fun BadgeThumb( @Composable private fun DrawBanner(baseUser: User) { val userState by baseUser.live().metadata.observeAsState() - val user = userState?.user ?: return + val banner = remember(userState) { userState?.user?.info?.banner } - val banner = user.info?.banner val clipboardManager = LocalClipboardManager.current var zoomImageDialogOpen by remember { mutableStateOf(false) } @@ -767,61 +832,51 @@ private fun DrawBanner(baseUser: User) { @Composable fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavController) { - val accountState by accountViewModel.accountLiveData.observeAsState() - if (accountState != null) { - val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel() + val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel() - LaunchedEffect(Unit) { - feedViewModel.invalidateData() - } + LaunchedEffect(Unit) { + feedViewModel.invalidateData() + } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) - } + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) } } } @Composable fun TabNotesConversations(accountViewModel: AccountViewModel, navController: NavController) { - val accountState by accountViewModel.accountLiveData.observeAsState() - if (accountState != null) { - val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel() + val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel() - LaunchedEffect(Unit) { - feedViewModel.invalidateData() - } + LaunchedEffect(Unit) { + feedViewModel.invalidateData() + } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) - } + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) } } } @Composable fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val userState by baseUser.live().bookmarks.observeAsState() - if (accountState != null) { - val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel() + val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel() - LaunchedEffect(userState) { - feedViewModel.invalidateData() - } + LaunchedEffect(Unit) { + feedViewModel.invalidateData() + } - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) - } + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false) } } } @@ -937,41 +992,6 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) { } } -@Composable -private fun NPubCopyButton( - user: User -) { - val clipboardManager = LocalClipboardManager.current - var popupExpanded by remember { mutableStateOf(false) } - - Button( - modifier = Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { popupExpanded = true }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.Share, - contentDescription = stringResource(R.string.copies_the_public_key_to_the_clipboard_for_sharing) - ) - - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = { popupExpanded = false } - ) { - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); popupExpanded = false }) { - Text(stringResource(R.string.copy_public_key_npub_to_the_clipboard)) - } - } - } -} - @Composable private fun MessageButton(user: User, navController: NavController) { Button( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 3a69e0a2d7..320e913c0e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -131,6 +131,8 @@ fun SearchScreen( } class SearchBarViewModel : ViewModel() { + var account: Account? = null + var searchValue by mutableStateOf("") val searchResults = mutableStateOf>(emptyList()) val searchResultsNotes = mutableStateOf>(emptyList()) @@ -156,8 +158,8 @@ class SearchBarViewModel : ViewModel() { } hashtagResults.value = findHashtags(searchValue) - searchResults.value = LocalCache.findUsersStartingWith(searchValue) - searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }.reversed() + searchResults.value = LocalCache.findUsersStartingWith(searchValue).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed() + searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue) } @@ -185,25 +187,24 @@ class SearchBarViewModel : ViewModel() { @Composable private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) { val searchBarViewModel: SearchBarViewModel = viewModel() + searchBarViewModel.account = accountViewModel.accountLiveData.value?.account val scope = rememberCoroutineScope() val listState = rememberLazyListState() val onlineSearch = NostrSearchEventOrUserDataSource - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return - // Create a channel for processing search queries. val searchTextChanges = remember { CoroutineChannel(CoroutineChannel.CONFLATED) } - LaunchedEffect(db) { - withContext(Dispatchers.IO) { - if (searchBarViewModel.isSearching()) { - println("Search Active") - searchBarViewModel.invalidateData() + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + if (searchBarViewModel.isSearching()) { + searchBarViewModel.invalidateData() + } } } } @@ -337,7 +338,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont } } -val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)") +val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)") fun findHashtags(content: String): List { val matcher = hashtagSearch.matcher(content) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 4ae9fef50e..7dc3d86dc2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -275,7 +275,7 @@ private fun RenderVideoOrPictureNote( Icon( imageVector = Icons.Default.MoreVert, null, - modifier = Modifier.size(15.dp), + modifier = Modifier.size(20.dp), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) 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 ec45c3aff3..450b147771 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedOff +import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -38,6 +39,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog import java.util.* @OptIn(ExperimentalComposeUiApi::class) @@ -55,6 +57,9 @@ fun LoginPage( var dialogOpen by remember { mutableStateOf(false) } + val useProxy = remember { mutableStateOf(false) } + val proxyPort = remember { mutableStateOf("9050") } + var connectOrbotDialogOpen by remember { mutableStateOf(false) } Column( modifier = Modifier @@ -158,7 +163,7 @@ fun LoginPage( keyboardActions = KeyboardActions( onGo = { try { - accountViewModel.startUI(key.value.text) + accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt()) } catch (e: Exception) { errorMessage = context.getString(R.string.invalid_key) } @@ -221,6 +226,32 @@ fun LoginPage( } } + if (isPackageInstalled(context, "org.torproject.android")) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } + } + ) + + Text(stringResource(R.string.connect_via_tor)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + proxyPort + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { @@ -237,7 +268,7 @@ fun LoginPage( if (acceptedTerms.value && key.value.text.isNotBlank()) { try { - accountViewModel.startUI(key.value.text) + accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt()) } catch (e: Exception) { errorMessage = context.getString(R.string.invalid_key) } @@ -265,7 +296,7 @@ fun LoginPage( .fillMaxWidth(), onClick = { if (acceptedTerms.value) { - accountViewModel.newKey() + accountViewModel.newKey(useProxy.value, proxyPort.value.toInt()) } else { termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) @@ -280,3 +311,7 @@ fun LoginPage( ) } } + +fun isPackageInstalled(context: Context, target: String): Boolean { + return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null +} diff --git a/app/src/main/res/drawable/ic_tor.xml b/app/src/main/res/drawable/ic_tor.xml new file mode 100644 index 0000000000..bafee0c19c --- /dev/null +++ b/app/src/main/res/drawable/ic_tor.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000000..3dce68a9dc --- /dev/null +++ b/app/src/main/res/values-fa/strings.xml @@ -0,0 +1,357 @@ + + Amethyst + Amethyst Debug + به QRکد اشاره کنید + QR کد را نشان بده + تصویر پروفایل + اسکن QRکد + بهر حال نشان بده + این نوت توسط این افراد نامناسب اعلام شده + این نوت یافت نشد + تصویر کانال + رویداد مربوطه یافت نشد + قادر به رمزگشایی پیام نیست + تصویر گروه + محتوای نامناسب + اسپم + جعل هویت + رفتار ناهنجار + ناشناس + آیکن رله + نویسنده ناشناس + کپی متن + کپی شناسه نویسنده + کپی شناسه نوت + انتشار + درخواست حذف/string> + بلاک/پنهان کردن کاربر + گزارش اسپم/کلاهبرداری + گزارش جعل هویت + گزارش محتوای نامناسب + گزارش رفتار ناهنجار + با کلید خصوصی وارد شوید تا بتوانید پاسخ دهید + با کلید خصوصی وارد شوید تا بتوانید نوت را بازارسال کنید + با کلید خصوصی وارد شوید تا بتوانید نوت را لایک کنید + مقداری برای زپ تعیین نشده. نگه دارید تا تغییر دهید + با کلید خصوصی وارد شوید تا بتوانید زپ کنید + زپ + دیدن تعداد + بازارسال + بازارسال شده + نقل قول + مبلغ جدید به ساتوشی + افزودن + " پاسخ به" + " و " + "در کانال " + بنر پروفایل + "دنبال شونده" + "دنبال کننده" + پروفایل + فیلترهای ایمنی + خروج + ادامه + اینویس لایتنینگ + پرداخت + انعام لایتنینگ + یادداشت برای گیرنده + !خیلی ممنون + مبلغ به ساتوشی + ارسال ساتوشی + " %1$s : %2$s خطا در تبدیل" + "%1$s پیش نمایش تصویر کارت برای" + کانال جدید + نام کانال + گروه باحال من + آدرس تصویر + توصیف + "درباره ما.. " + به چی فکر میکنی؟ + ارسال + ذخیره + ساختن + لغو + تصویر بارگذاری نشد + آدرس رله + نوت ها + بایت + خطاها + خبرنامه اصلی + پیام خصوصی + گفتگوی عمومی + خبرنامه همگانی + جستجو در خبرنامه + افزودن رله + نمایش نام + نام من + نام کاربری + نام کاربری من + درباره من + آدرس آواتار + آدرس بنر + آدری وبسایت + آدرس لایتنینگ + آدرس لایتنینگ(نقل شده) + تصویر در گالری ذخیره شد + تصویر ذخیره نشد + بارگذاری تصویر + ...در حال بارگذاری + این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است + "...اینجا پاسخ دهید " + شناسه نوت را برای اشتراک گذاری در کلیپبورد ذخیره می کند + ذخیره شناسه کانال در کلیپبورد + متادیتای کانال را ذخیره می کند + پیوستن + شناس + در خواست جدید + کاربران بلاک شده + رشته جدید + گفتگوها + نوت ها + پاسخ ها + "دنبال شوندگان" + "گزارش ها" + انتخاب های بیشتر + "رله ها" + وبسایت + آدرس لایتنینگ + شناسه کلید خصوصی (پسوردتان) را در کلیپبورد کپی می کند( + کپی کردن کلید خصوصی در کلیپبورد + کلید عمومی را برای اشتراک گذاری در کلیپبورد ذخیره می کند + کپی کردن کلید عمومی در کلیپبورد + ارسال پیام مستقیم + متادیتای کاربر را ویرایش می کند + دنبال کردن + دنبال کردن متقابل + از بلاک درآوردن + کپی شناسه کاربر + از بلاک درآوردن کاربر + "npub, hex, نام کاربری " + پاک کردن + لوگوی اپ + nsec / npub / hex کلید خصوصی + نشان دادن پسورد + پنهان کردن پسورد + کلید نامعتبر + "می پذیرم " + شرایط استفاده + پذیرش شرایط الازمیست + کلید الزامیست + ورود + ساختن جفت کلید جدید + در حال بارگذاری خبرنامه + " :خطا در بارگذاری پاسخ ها" + دوباره تلاش کنید + خبرنامه خالیست + تازه سازی + ساخته شد + با توصیف + و تصویر + نام گفتگو تغییر کرد به + تصویر + و تصویر به + ترک کردن + دنبال نکردن + کانال ساخته شد + "اطلاعات کانال تغییر کرد به" + گفتگوی عمومی + نوت های دریافت شده + بیرون کردن + ساتوشی + خودکار + ترجمه از + به + ابتدا به نشان بده %1$s + همیشه به ترجمه کن %1$s + هرگز به ترجمه نکن %1$s + NIP-05 + LNURL... + هرگز + اکنون + h + m + d + برهنگی + ناسزا / نفرت پراکنی + گزارش نفرت پراکنی + گزارش برهنگی / پورن + سایر + همه شناس ها خوانده شده کن + همه جدیدها را خوانده شده کن + همه را خوانده شده کن + بازیابی کلیدها + + ## راهنمای ایمنی و بازیابی کلید + \n\n- حساب کاربری شما با یک کلید خصوصی ایمن می شود. این کلید یک رشته تصادفی اعداد و ارقام است که با **nsec1** آغاز می گردد. + \n\n- هر کس به کلید خصوصی شما دست یابد می تواند با هویت شما اقدام به انتشار محتوا نماید. کلید خصوصی خود را در هیچ نرم افزار یا وب سایتی که به آن اعتماد ندارید قرار ندهید. + \n- سازندگان آماتیست **هرگز** از شما کلید خصوصی تان را نمی خواهند. + \n- حتما یک کپی از کلید خصوصی را برای بازیابی حساب کاربری نزد خود نگه دارد. ما استفاده از یک نرم افزار مدیریت پسورد را توصیه می نماییم. + + کلید خصوصی در کلیپبورد کپی شد + کلید خصوصیم را کپی کن + صحت سنجی انجام نشد + خطا + "ساخته شده توسط %1$s" + "تصویر مدال پاداش برای %1$s" + یک مدال پاداش دریافت کردید + مدال پاداش داده شد به + نوت در کلیپبورد کپی شد + @npubنویسنده در کلیپبورد کپی شد + (@note1)شناسه نوت در کلیپبورد کپی شد + انتخاب متن + Github Gist w/ Proof + Telegram + Mastodon Post ID w/ Proof + Twitter Status w/ Proof + https://gist.github.com/<user>/<gist> + https://t.me/<proof post> + https://<server>/<user>/<proof post> + https://twitter.com/<user>/status/<proof post> + "<نمی توان پیام خصوصی را رمزگشایی کردن>\n\nYou در پیام خصوصی/رمزگذاری شده بین و به شما اشاره شده %1$s %2$s." + افزودن حساب کاربری جدید + حساب های کاربری + انتخاب حساب کارگری + افزودن حساب کاربری جدید + حساب کاربری فعال + کلید خصوصی دارد + فقط خواندن، کلید خصوصی ندارد + بازگشت + انتخاب + اشتراک گذاری لینک پیماینده + اشتراک گذاری + شناسه نویسنده + شناسه نوت + کپی متن + حذف + دنبال نکردن + دنبال کردن + Request Deletion + آماتیست درخواست می کند که نوت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که نوت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. + بلاک + حذف + بلاک + گزارش + حذف + دوباره نشان نده + اسپم یا کلاهبرداری + محتوای نفرت پراکنی یا ناسزا + جعل هویت بدخواهانه + محتوای برهنگی یا نمایان + رفتار ناهنجار + بلاک کردن کاربر محتوای ایشان را در اپ شما پنهان می کند. نوت های شما همچنان بطور عمومی دیده می شوند که شامل کاربران بلاک شده توسط شما نیز هست. کاربران بلاک شده در صفحه فیلترهای ایمنی فهرست می شوند. + + گزارش سواستفاده + تمام گزارش ها بطور عمومی دیده می شوند + در صورت تمایل توضیحاتی به گزارش خود بیفزایید... + توضیحات اضافه + دلایل + دلیلی انتخاب کنید... + گزارش نوت + بلاک و گزارش + بلاک + + علامت گذاشته ها + علامت گاشته های خصوصی + علامت گذاشته های عمومی + افزودن به علامت گذاشته های خصوصی + افزودن به علامت گذاشته های عمومی + برداشتن از علامت گذاشته های خصوصی + برداشتن از علامت گذاشته های خصوصی + + خدمات اتصال به کیف پول + اجازه رمز نوستر برای پرداخت زپ بدون خارج شدن از اپ. رمز را ایمن نگه دارید و در صورت امکان از رله خصوصی استفاده کنید. + کلید عمومی اتصال به کیف پول + رله اتصال به کیف پول + رمز اتصال به کیف پول + نمایش کلید خصوصی + nsec / hex کلید خصوصی + + مبلغ وثیقه به ساتوشی + ارسال نظرسنجی + قسمت های الزامی: + دریافت کنندگان زپ + توضیحات اولیه نظرسنجی… + گزینه ها %s + توضیح گزینه های نظرسنجی + قسمت های اختیاری: + حداقل زپ + حداکثر زپ + توافق نظر + (0–100)% + بسته شدن پس از + روز + نظر سنجی برای رای جدید بسته است + مبلغ زپ + فقط یک رای به ازای هر کاربر در این نوع نظرسنجی مجاز است + + "جستجوی رویداد %1$s" + + افزودن پیام عمومی + افزودن پیام خصوصی + افزودن پیام اینویس + + از زحمات شما متشکریم! + + ساختن و افزودن + سازندگان نظرسنجی نمی توانند در نظر سنجی خود رای دهند. + #زپ_نظرسنجی + + + تصویر از زمان ارسال یکسان است + تصویر تغییر کرده است. ممکن است نویسنده تغییر را ندیده باشد. + + افزودن تصویر + افزودن فیلم + افزودن مدارک + + افزودن به پیام + توصیف محتوا + قایقی آبی در ساحل شنی سفید هنگام غروب + + نوع زپ + نوع زپ برای همه گزینه ها + + عمومی + همه می توانند تراکنش و پیام را ببینند + + خصوصی + فرستنده و گیرنده می توانند یکدیگر و پیام را ببینند + + ناشناس + گیرنده و دیگران نمی دانند چه کسی پرداخت کرده است + + غیرزپ + هیچ ردی در نوستر نمی ماند، فقط در شبکه لایتنینگ انجام می شود + + + سرور فایل + آدرس لایتنینگ یا @User + + imgur.com - معتمد + Imgur میتواند فایل را بهینه کند + + nostrimg.com - معتمد + NostrImg میتواند فایل را بهینه کند + + nostr.build - معتمد + Nostr.build can modify the file + + قابل بررسی Imgur (NIP-94) + بررسی کنید که Imgur فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند. + + قابل بررسی NostrImg (NIP-94) + بررسی کنید که NostrImg فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند. + + Verifiable Nostr.build (NIP-94) + بررسی کنید که Nostr.build فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند. + + رله های شما (NIP-95) + فایل ها در رله های شما میزبانی می شوند. NIPجدید: بررسی کنید آیا پشتیبانی می کنند یا خیر + + + + لیست دنبال ها + همه دنبال ها + همگانی + + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 64568685b5..86d2c42c4b 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -284,6 +284,8 @@ "%1$s esemény keresése" Nyilvános üzenet hozzáadása + Adj hozzá egy privát üzenetet + A számlához adj hozzá egy üzenetet Köszönöm a kemény munkát! Létrehoz és Hozzáad @@ -333,11 +335,54 @@ Saját csomópontjaid (NIP-95) A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel - - - + + Tor/Orbot beállítása + Csatlakozás a saját Orbot beállításod alapján + + Lecsatlakozás a saját Orbot/Tor hálózatról? + Az adataid azonnal a hagyományos hálózaton lesz továbbítva + Igen + Nem + + Követek Lista Mindenki akit követek Globális + + ## Kapcsolódj a TOR-on keresztül az Orbot segítségével + \n\n1. Telepítsd az [Orbot-ot](https://play.google.com/store/apps/details?id=org.torproject.android) + \n2. Indítsd el az Orbot-ot + \n3. Az Orbot-ban ellenőrizd a Socks portokat. Az alapbeállítás 9050 + \n4. Ha szükséges az Orbot-ban változtasd meg a portot + \n5. Ezen a felületen állítsd be a Socks portot + \n6. Kattíts az Aktiválás gombra, hogy az Orbot-ot átjátszóként használd + + Orbot Socks portja + Érvénytelen Port szám + Használd az Orbot-ot + TOR/Orbot Lekapcsolása + DefaultChannelID + New notification arrived + + PrivateMessagesID + Privát üzenetek + Értesítést küld, amikor privát üzeneted érkezik + + ZapsID + Zap-et kaptál + Értesítést küld, amikor valaki neked Zap-et küldött + %1$s sats + %1$s -tól + %1$s -ért + + Értesítés: + + Csatlakozz a beszélgetéshez + Felhasználó vagy Csoport azonosító + npub, nevent vagy hex + Létrehoz + Csatlakozás + + Ma diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e4760f5af6..ef55fe717d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -318,18 +318,35 @@ File server LnAddress of @User - imgur.com - minder beveiligd - Upload naar Imgur. Imgur kan uw afbeelding/video op elk moment wijzigen + imgur.com - vertrouwd + Imgur kan het bestand aanpassen - nostrimg.com - minder beveiligd - Upload naar NostrImg. NostrImg kan uw afbeelding/video op elk moment wijzigen + nostrimg.com - vertrouwd + NostrImg kan het bestand aanpassen + + nostr.build - vertrouwd + Nostr.build kan het bestand aanpassen + + nostrfiles.dev - vertrouwd + Nostrfiles.dev kan het bestand aanpassen Verifieerbare Imgur (NIP-94) - Beschermt tegen het achteraf wijzigen van de inhoud door Imgur. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet. + Checkt of Imgur het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet. Verifieerbare NostrImg (NIP-94) - Beschermt tegen het achteraf wijzigen van de inhoud door NostrImg. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet. + Checkt of NostrImg het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet. + + Verifieerbare Nostr.build (NIP-94) + Checkt of Nostr.build het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet. + + Verifieerbare Nostrfiles.dev (NIP-94) + Checkt of Nostrfiles.dev het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet. Uw relays (NIP-95) Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt. + + Volgerslijst + Alle gevolgden + Globaal + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2db0e85dcc..d662b8a8a4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -149,7 +149,7 @@ descrição para e foto para Sair - Remover seguidor + Desseguir Canal criado Informação do canal mudou para Chat público diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 66cb4e11bf..ea19d2dfa9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -234,4 +234,91 @@ Отправить жалобу "<Не удалось расшифровать сообщение>\n\nВас упомянули в приватном/зашифрованном чате между %1$s и %2$s." Заблокировать и пожаловаться + Байт + Подписаться в ответ + Закладки + Личные закладки + Публичные закладки + Добавить в Личные закладки + Добавить в Публичные закладки + Убрать из Личных закладок + Убрать из Публичных закладок + Служба Wallet Connect + Позволяет оплачивать запы с помощью секрета, не выходя из приложения. Храните секрет в безопасности и по возможности используйте приватный релей + Публичный ключ Wallet Connect + Релей Wallet Connect + Секрет Wallet Connect + Показать секрет + nsec / приватный ключ в hex + Сумма взноса в sat + Отправить опрос + Обязательные поля: + Получатели запов + Описание опроса + Вариант %s + Описание варианта + Опциональные поля: + Минимальный зап + Максимальный зап + Консенсус + (0–100)% + Закрытие после + дней + Опрос закрыт + Сумма запа + В этом опросе можно голосовать только один раз + Поиск события %1$s + Добавить публичное сообщение + Добавить личное сообщение + Добавить примечание к инвойсу + Спасибо всем вам за работу! + Создать и Добавить + Автор опроса не может голосовать + Фото не изменилось после публикации + Фото изменено. Автор мог не заметить изменение + Добавить фото + Добавить видео + Добавить документ + Добавить к сообщению + Описание содержимого + Компания весёлых молодых людей + Тип запа + Тип запа для всех вариантов + Публичный + Все видят платеж и сообщение + Приватный + Отправитель и получатель видят друг друга и могут прочесть сообщение + Анонимный + Никто не видит отправителя платежа + Не-зап + Без следа в Nostr, обычный Lightning платеж + Сервер для загрузки + LnAddress или @User + imgur.com – доверенный + Imgur может изменять файл + nostrimg.com – доверенный + NostrImg может изменять файл + Верифицируемый Imgur (NIP-94) + Проверка на изменение файла Imgur. Новый NIP: может не работать в других клиентах + Верифицируемый NostrImg (NIP-94) + Проверка на изменение файла NostrImg. Новый NIP: может не работать в других клиентах + Ваши релеи (NIP-95) + Файл хранится на ваших релеях. Новый NIP: может не работать в других клиентах + Все подписки + Глобально + nostr.build – доверенный + Nostr.build может изменять файл + nostrfiles.dev – доверенный + Nostrfiles.dev может изменять файл + Верифицируемый Nostr.build (NIP-94) + Проверка на изменение файла Nostr.build. Новый NIP: может не работать в других клиентах + Верифицируемый Nostrfiles.dev (NIP-94) + Проверка на изменение файла nostrfiles.dev. Новый NIP: может не работать в других клиентах + Личные сообщения + Уведомления о входящих сообщениях + Запы + Уведомления о полученных запах + %1$s sat + От %1$s + за %1$s \ No newline at end of file diff --git a/app/src/main/res/values-sv b/app/src/main/res/values-sv new file mode 100644 index 0000000000..abf3d1b94c --- /dev/null +++ b/app/src/main/res/values-sv @@ -0,0 +1,408 @@ + + Amethyst + Amethyst Debug + Point to the QR Code + Visa QR + Profilbild + Scanna QR + Visa ändå + Inlägget flaggades som olämpligt av + Inlägg hittades inte + Kanal bild + Refererad händelse hittades inte + Kunde inte dekryptera meddelandet + Grupp bild + Explicit Innehåll + Spam + Imitation + Olagligt beteende + Okänd + Relä ikon + Okänd användare + Kopiera Text + Kopiera användarens ID + Kopiera anteckningens ID + Sänd ut + Begär radering + + Rapportera Spam / Scam + Rapportera identitetsstöld + Rapportera explicit innehåll + Rapportera olagligt beteende + Logga in med en privat nyckel för att kunna svara + Logga in med en privat nyckel för att kunna boosta inlägg + Logga in med en privat nyckel för att gilla inlägg + Ingen konfiguration av Zap-belopp. Tryck länge för att ändra + Logga in med en privat nyckel för att kunna skicka Zaps + Zaps + Antal visningar + Boost + boosted + Citera + Nytt belopp i Sats + Lägg till + "Svarar till " + " och " + "i kanalen " + Profil Banner + " Följer" + " Följare" + Profil + Säkerhets Filter + Logga ut + Visa mer + Lightning Faktura + Betala + Lightning Dricks + Notering till mottagaren + Tack så mycket! + Summa i Sats + Skicka Sats + "Fel vid analys av förhandsvisning för %1$s : %2$s" + "Förhandsgranska kortbild för %1$s" + Ny kanal + Kanal Namn + Min fantastiska grupp + Bild Url + Beskrivning + "Om oss.. " + Vad tänker du på? + Dela + Spara + Skapa + Avbryt + Det gick inte att ladda upp bilden + Relä Adress + Inlägg + Bytes + Errors + Hem Flöde + Privata Meddelande Flöde + Publik Chat Flöde + Globalt Flöde + Sök i Flödet + Lägg till Relä + Visningsnamn + Mitt visningsnamn + Användarnamn + Mitt användarnamn + Om mig + Avatar URL + Banner URL + Websida URL + LN Adress + LN URL (Utdaterad) + Bild sparad till galleriet + Misslyckades att spara bilden + Ladda upp bilden + Laddar upp… + Användaren har inte en Lightningadressinställning för att ta emot sats + "svara här.. " + Kopierar antecknings-ID till urklipp för delning + Kopiera kanal-ID (anteckningen) till Urklipp + Redigerar kanalmetadata + Gå med + Känd + Nya förfrågningar + Blockerade användare + Nya trådar + konversationer + Anteckningar + Svar + "Följer" + "Rapporter" + Fler alternativ + " Reläer" + Websida + Lightning Adress + Kopierar Nsec ID (ditt lösenord) till urklipp för säkerhetskopiering + Kopiera hemlig nyckel till urklipp + Kopierar den publika nyckeln till urklipp för delning + Kopiera Public Key (NPub) till Urklipp + Skicka ett direkt meddelande + Redigerar användarens metadata + Följ + Följ tillbaka + Sluta blockera + Kopiera Användarens ID + Sluta blockera Användare + "npub, hex, användarnamn " + Rensa + App Logo + nsec / npub / hex privat nyckel + Visa lösenord + Göm lösenord + Ogiltig nyckel + "Jag accepterar " + villkor + Godkännande av villkor krävs + Nyckel krävs + Inloggning + Skapa en ny nyckel + Ladda flöde + "Det gick inte att läsa in svar: " + Försök igen + Flöde är tomt. + Uppdatera + Skapad + med beskrivning av + och bild + ändra chat namn till + beskrivning till + och bild till + Lämna + Sluta följa + Kanal skapad + "Kanalinformation ändrad till" + Publik Chat + mottagna inlägg + Ta bort + sats + Auto + översatt från + till + Visa i %1$s first + Översätt alltid till %1$s + Översätt aldrig från %1$s + NIP-05 + LNURL... + aldrig + nu + h + m + d + Naket + Svordomar / Hatiskt tal + Anmäl hattal + Rapportera nakenhet/porr + andras + Markera alla kända som lästa + Markera alla nya som lästa + Markera allt som läst + Backup-nycklar + + ## Nyckelsäkerhetskopiering och säkerhetstips + \n\nDitt konto är skyddat av en hemlig nyckel. Nyckeln är en lång slumpmässig sträng som börjar med **nsec1**. Alla som har tillgång till din hemliga nyckel kan publicera innehåll med din identitet. + \n\n- Lägg **inte** in din hemliga nyckel på någon webbplats eller programvara som du inte litar på. + \n- Amethyst-utvecklare kommer **aldrig** att be dig om din hemliga nyckel. + \n- **Behåll en säker säkerhetskopia av din hemliga nyckel för kontoåterställning. Vi rekommenderar att du använder en lösenordshanterare. + Hemlig nyckel (nsec) kopierad till urklipp + Kopiera min hemliga nyckel + Autentisering misslyckades + Error + "Skapad av %1$s" + "Tilldelad Badge för %1$s" + Du har blivit tilldelad en ny Badge + Badge utmärkelse tilldelas + Kopierade anteckningstext till urklipp + Kopierade författarens @npub till urklipp + Kopierade antecknings-ID (@note1) till urklipp + Välj Text + Github Gist med bevis + Telegram + Mastodon antecknings ID med bevis + Twitter Status med bevis + https://gist.github.com/<user>/<gist> + https://t.me/<proof post> + https://<server>/<user>/<proof post> + https://twitter.com/<user>/status/<proof post> + "<Kan inte dekryptera privat meddelande>\n\nDu citerades i en privat/krypterad konversation mellan %1$s och %2$s." + Lägg till nytt konto + Konton + Välj konto + Lägg till nytt konto + Aktivt konto + Har privat nyckel + Läs bara, ingen privat nyckel + Back + Välj + Dela webbläsarlänk + Dela + Användar ID + Antecknings ID + Kopiera Text + Radera + Sluta följa + Följ + Begär radering + Amethyst kommer att begära att din anteckning tas bort från de reläer du för närvarande är ansluten till. Det finns ingen garanti för att din anteckning kommer att raderas permanent från dessa reläer eller från andra reläer där den kan lagras. + Blockera + Radera + Blockera + Rapportera + Radera + Visa inte igen + Spam eller scams + Svordomar eller hatiskt beteende + Skadlig identitetsstöld + Nakenhet eller grafiskt innehåll + Olagligt beteende + Blockering av en användare kommer att dölja deras innehåll i din app. Dina anteckningar är fortfarande offentliga, inklusive för personer du blockerar. Blockerade användare listas på skärmen Säkerhetsfilter. + + Rapportera missbruk + Alla rapporter som publiceras kommer att vara offentliga. + Ge eventuellt ytterligare sammanhang om din rapport... + Ytterligare sammanhang + Anledning + Välj anledning… + Skicka rapport + Blockera och rapportera + Blockera + + Bokmärken + Privata Bokmärken + Publika Bokmärken + Lägg till i Privata Bokmärken + Lägg till i Publika Bokmärken + Ta bort från Privata Bokmärken + Ta bort från Publika Bokmärken + + Wallet Connect Service + Tillåter en Nostr Secret att betala zaps utan att lämna appen. Håll hemligheten säker och använd ett privat relä om möjligt + Wallet Connect Pubkey + Wallet Connect Relay + Wallet Connect Secret + Visa secret key + nsec / hex privat nyckel + + Pantbelopp i Sats + Dela Poll + Obligatoriska fält: + Zap mottagare + Beskrivning av primär omröstning… + Alternativ %s + Beskrivning av omröstningsalternativ + Valfria fält: + Minsta Zap + Maximal Zap + Konsensus + (0–100)% + Avsluta efter + dagar + Omröstningen är stängd för nya röster + Zap belopp + Endast en röst per användare tillåts i denna typ av omröstning + + "Letar efter Event %1$s" + + Lägg till ett offentligt meddelande + Lägg till ett privat meddelande + Lägg till ett fakturameddelande + + Tack för allt ditt arbete! + + Skapa och lägg till + Omröstningsskapare kan inte rösta i sina egna omröstningar. + #zappoll + + Bilden är densamma sedan inlägget + Bilden har ändrats. Användaren kanske inte har sett förändringen + + Lägg till Bild + Lägg till Video + Lägg till Dokument + + Lägg till Meddelande + Beskrivning av innehållet + En blå båt på en vit sandstrand vid solnedgången + + Zap Typ + Zap Typ för alla alternativ + + Publik + Alla kan se transaktionen och meddelandet + + Privat + Avsändare och mottagare kan se varandra och läsa meddelandet + + Anonym + Mottagaren och allmänheten vet inte vem som skickade betalningen + + Ingen Zap + Inga spår i Nostr, bara i Lightning + + + Fil Server + LnAdress or @Användare + + imgur.com - betrodd + Imgur kan ändra filen + + nostrimg.com - betrodd + NostrImg kan ändra filen + + nostr.build - betrodd + Nostr.build kan ändra filen + + nostrfiles.dev - betrodd + Nostrfiles.dev kan ändra filen + + + Verifierbar Imgur (NIP-94) + Kontrollerar om Imgur modifierat filen. Nytt NIP: andra klienter kanske inte ser det + + Verifierbar NostrImg (NIP-94) + Kontrollerar om NostrImg modifierat filen. Nytt NIP: andra klienter kanske inte ser det + + Verifierbar Nostr.build (NIP-94) + Kontrollerar om Nostr.build modifierat filen. Nytt NIP: andra klienter kanske inte ser det + + Verifierbar Nostrfiles.dev (NIP-94) + Kontrollerar om Nostrfiles.dev modifierat filen. Nytt NIP: andra klienter kanske inte ser det + + + Dina Reläer (NIP-95) + Filer är värd för dina reläer. Nytt NIP: kontrollera om de stöder + + Tor/Orbot Inställningar + Anslut via dina Orbot-inställiningar + + Koppla från Orbot/Tor? + Dina data kommer omedelbart att överföras till det vanliga nätverket + Ja + Nej + + + Följ lista + Alla följare + Global + + ## Anslut genom Tor med Orbot + \n\n1. Installera [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) + \n2. Starta Orbot + \n3. Kontrollera Socks-porten i Orbot. Standarden använder 9050 + \n4. Ändra vid behov porten i Orbot + \n5. Konfigurera Socks-porten på den här skärmen + \n6. Tryck på knappen Aktivera för att använda Orbot som proxy + Orbot Socks Port + Ogiltigt portnummer + Använd Orbot + Koppla ifrån Tor/Orbot + + Standardkanal-ID + Ny notifikation har kommit + + PrivataMeddelandID + Privata Meddelade + Meddelar dig när ett privat meddelande kommer + + ZapsID + Zaps Mottaget + Meddela dig när någon zappar dig + %1$s sats + Från %1$s + till %1$s + + Meddela: + + Gå med i konversation + Användare eller grupp ID + npub, nevent or hex + Skapa + Gå med + + + + + + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 4bb25631b2..e01745d789 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -234,4 +234,91 @@ Відправити скаргу "<Не вдалося розшифрувати повідомлення>\n\n\пВас згадали в приватному/зашифрованому чаті між %1$s і %2$s." Заблокувати та поскаржитися + Байт + Підписатися у відповідь + Закладки + Особисті закладки + Публічні закладки + Додати в Особисті закладки + Додати в Публічні закладки + Прибрати з Особистих закладок + Прибрати з Публічних закладок + Служба Wallet Connect + Дозволяє оплачувати запи за допомогою секрету, не виходячи з програми. Зберігайте секрет у безпеці та за можливості використовуйте приватний релей + Публічний ключ Wallet Connect + Релей Wallet Connect + Секрет Wallet Connect + Показати секрет + nsec / приватний ключ в hex + Сума внеску в sat + Відправити опитування + Обов\'язкові поля: + Одержувачі запів + Опис опитування + Варіант %s + Опис варіанту + Опціональні поля: + Мінімальний зап + Максимальний зап + Консенсус + (0–100)% + Закриття після + днів + Опитування закрито + Сума запу + У цьому опитуванні можна голосувати тільки один раз + Пошук події %1$s + Додати публічне повідомлення + Додати приватне повідомлення + Додати примітку до інвойсу + Дякую всім вам за роботу! + Створити та Додати + Автор опитування не може голосувати + Фото не змінилося після публікації + Фото змінено. Автор міг не помітити зміну + Додати фото + Додати відео + Додати документ + Додати до повідомлення + Опис вмісту + Компанія веселих молодих людей + Тип запу + Тип запу для всіх варіантів + Публічний + Всі бачать платіж і повідомлення + Приватний + Відправник і одержувач бачать один одного і можуть прочитати повідомлення + Анонімний + Ніхто не бачить відправника платежу + Не-зап + Без сліду в Nostr, звичайний Lightning платіж + Сервер для завантаження + LnAddress або @User + imgur.com – довірений + Imgur може змінювати файл + nostrimg.com – довірений + NostrImg може змінювати файл + Верифікований Imgur (NIP-94) + Перевірка на зміну файлу Imgur. Новий NIP: може не працювати в інших клієнтах + Верифікований NostrImg (NIP-94) + Перевірка на зміну файлу NostrImg. Новий NIP: може не працювати в інших клієнтах + Ваші релеї (NIP-95) + Файл зберігається на ваших релеях. Новий NIP: може не працювати в інших клієнтах + Всі підписки + Глобально + nostr.build – довірений + Nostr.build може змінювати файл + nostrfiles.dev – довірений + Nostrfiles.dev може змінювати файл + Верифікований Nostr.build (NIP-94) + Перевірка на зміну файлу Nostr.build. Новий NIP: може не працювати в інших клієнтах + Верифікований Nostrfiles.dev (NIP-94) + Перевірка на зміну файлу nostrfiles.dev. Новий NIP: може не працювати в інших клієнтах + Особисті повідомлення + Сповіщення про вхідні повідомлення + Запи + Сповіщення про отримані запахи + %1$s sat + Від %1$s + за %1$s \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 703bb8e124..b10e096310 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -296,7 +296,6 @@ Poll authors can\'t vote in their own polls. #zappoll - Image is the same since the post Image has changed. The author might not have seen the change @@ -356,10 +355,53 @@ Your relays (NIP-95) Files are hosted by your relays. New NIP: check if they support + Tor/Orbot setup + Connect through your Orbot setup + + Disconnect from your Orbot/Tor? + Your data will be immediately transferred in the regular network + Yes + No Follow List All Follows Global + + ## Connect through Tor with Orbot + \n\n1. Install [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) + \n2. Start Orbot + \n3. In Orbot, check the Socks port. The default uses 9050 + \n4. If necessary change the port in Orbot + \n5. Configure the Socks port in this screen + \n6. Press the Activate button to use Orbot as a proxy + + Orbot Socks Port + Invalid port number + Use Orbot + Disconnect Tor/Orbot + DefaultChannelID + New notification arrived + + PrivateMessagesID + Private Messages + Notifies you when a private message arrives + + ZapsID + Zaps Received + Notifies you when somebody zaps you + %1$s sats + From %1$s + for %1$s + + Notify: + + Join Conversation + User or Group\'s ID + npub, nevent or hex + Create + Join + + Today diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml new file mode 100644 index 0000000000..f85be4db8d --- /dev/null +++ b/app/src/play/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt new file mode 100644 index 0000000000..a933abb527 --- /dev/null +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -0,0 +1,22 @@ +package com.vitorpamplona.amethyst.service.notifications + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.service.model.Event + +class PushNotificationReceiverService : FirebaseMessagingService() { + + // this is called when a message is received + override fun onMessageReceived(remoteMessage: RemoteMessage) { + remoteMessage.data.let { + val eventStr = remoteMessage.data["event"] ?: return + val event = Event.fromJson(eventStr, true) + EventNotificationConsumer(applicationContext).consume(event) + } + } + + override fun onNewToken(token: String) { + RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) + } +} diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt new file mode 100644 index 0000000000..9fc979cb9e --- /dev/null +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -0,0 +1,31 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.util.Log +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import com.vitorpamplona.amethyst.AccountInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class PushNotificationUtils { + fun init(accounts: List) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + // get user notification token provided by firebase + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.w("FirebaseMsgService", "Fetching FCM registration token failed", task.exception) + return@OnCompleteListener + } + // Get new FCM registration token + val notificationToken = task.result + + RegisterAccounts(accounts).go(notificationToken) + } + ) + } + } +} diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 4c6e17a6ce..8e55bfcca1 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -48,7 +49,7 @@ fun TranslatableRichTextViewer( accountViewModel: AccountViewModel, navController: NavController ) { - val translatedTextState = remember { + var translatedTextState by remember { mutableStateOf(ResultOrError(content, null, null, null)) } @@ -56,7 +57,7 @@ fun TranslatableRichTextViewer( var langSettingsPopupExpanded by remember { mutableStateOf(false) } val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return val scope = rememberCoroutineScope() @@ -72,13 +73,17 @@ fun TranslatableRichTextViewer( val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!) showOriginal = preference == task.result.sourceLang } - translatedTextState.value = task.result + translatedTextState = task.result } } } } - val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content + val toBeViewed by remember { + derivedStateOf { + if (showOriginal) content else translatedTextState.result ?: content + } + } Column() { ExpandableRichTextViewer( @@ -91,8 +96,8 @@ fun TranslatableRichTextViewer( navController ) - val target = translatedTextState.value.targetLang - val source = translatedTextState.value.sourceLang + val target = translatedTextState.targetLang + val source = translatedTextState.sourceLang if (source != null && target != null) { if (source != target) { diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt index cbd9950b86..2f618ddf9c 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt @@ -61,7 +61,7 @@ class NIP19ParserTest { assertEquals("31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result?.hex) assertEquals(null, result?.relay) assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) - assertEquals(31337L, result?.kind) + assertEquals(31337, result?.kind) } @Test @@ -71,7 +71,7 @@ class NIP19ParserTest { assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result?.hex) assertEquals(null, result?.relay) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023L, result?.kind) + assertEquals(30023, result?.kind) } @Test @@ -81,7 +81,7 @@ class NIP19ParserTest { assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex) assertEquals(null, result?.relay) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023L, result?.kind) + assertEquals(30023, result?.kind) } @Test @@ -101,7 +101,7 @@ class NIP19ParserTest { assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) assertEquals("wss://nostr.mom", result?.relay) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) - assertEquals(1L, result?.kind) + assertEquals(1, result?.kind) } @Test @@ -112,7 +112,7 @@ class NIP19ParserTest { assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) assertEquals("wss://relay.damus.io", result?.relay) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1L, result?.kind) + assertEquals(1, result?.kind) } @Test @@ -123,7 +123,7 @@ class NIP19ParserTest { assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) assertEquals("wss://relay.damus.io", result?.relay) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1L, result?.kind) + assertEquals(1, result?.kind) } @Test diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt new file mode 100644 index 0000000000..29016a0d1e --- /dev/null +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip30Test.kt @@ -0,0 +1,58 @@ +package com.vitorpamplona.amethyst.service + +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class Nip30Test { + @Test() + fun parseEmoji() { + val input = "Alex Gleason :soapbox:" + + assertEquals( + listOf("Alex Gleason ", ":soapbox:", ""), + NIP30Parser().buildArray(input) + ) + } + + @Test() + fun parseEmojiInverted() { + val input = ":soapbox:Alex Gleason" + + assertEquals( + listOf("", ":soapbox:", "Alex Gleason"), + NIP30Parser().buildArray(input) + ) + } + + @Test() + fun parseEmoji2() { + val input = "Hello :gleasonator: \uD83D\uDE02 :ablobcatrainbow: :disputed: yolo" + + assertEquals( + listOf("Hello ", ":gleasonator:", " 😂 ", ":ablobcatrainbow:", " ", ":disputed:", " yolo"), + NIP30Parser().buildArray(input) + ) + + println(NIP30Parser().buildArray(input).joinToString(",")) + } + + @Test() + fun parseEmoji3() { + val input = "hello vitor: how can I help:" + + assertEquals( + listOf("hello vitor: how can I help:"), + NIP30Parser().buildArray(input) + ) + } + + @Test() + fun parseJapanese() { + val input = "\uD883\uDEDE\uD883\uDEDE麺の:x30EDE:。:\uD883\uDEDE:(Violation of NIP-30)" + + assertEquals( + listOf("\uD883\uDEDE\uD883\uDEDE麺の", ":x30EDE:", "。:\uD883\uDEDE:(Violation of NIP-30)"), + NIP30Parser().buildArray(input) + ) + } +} diff --git a/build.gradle b/build.gradle index 2c099a9ed3..0a999c817c 100644 --- a/build.gradle +++ b/build.gradle @@ -2,11 +2,15 @@ buildscript { ext { fragment_version = "1.5.7" lifecycle_version = '2.6.1' - compose_ui_version = '1.4.2' + compose_ui_version = '1.4.3' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' - coil_version = '2.3.0' + coil_version = '2.4.0' + vico_version = '1.6.6' + } + dependencies { + classpath 'com.google.gms:google-services:4.3.15' } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 0000000000..1db1b2cf48 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1 @@ +

Amethyst apporte le meilleur réseau social sur votre téléphone Android. Insérez simplement votre clé privée Nostr et commencez à poster.

Nostr est un protocole ouvert qui permet de créer un réseau "social" mondial résistant à la censure, en transférant des notes et d'autres éléments à l'aide de relais. Il ne repose pas sur un serveur central de confiance, ce qui le rend résistant; il est basé sur des clés et des signatures cryptographiques, ce qui le rend inviolable; il ne repose pas sur des techniques P2P, ce qui le rend efficace. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/images/icon.png b/fastlane/metadata/android/fr-FR/images/icon.png new file mode 100644 index 0000000000..dfae07cf09 Binary files /dev/null and b/fastlane/metadata/android/fr-FR/images/icon.png differ diff --git a/fastlane/metadata/android/fr-FR/images/phoneScreenshots/home.jpg b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/home.jpg new file mode 100644 index 0000000000..bb34431643 Binary files /dev/null and b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/home.jpg differ diff --git a/fastlane/metadata/android/fr-FR/images/phoneScreenshots/notifications.png b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/notifications.png new file mode 100644 index 0000000000..8e302188c9 Binary files /dev/null and b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/notifications.png differ diff --git a/fastlane/metadata/android/fr-FR/images/phoneScreenshots/replies.png b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/replies.png new file mode 100644 index 0000000000..70978d1df6 Binary files /dev/null and b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/replies.png differ diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 100644 index 0000000000..2984f2c4c8 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +Nostr Client pour Android \ No newline at end of file