diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 18916ef15..87b357540 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -126,6 +127,7 @@ private object PrefKeys { const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" const val SHARED_SETTINGS = "shared_settings" + const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets" } object LocalPreferences { @@ -357,6 +359,7 @@ object LocalPreferences { putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList) putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList) putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList) + putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) @@ -486,6 +489,7 @@ object LocalPreferences { val latestGeohashList = parseEventOrNull(PrefKeys.LATEST_GEOHASH_LIST) val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) val latestTrustProviderList = parseEventOrNull(PrefKeys.LATEST_TRUST_PROVIDER_LIST) + val latestPaymentTargets = parseEventOrNull(PrefKeys.LATEST_PAYMENT_TARGETS) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) @@ -533,6 +537,7 @@ object LocalPreferences { lastReadPerRoute = MutableStateFlow(lastReadPerRoute), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), pendingAttestations = MutableStateFlow(pendingAttestations), + backupNipA3PaymentTargets = latestPaymentTargets, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9b08ad91e..c45bbde5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryption import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState +import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState @@ -149,6 +150,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -353,6 +355,8 @@ class Account( val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) + val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings) + val feedDecryptionCaches = FeedDecryptionCaches( peopleListCache = peopleListDecryptionCache, @@ -511,6 +515,38 @@ class Account( onPrivate = ::broadcastPrivately, ) + /** + * Creates a reaction event without sending it. + * Returns the event and target relays for tracked broadcasting. + * Returns null if note has already been reacted to or note has no event. + */ + suspend fun createReactionEvent( + note: Note, + reaction: String, + ): Pair>? { + if (!signer.isWriteable()) return null + if (note.hasReacted(userProfile(), reaction)) return null + + val noteEvent = note.event ?: return null + + // For NIP-17 private groups, we don't support tracked mode (too complex) + if (noteEvent is NIP17Group) return null + + val relayHint = note.relays.firstOrNull()?.url + val event = ReactionAction.reactTo(noteEvent, reaction, signer, relayHint) + val relays = computeRelayListToBroadcast(event) + + return event to relays + } + + /** + * Consumes a reaction event into local cache. + * Called when tracked broadcasting succeeds. + */ + fun consumeReactionEvent(event: Event) { + cache.justConsumeMyOwnEvent(event) + } + suspend fun createZapRequestFor( event: Event, pollOption: Int?, @@ -632,6 +668,35 @@ class Account( } } + /** + * Creates a boost event without sending it. + * Returns the event and target relays for tracked broadcasting. + */ + suspend fun createBoostEvent(note: Note): Pair>? = + RepostAction.repost(note, signer)?.let { event -> + event to computeMyReactionToNote(note, event) + } + + /** + * Sends a boost event and updates the local cache. + * Used after tracked broadcasting completes. + */ + fun sendBoostEvent( + event: Event, + relays: Set, + ) { + client.send(event, relays) + cache.justConsumeMyOwnEvent(event) + } + + /** + * Updates the local cache with a boost event. + * Called when tracked broadcasting succeeds. + */ + fun consumeBoostEvent(event: Event) { + cache.justConsumeMyOwnEvent(event) + } + fun computeMyReactionToNote( note: Note, reaction: Event, @@ -1178,6 +1243,36 @@ class Account( return event } + /** + * Creates a post event without sending it. + * Returns the event, target relays, and extra events to broadcast. + * For use with tracked broadcasting. + */ + suspend fun createPostEvent( + template: EventTemplate, + extraNotesToBroadcast: List = emptyList(), + ): Triple, List> { + val event = signer.sign(template) + + // Use event-based relay computation (not note-based, since note is empty) + val relayList = computeRelayListToBroadcast(event) + + return Triple(event, relayList, extraNotesToBroadcast) + } + + /** + * Consumes a post event into local cache and sends extra events. + * Called when tracked broadcasting succeeds. + */ + fun consumePostEvent( + event: Event, + relays: Set, + extraNotesToBroadcast: List, + ) { + cache.justConsumeMyOwnEvent(event) + extraNotesToBroadcast.forEach { client.send(it, relays) } + } + suspend fun createAndSendDraftIgnoreErrors( draftTag: String, template: EventTemplate, @@ -1573,6 +1668,46 @@ class Account( } } + /** + * Creates a bookmark event without sending it. + * Returns the event and target relays for tracked broadcasting. + */ + suspend fun createAddBookmarkEvent( + note: Note, + isPrivate: Boolean, + ): Pair>? { + if (!isWriteable() || note.isDraft()) return null + + val event = bookmarkState.addBookmark(note, isPrivate) + val relays = outboxRelays.flow.value + + return event to relays + } + + /** + * Creates a remove bookmark event without sending it. + * Returns the event and target relays for tracked broadcasting. + */ + suspend fun createRemoveBookmarkEvent( + note: Note, + isPrivate: Boolean, + ): Pair>? { + if (!isWriteable() || note.isDraft()) return null + + val event = bookmarkState.removeBookmark(note, isPrivate) ?: return null + val relays = outboxRelays.flow.value + + return event to relays + } + + /** + * Consumes a bookmark event into local cache. + * Called when tracked broadcasting succeeds. + */ + fun consumeBookmarkEvent(event: Event) { + cache.justConsumeMyOwnEvent(event) + } + suspend fun createAuthEvent( relay: NormalizedRelayUrl, challenge: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index fb10ec4ce..07c767ceb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -145,6 +146,7 @@ class AccountSettings( val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), var hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), + var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, ) : EphemeralChatRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) @@ -342,6 +344,16 @@ class AccountSettings( } } + fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) { + if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) { + backupNipA3PaymentTargets = newNIPA3PaymentTargets + saveAccountSettings() + } + } + fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) { if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index a636b1068..e1c866921 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor @@ -48,6 +49,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent @@ -226,7 +228,7 @@ object LocalCache : ILocalCache, ICacheProvider { val liveChatChannels = LargeCache() val ephemeralChannels = LargeCache() - val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + val paymentTracker = NwcPaymentTracker() val relayHints = HintIndexer() @@ -309,7 +311,7 @@ object LocalCache : ILocalCache, ICacheProvider { fun load(keys: Set): Set = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) - fun getOrCreateUser(key: HexKey): User { + override fun getOrCreateUser(key: HexKey): User { require(isValidHex(key = key)) { "$key is not a valid hex" } return users.getOrCreate(key) { @@ -783,6 +785,51 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consume( + event: PaymentTargetsEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + val isVerified = + if (version.event == null && (wasVerified || justVerify(event))) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + true + } else { + wasVerified + } + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event?.id == event.id) return wasVerified + + if (antiSpam.isSpam(event, relay)) { + return false + } + + if (isVerified || justVerify(event)) { + if (event.createdAt > (note.createdAt() ?: 0L)) { + val replyTo = computeReplyTo(event) + + note.loadEvent(event, author, replyTo) + + refreshNewNoteObservers(note) + + return true + } + } + + return false + } + fun consume( event: WikiNoteEvent, relay: NormalizedRelayUrl?, @@ -2014,7 +2061,7 @@ object LocalCache : ILocalCache, ICacheProvider { zappedNote?.addZapPayment(note, null) - awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) + paymentTracker.registerRequest(event.id, zappedNote, onResponse) refreshNewNoteObservers(note) @@ -2030,9 +2077,10 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val requestId = event.requestId() - val pair = awaitingPaymentRequests[requestId] ?: return false + val pending = paymentTracker.onResponseReceived(requestId) ?: return false - val (zappedNote, responseCallback) = pair + val zappedNote = pending.zappedNote + val responseCallback = pending.onResponse val requestNote = requestId?.let { checkGetOrCreateNote(requestId) } @@ -2959,6 +3007,7 @@ object LocalCache : ILocalCache, ICacheProvider { is VoiceEvent -> consume(event, relay, wasVerified) is VoiceReplyEvent -> consume(event, relay, wasVerified) is WikiNoteEvent -> consume(event, relay, wasVerified) + is PaymentTargetsEvent -> consume(event, relay, wasVerified) else -> { Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}") false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt new file mode 100644 index 000000000..2f7bc83b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nipA3PaymentTargets + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class NipA3PaymentTargetsState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + val nipA3PaymentTargetsNote = cache.getOrCreateAddressableNote(getNipA3PaymentTargetsState()) + + fun getNipA3PaymentTargetsFlow(): StateFlow = nipA3PaymentTargetsNote.flow().metadata.stateFlow + + fun getNipA3PaymentTargetsState() = PaymentTargetsEvent.createAddress(signer.pubKey) + + init { + settings.backupNipA3PaymentTargets?.let { + Log.d("AccountRegisterObservers", "Loading saved nipA3 Payment targets ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "nipA3 Payment targets Collector Start") + getNipA3PaymentTargetsFlow().collect { + Log.d("AccountRegisterObservers", "Updating nipA3 Payment targets for ${signer.pubKey}") + (it.note.event as? PaymentTargetsEvent)?.let { paymentTargetsEvent -> + settings.updateNIPA3PaymentTargets(paymentTargetsEvent) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt new file mode 100644 index 000000000..6688e7c7a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.broadcast + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Result of a relay's response to an event publish. + */ +@Immutable +sealed class RelayResult { + /** Relay accepted the event (OK message with success=true) */ + data object Success : RelayResult() + + /** Relay rejected the event (OK message with success=false) */ + data class Error( + val code: String, + val message: String?, + ) : RelayResult() + + /** Relay did not respond within timeout */ + data object Timeout : RelayResult() + + /** Waiting for relay response */ + data object Pending : RelayResult() + + /** Retry in progress for this relay */ + data object Retrying : RelayResult() +} + +/** + * Overall status of a broadcast operation. + */ +enum class BroadcastStatus { + /** Currently waiting for relay responses */ + IN_PROGRESS, + + /** All relays accepted the event */ + SUCCESS, + + /** Some relays accepted, some failed */ + PARTIAL, + + /** No relays accepted the event */ + FAILED, +} + +/** + * Tracks a single event broadcast to multiple relays. + */ +@Immutable +data class BroadcastEvent( + val id: String, + val eventId: HexKey, + val eventName: String, + val kind: Int, + val targetRelays: List, + val results: Map = emptyMap(), + val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS, + val startedAt: Long = System.currentTimeMillis(), +) { + /** Number of relays that accepted the event */ + val successCount: Int + get() = results.count { it.value is RelayResult.Success } + + /** Number of relays that rejected or timed out */ + val failureCount: Int + get() = results.count { it.value is RelayResult.Error || it.value is RelayResult.Timeout } + + /** Number of relays still pending response */ + val pendingCount: Int + get() = targetRelays.size - results.size + + /** Total number of target relays */ + val totalRelays: Int + get() = targetRelays.size + + /** Progress as a fraction (0.0 to 1.0) */ + val progress: Float + get() = if (totalRelays == 0) 0f else results.size.toFloat() / totalRelays + + /** Whether all relays have responded */ + val isComplete: Boolean + get() = results.size >= targetRelays.size + + /** List of relays that failed and are not currently retrying */ + val failedRelays: List + get() = + results + .filter { + (it.value is RelayResult.Error || it.value is RelayResult.Timeout) && + it.value !is RelayResult.Retrying + }.keys + .toList() + + /** List of relays currently being retried */ + val retryingRelays: List + get() = results.filter { it.value is RelayResult.Retrying }.keys.toList() + + /** Creates a copy with an updated relay result */ + fun withResult( + relay: NormalizedRelayUrl, + result: RelayResult, + ): BroadcastEvent { + val newResults = results + (relay to result) + val newStatus = + when { + newResults.size < targetRelays.size -> BroadcastStatus.IN_PROGRESS + newResults.all { it.value is RelayResult.Success } -> BroadcastStatus.SUCCESS + newResults.none { it.value is RelayResult.Success } -> BroadcastStatus.FAILED + else -> BroadcastStatus.PARTIAL + } + return copy(results = newResults, status = newStatus) + } +} + +/** + * Result of a broadcast operation, returned after all relays respond or timeout. + */ +@Immutable +data class BroadcastResult( + val broadcast: BroadcastEvent, + val isSuccess: Boolean, +) { + val successCount: Int get() = broadcast.successCount + val totalRelays: Int get() = broadcast.totalRelays +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt new file mode 100644 index 000000000..0e0c4e41e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt @@ -0,0 +1,446 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.broadcast + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withTimeoutOrNull +import java.util.UUID + +/** + * Tracks event broadcasts to relays with live progress updates. + * + * Provides: + * - Real-time progress as relays respond + * - Detailed per-relay success/error information + * - Retry functionality for failed relays + */ +class BroadcastTracker { + companion object { + private const val TAG = "BroadcastTracker" + private const val TIMEOUT_SECONDS = 15L + const val COMPLETED_DISPLAY_DURATION_MS = 10_000L // SnackbarDuration.Long equivalent + } + + private val _activeBroadcasts = MutableStateFlow>(emptyList()) + val activeBroadcasts: StateFlow> = _activeBroadcasts.asStateFlow() + + private val _completedBroadcast = MutableSharedFlow(extraBufferCapacity = 10) + val completedBroadcast: SharedFlow = _completedBroadcast.asSharedFlow() + + // Event cache for retries - maps tracking ID to original Event + private val eventCache = mutableMapOf() + + /** + * Tracks an event broadcast to relays with live progress updates. + * + * @param event The Nostr event to broadcast + * @param eventName Human-readable name (e.g., "Boost", "Reaction") + * @param relays Target relays to send to + * @param client The Nostr client for sending + * @return BroadcastResult with final status + */ + @OptIn(DelicateCoroutinesApi::class) + suspend fun trackBroadcast( + event: Event, + eventName: String, + relays: Set, + client: INostrClient, + ): BroadcastResult { + val trackingId = UUID.randomUUID().toString() + + val broadcast = + BroadcastEvent( + id = trackingId, + eventId = event.id, + eventName = eventName, + kind = event.kind, + targetRelays = relays.toList(), + ) + + // Add to active broadcasts and cache event for retries + _activeBroadcasts.update { it + broadcast } + eventCache[trackingId] = event + + Log.d(TAG, "Starting broadcast $trackingId: $eventName (kind ${event.kind}) to ${relays.size} relays") + + val resultChannel = Channel(UNLIMITED) + + val subscription = + object : IRelayClientListener { + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + if (relay.url in relays) { + resultChannel.trySend( + RelayResponse( + relay = relay.url, + result = RelayResult.Error("CONNECTION_ERROR", errorMessage), + ), + ) + Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage") + } + } + + override fun onDisconnected(relay: IRelayClient) { + if (relay.url in relays) { + resultChannel.trySend( + RelayResponse( + relay = relay.url, + result = RelayResult.Error("DISCONNECTED", "Relay disconnected"), + ), + ) + Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}") + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + super.onIncomingMessage(relay, msgStr, msg) + + when (msg) { + is OkMessage -> { + if (msg.eventId == event.id) { + val result = + if (msg.success) { + RelayResult.Success + } else { + val (code, message) = parseOkError(msg.message) + RelayResult.Error(code, message) + } + resultChannel.trySend(RelayResponse(relay.url, result)) + Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}") + } + } + } + } + } + + client.subscribe(subscription) + + val finalBroadcast = + coroutineScope { + val resultCollector = + async { + val receivedRelays = mutableSetOf() + var currentBroadcast = broadcast + + withTimeoutOrNull(TIMEOUT_SECONDS * 1000) { + while (receivedRelays.size < relays.size) { + val response = resultChannel.receive() + + // Skip if already received (don't override success) + if (response.relay in receivedRelays) continue + + receivedRelays.add(response.relay) + currentBroadcast = currentBroadcast.withResult(response.relay, response.result) + + // Update active broadcasts with new progress + _activeBroadcasts.update { list -> + list.map { if (it.id == trackingId) currentBroadcast else it } + } + } + } + + // Mark remaining relays as timeout + relays.filter { it !in receivedRelays }.forEach { relay -> + currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout) + } + + currentBroadcast + } + + // Send after setting up listener + client.send(event, relays) + + resultCollector.await() + } + + client.unsubscribe(subscription) + resultChannel.close() + + // Remove from active, emit to completed + _activeBroadcasts.update { list -> list.filter { it.id != trackingId } } + _completedBroadcast.emit(finalBroadcast) + + Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") + + return BroadcastResult( + broadcast = finalBroadcast, + isSuccess = finalBroadcast.successCount > 0, + ) + } + + /** + * Marks relays as Retrying in an existing broadcast. + * Call this before starting the retry to show immediate feedback. + */ + fun markRelaysRetrying( + broadcastId: String, + relays: Set, + ) { + _activeBroadcasts.update { list -> + list.map { broadcast -> + if (broadcast.id == broadcastId) { + var updated = broadcast + relays.forEach { relay -> + updated = updated.withResult(relay, RelayResult.Retrying) + } + updated.copy(status = BroadcastStatus.IN_PROGRESS) + } else { + broadcast + } + } + } + } + + /** + * Retries sending an event to failed relays using cached event. + * Updates the existing broadcast in-place with retry results. + * + * @param broadcast The broadcast to retry (must be in activeBroadcasts or recently completed) + * @param client The Nostr client + * @param specificRelay Optional specific relay to retry (null = all failed) + * @return Updated BroadcastEvent or null if event not in cache + */ + @OptIn(DelicateCoroutinesApi::class) + suspend fun retry( + broadcast: BroadcastEvent, + client: INostrClient, + specificRelay: NormalizedRelayUrl? = null, + ): BroadcastEvent? { + val event = eventCache[broadcast.id] ?: return null + + val relaysToRetry = + if (specificRelay != null) { + setOf(specificRelay) + } else { + broadcast.failedRelays.toSet() + } + + if (relaysToRetry.isEmpty()) { + return broadcast + } + + // Mark relays as retrying for immediate feedback + markRelaysRetrying(broadcast.id, relaysToRetry) + + // If broadcast not in active list, re-add it + if (_activeBroadcasts.value.none { it.id == broadcast.id }) { + _activeBroadcasts.update { list -> + var updated = broadcast + relaysToRetry.forEach { relay -> + updated = updated.withResult(relay, RelayResult.Retrying) + } + list + updated.copy(status = BroadcastStatus.IN_PROGRESS) + } + } + + // Setup result collection + val resultChannel = Channel(UNLIMITED) + + val subscription = + object : IRelayClientListener { + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + if (relay.url in relaysToRetry) { + resultChannel.trySend( + RelayResponse( + relay = relay.url, + result = RelayResult.Error("CONNECTION_ERROR", errorMessage), + ), + ) + Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage") + } + } + + override fun onDisconnected(relay: IRelayClient) { + if (relay.url in relaysToRetry) { + resultChannel.trySend( + RelayResponse( + relay = relay.url, + result = RelayResult.Error("DISCONNECTED", "Relay disconnected"), + ), + ) + Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}") + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + super.onIncomingMessage(relay, msgStr, msg) + + when (msg) { + is OkMessage -> { + if (msg.eventId == event.id) { + val result = + if (msg.success) { + RelayResult.Success + } else { + val (code, message) = parseOkError(msg.message) + RelayResult.Error(code, message) + } + resultChannel.trySend(RelayResponse(relay.url, result)) + Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}") + } + } + } + } + } + + client.subscribe(subscription) + + val finalBroadcast = + coroutineScope { + val resultCollector = + async { + val receivedRelays = mutableSetOf() + var currentBroadcast = _activeBroadcasts.value.find { it.id == broadcast.id } ?: broadcast + + withTimeoutOrNull(TIMEOUT_SECONDS * 1000) { + while (receivedRelays.size < relaysToRetry.size) { + val response = resultChannel.receive() + + if (response.relay !in relaysToRetry) continue + if (response.relay in receivedRelays) continue + + receivedRelays.add(response.relay) + currentBroadcast = currentBroadcast.withResult(response.relay, response.result) + + _activeBroadcasts.update { list -> + list.map { if (it.id == broadcast.id) currentBroadcast else it } + } + } + } + + // Mark remaining as timeout + relaysToRetry.filter { it !in receivedRelays }.forEach { relay -> + currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout) + } + + // Recalculate status + val newStatus = + when { + currentBroadcast.results.values.any { it is RelayResult.Pending || it is RelayResult.Retrying } -> + BroadcastStatus.IN_PROGRESS + currentBroadcast.results.all { it.value is RelayResult.Success } -> + BroadcastStatus.SUCCESS + currentBroadcast.results.none { it.value is RelayResult.Success } -> + BroadcastStatus.FAILED + else -> BroadcastStatus.PARTIAL + } + currentBroadcast.copy(status = newStatus) + } + + client.send(event, relaysToRetry) + + resultCollector.await() + } + + client.unsubscribe(subscription) + resultChannel.close() + + // Update in active broadcasts + _activeBroadcasts.update { list -> + list.map { if (it.id == broadcast.id) finalBroadcast else it } + } + + // Emit to completed if all done + if (finalBroadcast.status != BroadcastStatus.IN_PROGRESS) { + _completedBroadcast.emit(finalBroadcast) + } + + Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") + + return finalBroadcast + } + + /** + * Gets details for a specific broadcast by tracking ID. + */ + fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId } + + /** + * Gets the cached event for a broadcast (for retries). + */ + fun getCachedEvent(trackingId: String): Event? = eventCache[trackingId] + + /** + * Removes a broadcast from cache (call after COMPLETED_DISPLAY_DURATION_MS expires). + */ + fun expireBroadcast(trackingId: String) { + eventCache.remove(trackingId) + Log.d(TAG, "Expired broadcast $trackingId from cache") + } + + /** + * Clears all active broadcasts and cache (e.g., on logout). + */ + fun clear() { + _activeBroadcasts.update { emptyList() } + eventCache.clear() + } + + /** + * Parses NIP-20 OK error message into code and description. + * Format: "prefix: message" or just "message" + */ + private fun parseOkError(message: String): Pair { + val parts = message.split(":", limit = 2) + return if (parts.size == 2) { + parts[0].trim().uppercase() to parts[1].trim() + } else { + "ERROR" to message.takeIf { it.isNotBlank() } + } + } + + private data class RelayResponse( + val relay: NormalizedRelayUrl, + val result: RelayResult, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt index e2f95fadb..aab7ff6e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity -import com.vitorpamplona.amethyst.ui.components.ShareImageAction +import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size110dp @@ -71,11 +71,11 @@ fun RenderControlButtons( } AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle -> - ShareImageAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle) + ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle) } } else { AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle -> - ShareImageAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle) + ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index 4c14a918d..eb9b1122b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.service.relays.EOSEByKey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index ff2b8411a..9152c9220 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index 3b5583f38..5eff68dd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index 2a93ccfd9..a1136b4dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 0265f6603..5df26f53b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManagerControls import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManagerControls import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblyGroup import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index b62529dcc..74ceae5a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.AccountFollowsLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.paymentTargets.AccountPaymentTargetsEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -61,6 +62,7 @@ class AccountFilterAssembler( AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys), + AccountPaymentTargetsEoseManager(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt index 2745f17e7..b7b092c0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 4693c9e74..6067d32b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.BundledUpdate -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.IEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/AccountPaymentTargetsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/AccountPaymentTargetsEoseManager.kt new file mode 100644 index 000000000..fbafca0ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/AccountPaymentTargetsEoseManager.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.paymentTargets + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class AccountPaymentTargetsEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List = + if (key.account.isWriteable()) { + relayFlow(key).value.flatMap { + listOf( + filterPaymentTargetsFromKey(it, user(key).pubkeyHex, since?.get(it)?.time), + ).flatten() + } + } else { + emptyList() + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + relayFlow(key).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/FilterPaymentTargetsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/FilterPaymentTargetsFromKey.kt new file mode 100644 index 000000000..343f0100e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/FilterPaymentTargetsFromKey.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.paymentTargets + +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +val paymentTargetsKinds = + listOf( + PaymentTargetsEvent.KIND, + ) + +fun filterPaymentTargetsFromKey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = paymentTargetsKinds, + authors = listOf(pubkey), + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt index 3a46ae22e..eee6db615 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt index 7a5534b39..f4f2bf0ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt index 07a032201..789dc3909 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixCha import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.filterChannelMetadataUpdatesById import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities.filterLiveStreamUpdatesByAddress diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index db3d6bb25..a3bf87923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt index d0ffe095e..a9c131539 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index ff3f946a6..efa768df6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.MutableTime diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt index fbb130e33..caf176734 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt index b71822d98..9fd6d793a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index dc4240828..712a84916 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt index 7e1450331..2bd9789fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @SuppressLint("StateFlowValueCalledInComposition") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index 76108f540..a7dcadb85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index a8bdc42cd..030afe5d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.toHexSet +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.MutableTime import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index 2b4843562..3b102fc38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.toHexSet +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.MutableTime import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index d3dd7e145..414e831ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt index 811cd7338..a29198da3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt index 534621242..a38617087 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt index c6a337bee..a5744c98d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 32af08e0b..7fe4340d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -42,11 +42,14 @@ import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +const val MAX_VOICE_RECORD_SECONDS = 600 + @OptIn(ExperimentalPermissionsApi::class) @Composable fun RecordAudioBox( modifier: Modifier, onRecordTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int? = null, content: @Composable (Boolean, Int) -> Unit, ) { val mediaRecorder = remember { mutableStateOf(null) } @@ -107,6 +110,10 @@ fun RecordAudioBox( while (isActive) { delay(1000) elapsedSeconds++ + if (maxDurationSeconds != null && elapsedSeconds >= maxDurationSeconds) { + stopRecording() + break + } } } else { // Reset elapsed time when not recording diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 9cedeeab6..5d9fd487f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -42,7 +42,10 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes @Composable -fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { +fun RecordVoiceButton( + onVoiceTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int? = null, +) { var isRecording by remember { mutableStateOf(false) } var elapsedSeconds by remember { mutableIntStateOf(0) } @@ -61,6 +64,7 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) { elapsedSeconds = 0 onVoiceTaken(recording) }, + maxDurationSeconds = maxDurationSeconds, ) { recordingState, elapsed -> // Update parent state after composition completes SideEffect { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt index b720e20d8..e154f0a27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationController.kt @@ -61,6 +61,7 @@ class VoiceAnonymizationController( preset: VoicePreset, originalFile: File?, ) { + Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}") if (processingPreset != null || preset == selectedPreset) return if (preset == VoicePreset.NONE) { @@ -76,9 +77,9 @@ class VoiceAnonymizationController( val file = originalFile ?: return processingJob?.cancel() + processingPreset = preset processingJob = scope.launch { - processingPreset = preset try { val anonymizer = VoiceAnonymizer() val result = anonymizer.anonymize(file, preset) @@ -108,8 +109,11 @@ class VoiceAnonymizationController( distortedFiles.values.forEach { result -> try { if (result.file.exists()) { - result.file.delete() - Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}") + if (result.file.delete()) { + Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}") + } else { + Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}") + } } } catch (e: Exception) { Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt index 3b7f02028..ad4cce69d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -63,7 +62,7 @@ fun VoiceAnonymizationSection( Text( text = stringRes(R.string.voice_anonymize_description), style = MaterialTheme.typography.bodySmall, - color = Color.Gray, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt index 391b79349..dd3ecef96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceAnonymizer.kt @@ -41,12 +41,26 @@ import java.io.File import java.nio.ByteOrder import kotlin.math.abs +/** + * Result of voice anonymization processing. + * + * @property file The output audio file (AAC in MP4 container) + * @property waveform Amplitude data for waveform visualization (one value per second) + * @property duration Audio duration in seconds + */ data class AnonymizedResult( val file: File, val waveform: List, val duration: Int, ) +/** + * Processes audio files to alter voice characteristics for privacy. + * + * Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with + * rate transposition to shift pitch while preserving duration. Note that in TarsosDSP, + * pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch. + */ class VoiceAnonymizer { companion object { private const val TAG = "VoiceAnonymizer" @@ -54,6 +68,20 @@ class VoiceAnonymizer { private const val BIT_RATE = 128000 } + /** + * Applies voice anonymization to an audio file. + * + * The process involves three stages: + * 1. Decode input audio to PCM (0-30% progress) + * 2. Apply pitch shifting with TarsosDSP (30-70% progress) + * 3. Encode processed audio to AAC (70-100% progress) + * + * @param inputFile Source audio file (supports formats decodable by MediaCodec) + * @param preset Voice transformation preset (NONE is not allowed) + * @param onProgress Callback invoked with progress value from 0.0 to 1.0 + * @return [Result.success] with [AnonymizedResult] containing the output file, + * waveform data, and duration; or [Result.failure] with the exception + */ suspend fun anonymize( inputFile: File, preset: VoicePreset, @@ -98,7 +126,8 @@ class VoiceAnonymizer { ): File { val baseName = inputFile.nameWithoutExtension val presetSuffix = preset.name.lowercase() - return File(inputFile.parentFile, "${baseName}_$presetSuffix.mp4") + val parentDir = inputFile.parentFile ?: inputFile.absoluteFile.parentFile + return File(parentDir, "${baseName}_$presetSuffix.mp4") } private data class DecodedAudio( @@ -107,6 +136,116 @@ class VoiceAnonymizer { val duration: Int, ) + private data class AudioTrackInfo( + val trackIndex: Int, + val format: MediaFormat, + val mime: String, + val sampleRate: Int, + val durationUs: Long, + ) + + private fun findAudioTrack(extractor: MediaExtractor): AudioTrackInfo { + for (i in 0 until extractor.trackCount) { + val trackFormat = extractor.getTrackFormat(i) + val mime = trackFormat.getString(MediaFormat.KEY_MIME) + if (mime?.startsWith("audio/") == true) { + return AudioTrackInfo( + trackIndex = i, + format = trackFormat, + mime = mime, + sampleRate = trackFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE), + durationUs = trackFormat.getLong(MediaFormat.KEY_DURATION), + ) + } + } + throw IllegalStateException("No audio track found in file") + } + + private class DecoderInputFeeder( + private val decoder: MediaCodec, + private val extractor: MediaExtractor, + private val durationUs: Long, + private val onProgress: (Float) -> Unit, + ) { + var isDone = false + private set + + fun feedInput() { + if (isDone) return + + val inputBufferIndex = decoder.dequeueInputBuffer(10000) + if (inputBufferIndex < 0) return + + val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!! + val sampleSize = extractor.readSampleData(inputBuffer, 0) + + if (sampleSize < 0) { + queueEndOfStream(inputBufferIndex) + } else { + queueSampleData(inputBufferIndex, sampleSize) + } + } + + private fun queueEndOfStream(inputBufferIndex: Int) { + decoder.queueInputBuffer( + inputBufferIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM, + ) + isDone = true + } + + private fun queueSampleData( + inputBufferIndex: Int, + sampleSize: Int, + ) { + val presentationTimeUs = extractor.sampleTime + decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0) + extractor.advance() + reportProgress(presentationTimeUs) + } + + private fun reportProgress(presentationTimeUs: Long) { + if (durationUs > 0) { + onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f)) + } + } + } + + private class DecoderOutputDrainer( + private val decoder: MediaCodec, + private val pcmSamples: MutableList, + ) { + private val bufferInfo = MediaCodec.BufferInfo() + var isDone = false + private set + + fun drainOutput() { + val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000) + if (outputBufferIndex < 0) return + + extractPcmSamples(outputBufferIndex) + decoder.releaseOutputBuffer(outputBufferIndex, false) + checkForEndOfStream() + } + + private fun extractPcmSamples(outputBufferIndex: Int) { + val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!! + val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer() + while (shortBuffer.hasRemaining()) { + pcmSamples.add(shortBuffer.get() / 32768f) + } + } + + private fun checkForEndOfStream() { + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + isDone = true + } + } + } + private suspend fun decodeAudioToPcm( inputFile: File, onProgress: (Float) -> Unit, @@ -116,92 +255,43 @@ class VoiceAnonymizer { try { extractor.setDataSource(inputFile.absolutePath) + val trackInfo = findAudioTrack(extractor) + extractor.selectTrack(trackInfo.trackIndex) - var audioTrackIndex = -1 - var format: MediaFormat? = null - for (i in 0 until extractor.trackCount) { - val trackFormat = extractor.getTrackFormat(i) - val mime = trackFormat.getString(MediaFormat.KEY_MIME) - if (mime?.startsWith("audio/") == true) { - audioTrackIndex = i - format = trackFormat - break - } - } - - if (audioTrackIndex == -1 || format == null) { - throw IllegalStateException("No audio track found in file") - } - - extractor.selectTrack(audioTrackIndex) - val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm" - val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) - val durationUs = format.getLong(MediaFormat.KEY_DURATION) - val duration = (durationUs / 1_000_000).toInt() - - decoder = MediaCodec.createDecoderByType(mime) - decoder.configure(format, null, null, 0) - decoder.start() - - val pcmSamples = mutableListOf() - val bufferInfo = MediaCodec.BufferInfo() - var inputDone = false - var outputDone = false - - while (!outputDone && currentCoroutineContext().isActive) { - if (!inputDone) { - val inputBufferIndex = decoder.dequeueInputBuffer(10000) - if (inputBufferIndex >= 0) { - val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!! - val sampleSize = extractor.readSampleData(inputBuffer, 0) - if (sampleSize < 0) { - decoder.queueInputBuffer( - inputBufferIndex, - 0, - 0, - 0, - MediaCodec.BUFFER_FLAG_END_OF_STREAM, - ) - inputDone = true - } else { - val presentationTimeUs = extractor.sampleTime - decoder.queueInputBuffer( - inputBufferIndex, - 0, - sampleSize, - presentationTimeUs, - 0, - ) - extractor.advance() - if (durationUs > 0) { - onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f)) - } - } - } + decoder = + MediaCodec.createDecoderByType(trackInfo.mime).apply { + configure(trackInfo.format, null, null, 0) + start() } - val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000) - if (outputBufferIndex >= 0) { - val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!! - val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer() - while (shortBuffer.hasRemaining()) { - pcmSamples.add(shortBuffer.get() / 32768f) - } - decoder.releaseOutputBuffer(outputBufferIndex, false) - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - outputDone = true - } - } + val estimatedSamples = (trackInfo.sampleRate.toLong() * trackInfo.durationUs / 1_000_000).toInt() + val pcmSamples = ArrayList(estimatedSamples) + + val inputFeeder = DecoderInputFeeder(decoder, extractor, trackInfo.durationUs, onProgress) + val outputDrainer = DecoderOutputDrainer(decoder, pcmSamples) + + while (!outputDrainer.isDone && currentCoroutineContext().isActive) { + inputFeeder.feedInput() + outputDrainer.drainOutput() } - return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration) + val duration = (trackInfo.durationUs / 1_000_000).toInt() + return DecodedAudio(pcmSamples.toFloatArray(), trackInfo.sampleRate, duration) } finally { - decoder?.stop() - decoder?.release() + decoder?.safeStopAndRelease() extractor.release() } } + private fun MediaCodec.safeStopAndRelease() { + try { + stop() + } catch (_: IllegalStateException) { + // Decoder was never started + } + release() + } + private fun processPcmWithTarsos( pcmData: FloatArray, preset: VoicePreset, @@ -218,8 +308,8 @@ class VoiceAnonymizer { } else -> baseFactor } - val processedSamples = mutableListOf() val totalSamples = pcmData.size + val processedSamples = ArrayList(totalSamples) val wsola = WaveformSimilarityBasedOverlapAdd( @@ -252,7 +342,9 @@ class VoiceAnonymizer { return true } - override fun processingFinished() {} + override fun processingFinished() { + // No-op: no cleanup needed + } } val dispatcher = @@ -276,7 +368,9 @@ class VoiceAnonymizer { return true } - override fun processingFinished() {} + override fun processingFinished() { + // No-op: no cleanup needed + } } dispatcher.addAudioProcessor(progressProcessor) @@ -308,106 +402,159 @@ class VoiceAnonymizer { return waveform } + private class EncoderInputFeeder( + private val encoder: MediaCodec, + private val pcmData: FloatArray, + private val sampleRate: Int, + private val onProgress: (Float) -> Unit, + ) { + private var inputOffset = 0 + var isDone = false + private set + + fun feedInput() { + if (isDone) return + + val inputBufferIndex = encoder.dequeueInputBuffer(10000) + if (inputBufferIndex < 0) return + + val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!! + inputBuffer.clear() + + val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset) + if (samplesToWrite <= 0) { + queueEndOfStream(inputBufferIndex) + } else { + queueSampleData(inputBufferIndex, inputBuffer, samplesToWrite) + } + } + + private fun queueEndOfStream(inputBufferIndex: Int) { + encoder.queueInputBuffer( + inputBufferIndex, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM, + ) + isDone = true + } + + private fun queueSampleData( + inputBufferIndex: Int, + inputBuffer: java.nio.ByteBuffer, + samplesToWrite: Int, + ) { + writePcmSamplesToBuffer(inputBuffer, samplesToWrite) + val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate + encoder.queueInputBuffer(inputBufferIndex, 0, inputBuffer.position(), presentationTimeUs, 0) + inputOffset += samplesToWrite + onProgress(inputOffset.toFloat() / pcmData.size) + } + + private fun writePcmSamplesToBuffer( + inputBuffer: java.nio.ByteBuffer, + samplesToWrite: Int, + ) { + for (i in 0 until samplesToWrite) { + val sample = + (pcmData[inputOffset + i] * 32767) + .toInt() + .coerceIn(-32768, 32767) + .toShort() + inputBuffer.putShort(sample) + } + } + } + + private class EncoderOutputDrainer( + private val encoder: MediaCodec, + private val muxer: MediaMuxer, + ) { + private val bufferInfo = MediaCodec.BufferInfo() + private var audioTrackIndex = -1 + var isMuxerStarted = false + private set + var isDone = false + private set + + fun drainOutput() { + val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000) + + when { + outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> startMuxer() + outputBufferIndex >= 0 -> processOutputBuffer(outputBufferIndex) + } + } + + private fun startMuxer() { + audioTrackIndex = muxer.addTrack(encoder.outputFormat) + muxer.start() + isMuxerStarted = true + } + + private fun processOutputBuffer(outputBufferIndex: Int) { + val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!! + writeToMuxerIfReady(outputBuffer) + encoder.releaseOutputBuffer(outputBufferIndex, false) + checkForEndOfStream() + } + + private fun writeToMuxerIfReady(outputBuffer: java.nio.ByteBuffer) { + if (isMuxerStarted && bufferInfo.size > 0) { + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo) + } + } + + private fun checkForEndOfStream() { + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + isDone = true + } + } + } + + private fun createAacFormat(sampleRate: Int): MediaFormat = + MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, CHANNELS).apply { + setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) + setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) + } + private fun encodePcmToAac( pcmData: FloatArray, sampleRate: Int, outputFile: File, onProgress: (Float) -> Unit, ) { - val format = - MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, CHANNELS) - format.setInteger( - MediaFormat.KEY_AAC_PROFILE, - MediaCodecInfo.CodecProfileLevel.AACObjectLC, - ) - format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) - val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) var muxerStarted = false try { - encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + encoder.configure(createAacFormat(sampleRate), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) encoder.start() - var audioTrackIndex = -1 - val bufferInfo = MediaCodec.BufferInfo() - var inputOffset = 0 - var inputDone = false - var outputDone = false - val totalSamples = pcmData.size + val inputFeeder = EncoderInputFeeder(encoder, pcmData, sampleRate, onProgress) + val outputDrainer = EncoderOutputDrainer(encoder, muxer) - while (!outputDone) { - if (!inputDone) { - val inputBufferIndex = encoder.dequeueInputBuffer(10000) - if (inputBufferIndex >= 0) { - val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!! - inputBuffer.clear() - - val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset) - if (samplesToWrite <= 0) { - encoder.queueInputBuffer( - inputBufferIndex, - 0, - 0, - 0, - MediaCodec.BUFFER_FLAG_END_OF_STREAM, - ) - inputDone = true - } else { - for (i in 0 until samplesToWrite) { - val sample = - (pcmData[inputOffset + i] * 32767) - .toInt() - .coerceIn(-32768, 32767) - .toShort() - inputBuffer.putShort(sample) - } - val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate - encoder.queueInputBuffer( - inputBufferIndex, - 0, - inputBuffer.position(), - presentationTimeUs, - 0, - ) - inputOffset += samplesToWrite - onProgress(inputOffset.toFloat() / totalSamples) - } - } - } - - val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000) - when { - outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - audioTrackIndex = muxer.addTrack(encoder.outputFormat) - muxer.start() - muxerStarted = true - } - - outputBufferIndex >= 0 -> { - val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!! - if (muxerStarted && bufferInfo.size > 0) { - outputBuffer.position(bufferInfo.offset) - outputBuffer.limit(bufferInfo.offset + bufferInfo.size) - muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo) - } - encoder.releaseOutputBuffer(outputBufferIndex, false) - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - outputDone = true - } - } - } + while (!outputDrainer.isDone) { + inputFeeder.feedInput() + outputDrainer.drainOutput() } + muxerStarted = outputDrainer.isMuxerStarted } finally { - encoder.stop() - encoder.release() - if (muxerStarted) { - muxer.stop() - } - muxer.release() + encoder.safeStopAndRelease() + muxer.safeStopAndRelease(muxerStarted) } } + + private fun MediaMuxer.safeStopAndRelease(wasStarted: Boolean) { + if (wasStarted) { + stop() + } + release() + } } private class FloatArrayAudioInputStream( @@ -446,5 +593,7 @@ private class FloatArrayAudioInputStream( return actualSkip.toLong() * 2 } - override fun close() {} + override fun close() { + // No-op: no cleanup needed + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index 7633a47d8..40f60124e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -35,8 +35,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -67,6 +69,8 @@ fun VoiceMessagePreview( voiceMetadata: AudioMeta, localFile: File? = null, onRemove: () -> Unit, + onReRecord: ((RecordingResult) -> Unit)? = null, + isUploading: Boolean = false, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -101,77 +105,159 @@ fun VoiceMessagePreview( shape = RoundedCornerShape(8.dp), ).padding(12.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - // Play/Pause Button - IconButton( - onClick = { - handlePlayPauseClick( - mediaPlayer = mediaPlayer, - isPlaying = isPlaying, - progress = progress, - onProgressReset = { progress = 0f }, - onPlayingChanged = { isPlaying = it }, - ) - }, - modifier = Modifier.size(48.dp), + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon( - imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), - tint = MaterialTheme.colorScheme.primary, - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Waveform and Duration - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - ) { - AudioWaveformReadOnly( - amplitudes = voiceMetadata.waveform ?: emptyList(), - progress = progress, - waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), - progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), - onProgressChange = { newProgress -> - handleWaveformScrub( - newProgress = newProgress, + // Play/Pause Button + IconButton( + onClick = { + handlePlayPauseClick( mediaPlayer = mediaPlayer, - onProgressChanged = { progress = it }, + isPlaying = isPlaying, + progress = progress, + onProgressReset = { progress = 0f }, + onPlayingChanged = { isPlaying = it }, ) }, - ) + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play), + tint = MaterialTheme.colorScheme.primary, + ) + } - Text( - text = formatSecondsToTime(voiceMetadata.duration ?: 0), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) + Spacer(modifier = Modifier.width(8.dp)) + + // Waveform and Duration + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + AudioWaveformReadOnly( + amplitudes = voiceMetadata.waveform ?: emptyList(), + progress = progress, + waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), + progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), + onProgressChange = { newProgress -> + handleWaveformScrub( + newProgress = newProgress, + mediaPlayer = mediaPlayer, + onProgressChanged = { progress = it }, + ) + }, + ) + + Text( + text = formatSecondsToTime(voiceMetadata.duration ?: 0), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Remove Button + IconButton( + onClick = onRemove, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringRes(context, R.string.remove), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - Spacer(modifier = Modifier.width(8.dp)) - - // Remove Button - IconButton( - onClick = onRemove, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringRes(context, R.string.remove), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + if (onReRecord != null) { + Spacer(modifier = Modifier.size(8.dp)) + ReRecordButton( + isUploading = isUploading, + isPlaying = isPlaying, + onRecordTaken = onReRecord, ) } } } } +@Composable +private fun ReRecordButton( + isUploading: Boolean, + isPlaying: Boolean, + onRecordTaken: (RecordingResult) -> Unit, +) { + if (isUploading || isPlaying) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringRes(id = R.string.re_record), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + RecordAudioBox( + modifier = Modifier, + onRecordTaken = onRecordTaken, + maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, + ) { isRecording, elapsedSeconds -> + val contentColor = + if (isRecording) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val icon = + if (isRecording) { + Icons.Default.Stop + } else { + Icons.Default.Mic + } + val label = + if (isRecording) { + formatSecondsToTime(elapsedSeconds) + } else { + stringRes(id = R.string.re_record) + } + val iconDescription = + if (isRecording) { + stringRes(id = R.string.recording_indicator_description) + } else { + stringRes(id = R.string.record_a_message) + } + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = iconDescription, + tint = contentColor, + ) + Text( + text = label, + color = contentColor, + ) + } + } +} + @Composable private fun ManageMediaPlayer( voiceMetadata: AudioMeta, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt index 49544e51a..b6c67d244 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt @@ -29,5 +29,5 @@ enum class VoicePreset( NONE(1.0, R.string.voice_preset_none), DEEP(1.4, R.string.voice_preset_deep), HIGH(0.75, R.string.voice_preset_high), - NEUTRAL(0.9, R.string.voice_preset_neutral), + NEUTRAL(1.1, R.string.voice_preset_neutral), } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt new file mode 100644 index 000000000..0db0e2628 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -0,0 +1,203 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.broadcast + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent + +/** + * Banner showing active broadcast progress. + * Displayed above bottom navigation when events are being sent to relays. + */ +@Composable +fun BroadcastBanner( + broadcasts: List, + onTap: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = broadcasts.isNotEmpty(), + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 2.dp, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap), + ) { + Column( + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize(), + ) { + if (broadcasts.size == 1) { + SingleBroadcastContent(broadcasts.first()) + } else { + MultipleBroadcastsContent(broadcasts) + } + } + } + } +} + +@Composable +private fun SingleBroadcastContent(broadcast: BroadcastEvent) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = "Broadcasting", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Broadcasting ${broadcast.eventName} (kind ${broadcast.kind})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = "[${broadcast.results.size}/${broadcast.totalRelays}]", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(Modifier.height(4.dp)) + + val animatedProgress by animateFloatAsState( + targetValue = broadcast.progress, + animationSpec = tween(300), + label = "progress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } +} + +@Composable +private fun MultipleBroadcastsContent(broadcasts: List) { + val totalRelays = broadcasts.sumOf { it.totalRelays } + val completedResponses = broadcasts.sumOf { it.results.size } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = "Broadcasting", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Broadcasting ${broadcasts.size} events...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + text = "[$completedResponses/$totalRelays]", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(Modifier.height(4.dp)) + + val progress = if (totalRelays > 0) completedResponses.toFloat() / totalRelays else 0f + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(300), + label = "progress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt new file mode 100644 index 000000000..4358ef697 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -0,0 +1,512 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.broadcast + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent +import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus +import com.vitorpamplona.amethyst.service.broadcast.RelayResult +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +private const val MAX_EXPANDED_SECTIONS = 2 + +/** + * Modal bottom sheet showing detailed relay results for broadcasts. + * Shows up to 2 expanded sections, with a summary for additional broadcasts. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BroadcastDetailsSheet( + broadcast: BroadcastEvent, + onDismiss: () -> Unit, + onRetryRelay: (NormalizedRelayUrl) -> Unit, + onRetryAllFailed: () -> Unit, + sheetState: SheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ), +) { + MultiBroadcastDetailsSheet( + broadcasts = listOf(broadcast), + onDismiss = onDismiss, + onRetryRelay = { _, relay -> onRetryRelay(relay) }, + onRetryAllFailed = { onRetryAllFailed() }, + sheetState = sheetState, + ) +} + +/** + * Modal bottom sheet showing detailed relay results for multiple broadcasts. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MultiBroadcastDetailsSheet( + broadcasts: List, + onDismiss: () -> Unit, + onRetryRelay: (BroadcastEvent, NormalizedRelayUrl) -> Unit, + onRetryAllFailed: (BroadcastEvent) -> Unit, + sheetState: SheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ), +) { + // Synced rotation animation for all pending/retrying icons + val infiniteTransition = rememberInfiniteTransition(label = "pendingRotation") + val rotationAngle by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "rotation", + ) + + // Track which sections are expanded (by broadcast ID) + var expandedSections by remember { mutableStateOf(setOf()) } + + // Sort: in-progress first, then by start time + val sortedBroadcasts = + broadcasts.sortedWith( + compareBy { it.status != BroadcastStatus.IN_PROGRESS } + .thenByDescending { it.startedAt }, + ) + + // First 2 get shown expanded by default (unless completed) + val visibleBroadcasts = sortedBroadcasts.take(MAX_EXPANDED_SECTIONS) + val overflowBroadcasts = sortedBroadcasts.drop(MAX_EXPANDED_SECTIONS) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()), + ) { + // Header + Text( + text = if (broadcasts.size == 1) "Broadcast Results" else "Broadcasts (${broadcasts.size})", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + // Visible broadcast sections + visibleBroadcasts.forEachIndexed { index, broadcast -> + val isExpanded = + broadcast.id in expandedSections || + (broadcast.status == BroadcastStatus.IN_PROGRESS && broadcast.id !in expandedSections) + + // Auto-expand in-progress, auto-collapse completed (unless manually expanded) + val showExpanded = + if (broadcast.status == BroadcastStatus.IN_PROGRESS) { + true + } else { + broadcast.id in expandedSections + } + + BroadcastSection( + broadcast = broadcast, + isExpanded = showExpanded, + onToggleExpand = { + expandedSections = + if (broadcast.id in expandedSections) { + expandedSections - broadcast.id + } else { + expandedSections + broadcast.id + } + }, + onRetryRelay = { relay -> onRetryRelay(broadcast, relay) }, + onRetryAllFailed = { onRetryAllFailed(broadcast) }, + rotationAngle = rotationAngle, + ) + + if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + } + } + + // Overflow summary + if (overflowBroadcasts.isNotEmpty()) { + OverflowSummary(overflowBroadcasts) + } + + Spacer(Modifier.height(16.dp)) + + // Dismiss button + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Dismiss") + } + } + } +} + +@Composable +private fun BroadcastSection( + broadcast: BroadcastEvent, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onRetryRelay: (NormalizedRelayUrl) -> Unit, + onRetryAllFailed: () -> Unit, + rotationAngle: Float, +) { + Column(modifier = Modifier.fillMaxWidth()) { + // Section header (clickable to expand/collapse) + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable { onToggleExpand() }, + color = Color.Transparent, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 8.dp), + ) { + StatusIcon(broadcast.status, rotationAngle) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = broadcast.eventName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "kind ${broadcast.kind}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = "${broadcast.successCount}/${broadcast.totalRelays}", + style = MaterialTheme.typography.labelLarge, + color = statusColor(broadcast.status), + ) + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } + + // Expandable relay list + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + broadcast.targetRelays.forEach { relay -> + val result = broadcast.results[relay] ?: RelayResult.Pending + RelayResultRow( + relay = relay, + result = result, + onRetry = { onRetryRelay(relay) }, + rotationAngle = rotationAngle, + ) + } + + // Retry all button + if (broadcast.failedRelays.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Button( + onClick = onRetryAllFailed, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Retry Failed (${broadcast.failedRelays.size})") + } + } + } + } + } +} + +@Composable +private fun OverflowSummary(broadcasts: List) { + val totalSuccess = broadcasts.sumOf { it.successCount } + val totalRelays = broadcasts.sumOf { it.totalRelays } + val inProgressCount = broadcasts.count { it.status == BroadcastStatus.IN_PROGRESS } + + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(12.dp), + ) { + Icon( + imageVector = Icons.Default.HourglassEmpty, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + + Spacer(Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "+${broadcasts.size} more broadcasts", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = + buildString { + append("$totalSuccess/$totalRelays relays") + if (inProgressCount > 0) { + append(" ($inProgressCount in progress)") + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun statusColor(status: BroadcastStatus): Color = + when (status) { + BroadcastStatus.SUCCESS -> Color(0xFF22C55E) + BroadcastStatus.PARTIAL -> Color(0xFFF59E0B) + BroadcastStatus.FAILED -> MaterialTheme.colorScheme.error + BroadcastStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary + } + +@Composable +private fun StatusIcon( + status: BroadcastStatus, + rotationAngle: Float = 0f, +) { + val (icon, tint, shouldRotate) = + when (status) { + BroadcastStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, Color(0xFF22C55E), false) + BroadcastStatus.PARTIAL -> Triple(Icons.Default.Error, Color(0xFFF59E0B), false) + BroadcastStatus.FAILED -> Triple(Icons.Default.Error, MaterialTheme.colorScheme.error, false) + BroadcastStatus.IN_PROGRESS -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true) + } + + Icon( + imageVector = icon, + contentDescription = status.name, + tint = tint, + modifier = + Modifier + .size(24.dp) + .then( + if (shouldRotate) { + Modifier.graphicsLayer { rotationZ = rotationAngle } + } else { + Modifier + }, + ), + ) +} + +@Composable +private fun RelayResultRow( + relay: NormalizedRelayUrl, + result: RelayResult, + onRetry: () -> Unit, + rotationAngle: Float = 0f, +) { + val successColor = Color(0xFF22C55E) + val errorColor = MaterialTheme.colorScheme.error + val warningColor = Color(0xFFF59E0B) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Status icon + val (icon, tint, shouldRotate) = + when (result) { + is RelayResult.Success -> Triple(Icons.Default.CheckCircle, successColor, false) + is RelayResult.Error -> Triple(Icons.Default.Error, errorColor, false) + is RelayResult.Timeout -> Triple(Icons.Default.HourglassEmpty, warningColor, false) + is RelayResult.Pending -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.onSurfaceVariant, true) + is RelayResult.Retrying -> Triple(Icons.Default.HourglassEmpty, MaterialTheme.colorScheme.primary, true) + } + + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = + Modifier + .size(20.dp) + .then( + if (shouldRotate) { + Modifier.graphicsLayer { rotationZ = rotationAngle } + } else { + Modifier + }, + ), + ) + + Spacer(Modifier.width(12.dp)) + + // Relay URL + Column(modifier = Modifier.weight(1f)) { + Text( + text = relay.displayUrl(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // Error message if present + when (result) { + is RelayResult.Error -> { + Text( + text = "[${result.code}]${result.message?.let { " $it" } ?: ""}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = errorColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + is RelayResult.Timeout -> { + Text( + text = "Timeout", + style = MaterialTheme.typography.bodySmall, + color = warningColor, + ) + } + is RelayResult.Retrying -> { + Text( + text = "Retrying...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + else -> {} + } + } + + // Retry button for failed relays (not when already retrying) + if ((result is RelayResult.Error || result is RelayResult.Timeout) && result !is RelayResult.Retrying) { + IconButton( + onClick = onRetry, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Retry", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastSnackbar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastSnackbar.kt new file mode 100644 index 000000000..df71a6a30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastSnackbar.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.broadcast + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SnackbarVisuals +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent +import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus +import kotlinx.coroutines.flow.SharedFlow + +/** + * Custom snackbar visuals for broadcast results. + */ +data class BroadcastSnackbarVisuals( + val broadcast: BroadcastEvent, + override val actionLabel: String? = "View", + override val duration: SnackbarDuration = SnackbarDuration.Short, + override val withDismissAction: Boolean = true, +) : SnackbarVisuals { + override val message: String + get() = + when (broadcast.status) { + BroadcastStatus.SUCCESS -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays" + BroadcastStatus.PARTIAL -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays" + BroadcastStatus.FAILED -> "${broadcast.eventName} failed - 0/${broadcast.totalRelays} relays" + BroadcastStatus.IN_PROGRESS -> "Broadcasting ${broadcast.eventName}..." + } +} + +/** + * Snackbar host that listens to completed broadcasts and shows result notifications. + */ +@Composable +fun BroadcastSnackbarHost( + completedBroadcast: SharedFlow, + onViewDetails: (BroadcastEvent) -> Unit, + onRetry: (BroadcastEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(completedBroadcast) { + completedBroadcast.collect { broadcast -> + val visuals = BroadcastSnackbarVisuals(broadcast) + val result = snackbarHostState.showSnackbar(visuals) + + when (result) { + SnackbarResult.ActionPerformed -> { + if (broadcast.status == BroadcastStatus.FAILED) { + onRetry(broadcast) + } else { + onViewDetails(broadcast) + } + } + SnackbarResult.Dismissed -> { /* No action */ } + } + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = modifier.padding(bottom = 8.dp), + ) { snackbarData -> + BroadcastSnackbarContent(snackbarData) + } +} + +@Composable +private fun BroadcastSnackbarContent(snackbarData: SnackbarData) { + val visuals = snackbarData.visuals + val broadcastVisuals = visuals as? BroadcastSnackbarVisuals + + val containerColor = + when (broadcastVisuals?.broadcast?.status) { + BroadcastStatus.FAILED -> Color(0xFF7F1D1D) // Dark red + BroadcastStatus.PARTIAL -> Color(0xFF78350F) // Dark amber + else -> Color(0xFF1E3A5F) // Dark blue (default) + } + + val actionLabel = + when (broadcastVisuals?.broadcast?.status) { + BroadcastStatus.FAILED -> "Retry" + else -> "View" + } + + Snackbar( + action = { + snackbarData.visuals.actionLabel?.let { + TextButton(onClick = { snackbarData.performAction() }) { + Text(actionLabel) + } + } + }, + dismissAction = + if (visuals.withDismissAction) { + { + TextButton(onClick = { snackbarData.dismiss() }) { + Text("Dismiss") + } + } + } else { + null + }, + containerColor = containerColor, + ) { + Text(visuals.message) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt new file mode 100644 index 000000000..465aa9c50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt @@ -0,0 +1,294 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.broadcast + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent +import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus +import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Displays broadcast progress UI components: + * - BroadcastBanner: Shows active broadcasts with progress + * - CompletedBroadcastIndicator: Shows completed broadcast for tap-to-view (auto-dismisses after 10s) + * - BroadcastDetailsSheet: Shows detailed relay status on tap + * + * Only shown when FeatureSetType.COMPLETE is enabled. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) { + // Only show in COMPLETE UI mode + if (!accountViewModel.settings.isCompleteUIMode()) return + + val scope = rememberCoroutineScope() + val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() + + // State for completed broadcast (with auto-dismiss) + var completedBroadcast by remember { mutableStateOf(null) } + + // State for details sheet + var selectedBroadcast by remember { mutableStateOf(null) } + + // Collect completed broadcasts and set auto-dismiss timer + LaunchedEffect(Unit) { + accountViewModel.broadcastTracker.completedBroadcast.collect { broadcast -> + completedBroadcast = broadcast + + // Auto-dismiss after LONG duration + delay(BroadcastTracker.COMPLETED_DISPLAY_DURATION_MS) + + // Only dismiss if still showing same broadcast + if (completedBroadcast?.id == broadcast.id) { + completedBroadcast = null + accountViewModel.broadcastTracker.expireBroadcast(broadcast.id) + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // Banner for active broadcasts (above bottom navigation) + BroadcastBanner( + broadcasts = activeBroadcasts, + onTap = { + activeBroadcasts.firstOrNull()?.let { selectedBroadcast = it } + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 56.dp), + ) + + // Completed broadcast indicator (when no active broadcasts) + if (activeBroadcasts.isEmpty()) { + CompletedBroadcastIndicator( + broadcast = completedBroadcast, + onTap = { broadcast -> + selectedBroadcast = broadcast + }, + onRetry = { b -> + scope.launch { + accountViewModel.broadcastTracker.retry( + broadcast = b, + client = accountViewModel.account.client, + ) + } + }, + onDismiss = { broadcast -> + completedBroadcast = null + accountViewModel.broadcastTracker.expireBroadcast(broadcast.id) + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 56.dp), + ) + } + } + + // Details sheet - show all broadcasts when opened + if (selectedBroadcast != null) { + // Combine active broadcasts with the selected/completed one + val allBroadcasts = + (activeBroadcasts + listOfNotNull(completedBroadcast)) + .distinctBy { it.id } + + MultiBroadcastDetailsSheet( + broadcasts = allBroadcasts, + onDismiss = { selectedBroadcast = null }, + onRetryRelay = { b: BroadcastEvent, relay: NormalizedRelayUrl -> + scope.launch { + accountViewModel.broadcastTracker.retry( + broadcast = b, + client = accountViewModel.account.client, + specificRelay = relay, + ) + } + }, + onRetryAllFailed = { b: BroadcastEvent -> + scope.launch { + accountViewModel.broadcastTracker.retry( + broadcast = b, + client = accountViewModel.account.client, + ) + } + }, + ) + } +} + +/** + * Compact indicator for completed broadcasts. + * Styled consistently with BroadcastBanner. + * Tappable to show details sheet, with retry button for failures. + */ +@Composable +private fun CompletedBroadcastIndicator( + broadcast: BroadcastEvent?, + onTap: (BroadcastEvent) -> Unit, + onRetry: (BroadcastEvent) -> Unit, + onDismiss: (BroadcastEvent) -> Unit, + modifier: Modifier = Modifier, +) { + // Status colors (for icon tint only) + val successColor = Color(0xFF22C55E) + val warningColor = Color(0xFFF59E0B) + + AnimatedVisibility( + visible = broadcast != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + broadcast?.let { b -> + val (statusIcon, iconTint) = + when (b.status) { + BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to successColor + BroadcastStatus.PARTIAL -> Icons.Default.Error to warningColor + BroadcastStatus.FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error + BroadcastStatus.IN_PROGRESS -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary + } + + // Same styling as BroadcastBanner + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 2.dp, + modifier = + Modifier + .fillMaxWidth() + .clickable { onTap(b) }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + // Small status icon (like BroadcastBanner) + Icon( + imageVector = statusIcon, + contentDescription = b.status.name, + tint = iconTint, + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "${b.eventName} sent", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Text( + text = "[${b.successCount}/${b.totalRelays}]", + style = MaterialTheme.typography.labelMedium, + color = iconTint, + ) + } + + if (b.failedRelays.isNotEmpty()) { + Text( + text = "Tap to view details", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Retry button for failures + if (b.failedRelays.isNotEmpty()) { + IconButton( + onClick = { onRetry(b) }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Retry failed", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + + // Dismiss X + Text( + text = "×", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .clickable { onDismiss(b) } + .padding(4.dp), + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index c88f6d6fe..7e6a1cf7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -33,16 +33,37 @@ import java.io.IOException object ShareHelper { private const val TAG = "ShareHelper" - private const val DEFAULT_EXTENSION = "jpg" + private const val DEFAULT_IMAGE_EXTENSION = "jpg" + private const val DEFAULT_VIDEO_EXTENSION = "mp4" private const val SHARED_FILE_PREFIX = "shared_media" - // Media type magic numbers + data class SharableFile( + val uri: Uri, + val extension: String, + val file: File, + ) + + // Image type magic numbers private val JPEG_MAGIC = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte()) private val WEBP_HEADER_START = "RIFF".toByteArray() private val WEBP_HEADER_END = "WEBP".toByteArray() private val GIF_MAGIC = "GIF8".toByteArray() + // Video type magic numbers + // Note: WebM and MKV share the same EBML header (Matroska container) + private val WEBM_MAGIC = byteArrayOf(0x1A, 0x45, 0xDF.toByte(), 0xA3.toByte()) + private val AVI_HEADER_START = "RIFF".toByteArray() + private val AVI_HEADER_END = "AVI ".toByteArray() + private val MOV_FTYP = "ftyp".toByteArray() + private val MOV_MOOV = "moov".toByteArray() + private val MOV_MDAT = "mdat".toByteArray() + private val MOV_FREE = "free".toByteArray() + private val MP4_BRAND_ISOM = "isom".toByteArray() + private val MP4_BRAND_MP41 = "mp41".toByteArray() + private val MP4_BRAND_MP42 = "mp42".toByteArray() + private val MOV_BRAND_QT = "qt ".toByteArray() + suspend fun getSharableUriFromUrl( context: Context, imageUrl: String, @@ -64,39 +85,85 @@ object ShareHelper { } ?: throw IOException("Unable to open snapshot for: $imageUrl") } - private fun getImageExtension(file: File): String = - try { + private fun getImageExtension(file: File): String = getMediaExtension(file, isVideo = false) + + private fun getVideoExtension(file: File): String = getMediaExtension(file, isVideo = true) + + private fun getMediaExtension( + file: File, + isVideo: Boolean, + ): String { + val defaultExtension = if (isVideo) DEFAULT_VIDEO_EXTENSION else DEFAULT_IMAGE_EXTENSION + return try { FileInputStream(file).use { inputStream -> - val header = ByteArray(12) + val header = ByteArray(16) val bytesRead = inputStream.read(header) if (bytesRead < 4) { - // If we couldn't read at least 4 bytes, default to jpg - return DEFAULT_EXTENSION + return defaultExtension } - when { - // JPEG: Check first 2 bytes - matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" + if (isVideo) { + when { + // Video formats + // WebM/MKV: Check first 4 bytes (EBML header) + // Both use Matroska container; default to webm as it's more common on web + matchesMagicNumbers(header, 0, WEBM_MAGIC) -> "webm" - // PNG: Check first 4 bytes - matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png" + // AVI: Check "RIFF" (bytes 0-3) and "AVI " (bytes 8-11) + matchesMagicNumbers(header, 0, AVI_HEADER_START) && + bytesRead >= 12 && + matchesMagicNumbers(header, 8, AVI_HEADER_END) -> "avi" - // GIF: Check first 4 bytes for "GIF8" - matchesMagicNumbers(header, 0, GIF_MAGIC) -> "gif" + // MP4/MOV: Check for ftyp box (bytes 4-7 should be "ftyp") + bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header) - // WEBP: Check "RIFF" (bytes 0-3) and "WEBP" (bytes 8-11) - matchesMagicNumbers(header, 0, WEBP_HEADER_START) && - bytesRead >= 12 && - matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp" + // MP4/MOV alternative: moov, mdat, or free at offset 4 + bytesRead >= 8 && ( + matchesMagicNumbers(header, 4, MOV_MOOV) || + matchesMagicNumbers(header, 4, MOV_MDAT) || + matchesMagicNumbers(header, 4, MOV_FREE) + ) -> "mp4" - else -> DEFAULT_EXTENSION + else -> defaultExtension + } + } else { + when { + // Image formats + // JPEG: Check first 2 bytes + matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" + + // PNG: Check first 4 bytes + matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png" + + // GIF: Check first 4 bytes for "GIF8" + matchesMagicNumbers(header, 0, GIF_MAGIC) -> "gif" + + // WEBP: Check "RIFF" (bytes 0-3) and "WEBP" (bytes 8-11) + matchesMagicNumbers(header, 0, WEBP_HEADER_START) && + bytesRead >= 12 && + matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp" + + else -> defaultExtension + } } } } catch (e: IOException) { - Log.w(TAG, "Could not determine image type for ${file.name}, defaulting to $DEFAULT_EXTENSION", e) - DEFAULT_EXTENSION + Log.w(TAG, "Could not determine media type for ${file.name}, defaulting to $defaultExtension", e) + defaultExtension } + } + + private fun detectMp4OrMov(header: ByteArray): String { + // Check brand at bytes 8-11 to differentiate MP4 from MOV + return when { + matchesMagicNumbers(header, 8, MP4_BRAND_ISOM) -> "mp4" + matchesMagicNumbers(header, 8, MP4_BRAND_MP41) -> "mp4" + matchesMagicNumbers(header, 8, MP4_BRAND_MP42) -> "mp4" + matchesMagicNumbers(header, 8, MOV_BRAND_QT) -> "mov" + else -> "mp4" // Default to mp4 for unknown ftyp brands + } + } private fun matchesMagicNumbers( data: ByteArray, @@ -132,4 +199,58 @@ object ShareHelper { return sharableFile } + + suspend fun getSharableUriForLocalVideo( + context: Context, + localFile: File, + ): Pair = + withContext(Dispatchers.IO) { + val fileExtension = getVideoExtension(localFile) + val sharableFile = prepareSharableFile(context, localFile, fileExtension) + Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", sharableFile), + fileExtension, + ) + } + + fun createTempVideoFile(context: Context): File { + val timestamp = System.currentTimeMillis() + return File(context.cacheDir, "${SHARED_FILE_PREFIX}_video_$timestamp.tmp") + } + + fun prepareTempVideoForSharing( + context: Context, + tempFile: File, + ): SharableFile { + val extension = getVideoExtension(tempFile) + val timestamp = System.currentTimeMillis() + val sharableFile = File(context.cacheDir, "${SHARED_FILE_PREFIX}_$timestamp.$extension") + + try { + moveTempFileForSharing(tempFile, sharableFile) + } catch (e: Exception) { + Log.e(TAG, "Failed to rename temp video file for sharing", e) + throw e + } + + return SharableFile( + uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", sharableFile), + extension = extension, + file = sharableFile, + ) + } + + internal fun moveTempFileForSharing( + tempFile: File, + sharableFile: File, + rename: (File, File) -> Boolean = { source, dest -> source.renameTo(dest) }, + ) { + val renamed = rename(tempFile, sharableFile) + if (!renamed) { + tempFile.copyTo(sharableFile, overwrite = true) + if (!tempFile.delete()) { + Log.w(TAG, "Failed to delete temp file ${tempFile.path} after copy") + } + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 42345b014..0cac2f67d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -234,7 +234,7 @@ private fun DialogContent( contentDescription = stringRes(R.string.quick_action_share), ) - ShareImageAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }) + ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }) } if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 222898aa8..97d8e193d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size @@ -108,12 +109,25 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import okio.sink +import java.io.File +import java.io.IOException import kotlin.time.Duration.Companion.seconds +// Delay before cleaning up shared video temp files. +// Allows time for receiving app to copy the file after user confirms share. +private const val SHARED_VIDEO_CLEANUP_DELAY_MS = 120_000L + @Composable fun ZoomableContentView( content: BaseMediaContent, @@ -670,14 +684,14 @@ fun DisplayBlurHash( } @Composable -fun ShareImageAction( +fun ShareMediaAction( accountViewModel: AccountViewModel, popupExpanded: MutableState, content: BaseMediaContent, onDismiss: () -> Unit, ) { if (content is MediaUrlContent) { - ShareImageAction( + ShareMediaAction( accountViewModel = accountViewModel, popupExpanded = popupExpanded, videoUri = content.url, @@ -690,7 +704,7 @@ fun ShareImageAction( content = content, ) } else if (content is MediaPreloadedContent) { - ShareImageAction( + ShareMediaAction( accountViewModel = accountViewModel, popupExpanded = popupExpanded, videoUri = content.localFile?.toUri().toString(), @@ -707,7 +721,7 @@ fun ShareImageAction( @OptIn(ExperimentalPermissionsApi::class) @Composable -fun ShareImageAction( +fun ShareMediaAction( accountViewModel: AccountViewModel, popupExpanded: MutableState, videoUri: String?, @@ -721,9 +735,12 @@ fun ShareImageAction( ) { val scope = rememberCoroutineScope() + // Track if video is downloading - hoisted here to block menu dismiss during download + val isDownloadingVideo = remember { mutableStateOf(false) } + DropdownMenu( expanded = popupExpanded.value, - onDismissRequest = onDismiss, + onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() }, ) { val clipboardManager = LocalClipboardManager.current @@ -765,19 +782,72 @@ fun ShareImageAction( } content?.let { - if (content is MediaUrlImage) { - val context = LocalContext.current - videoUri?.let { - if (videoUri.isNotEmpty()) { + val context = LocalContext.current + + when (content) { + is MediaUrlImage -> { + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { Text(stringRes(R.string.share_image)) }, + onClick = { + scope.launch { shareImageFile(context, videoUri, mimeType) } + onDismiss() + }, + ) + } + } + } + is MediaUrlVideo -> { + videoUri?.let { + if (videoUri.isNotEmpty()) { + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringRes(R.string.share_video)) + if (isDownloadingVideo.value) { + Spacer(modifier = Modifier.width(8.dp)) + LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp) + } + } + }, + enabled = !isDownloadingVideo.value, + onClick = { + isDownloadingVideo.value = true + scope.launch { + shareVideoFile( + context = context, + videoUrl = videoUri, + mimeType = mimeType, + okHttpClient = { url -> + accountViewModel.httpClientBuilder.okHttpClientForVideo(url) + }, + onComplete = { + isDownloadingVideo.value = false + onDismiss() + }, + onError = { + isDownloadingVideo.value = false + }, + ) + } + }, + ) + } + } + } + is MediaLocalVideo -> { + content.localFile?.let { localFile -> DropdownMenuItem( - text = { Text(stringRes(R.string.share_image)) }, + text = { Text(stringRes(R.string.share_video)) }, onClick = { - scope.launch { shareImageFile(context, videoUri, mimeType) } + scope.launch { shareLocalVideoFile(context, localFile, mimeType) } onDismiss() }, ) } } + else -> { /* No share option for other types */ } } } } @@ -810,6 +880,128 @@ private suspend fun shareImageFile( } } +@OptIn(DelicateCoroutinesApi::class) +private suspend fun shareVideoFile( + context: Context, + videoUrl: String, + mimeType: String?, + okHttpClient: (String) -> OkHttpClient, + onComplete: () -> Unit, + onError: () -> Unit, +) { + val tempFile = ShareHelper.createTempVideoFile(context) + var sharedFile: File? = null + try { + withContext(Dispatchers.IO) { + // Download video using streaming + val client = okHttpClient(videoUrl) + val request = + Request + .Builder() + .get() + .url(videoUrl) + .build() + + client.newCall(request).executeAsync().use { response -> + check(response.isSuccessful) { "Download failed: ${response.code}" } + val responseBody = response.body + + // Stream the response to the temp file + tempFile.outputStream().use { outputStream -> + val bytesCopied = responseBody.source().readAll(outputStream.sink()) + if (bytesCopied == 0L) { + throw IOException("Download failed: empty response body") + } + } + } + + // Prepare the temp file for sharing (determines extension and creates sharable URI) + val (uri, extension, sharableFile) = ShareHelper.prepareTempVideoForSharing(context, tempFile) + sharedFile = sharableFile + + // Determine mime type + val determinedMimeType = mimeType ?: "video/$extension" + + // Create share intent + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = determinedMimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + withContext(Dispatchers.Main) { + context.startActivity(Intent.createChooser(shareIntent, null)) + } + } + + // Schedule cleanup to allow the receiving app time to copy the file. + // GlobalScope is intentional: cleanup must survive after share UI is dismissed. + GlobalScope.launch(Dispatchers.IO) { + delay(SHARED_VIDEO_CLEANUP_DELAY_MS) + sharedFile?.delete() + } + + withContext(Dispatchers.Main) { + onComplete() + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("ZoomableContentView", "Failed to share video: $videoUrl", e) + + // Clean up temp file on error + tempFile.delete() + sharedFile?.delete() + + withContext(Dispatchers.Main) { + Toast + .makeText( + context, + context.getString(R.string.unable_to_share_video), + Toast.LENGTH_SHORT, + ).show() + onError() + } + } +} + +private suspend fun shareLocalVideoFile( + context: Context, + localFile: File, + mimeType: String?, +) { + try { + withContext(Dispatchers.IO) { + // Get sharable URI for the local file + val (uri, extension) = ShareHelper.getSharableUriForLocalVideo(context, localFile) + + // Determine mime type + val determinedMimeType = mimeType ?: "video/$extension" + + // Create share intent + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = determinedMimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + withContext(Dispatchers.Main) { + context.startActivity(Intent.createChooser(shareIntent, null)) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("ZoomableContentView", "Failed to share local video: ${localFile.path}", e) + Toast + .makeText( + context, + context.getString(R.string.unable_to_share_video), + Toast.LENGTH_SHORT, + ).show() + } +} + private fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt index 72f1e65b8..c4406d235 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.dal +import com.vitorpamplona.amethyst.commons.ui.notifications.Card import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.Card val DefaultFeedOrder: Comparator = compareByDescending { it.createdAt() }.thenBy { it.idHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 9266e299e..6cad3e11a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen +import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @@ -310,6 +311,7 @@ fun AppNavigation( DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) DisplayNotifyMessages(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) + DisplayBroadcastProgress(accountViewModel) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a2814a464..35beac436 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator +import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox @@ -414,7 +415,9 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists( ) { val hasReactions by observeNoteReferences(baseNote, accountViewModel) - if (hasReactions) { + val hasZapraiser = (baseNote.event?.zapraiserAmount() ?: 0) > 0 + + if (hasReactions || hasZapraiser) { content() } } @@ -635,6 +638,7 @@ fun ReplyViaVoiceReaction( ) } }, + maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, ) { isRecording, elapsedSeconds -> if (voiceRecordingState != null) { SideEffect { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt index 557e26c46..2b4bb2fe2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import android.R.attr.label -import android.R.attr.maxLines import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index fffbe41a2..68f8b18bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -77,7 +79,6 @@ import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.UiSettingsState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @@ -168,6 +169,7 @@ class AccountViewModel( var firstRoute: Route? = null val toastManager = ToastManager() + val broadcastTracker = BroadcastTracker() val feedStates = AccountFeedContentStates(account, viewModelScope) val tempManualPaymentCache = LruCache>(5) @@ -315,7 +317,25 @@ class AccountViewModel( if (currentReactions.isNotEmpty()) { account.delete(currentReactions) } else { - account.reactTo(note, reaction) + if (settings.isCompleteUIMode()) { + // Tracked broadcasting with progress feedback + account.createReactionEvent(note, reaction)?.let { (event, relays) -> + val result = + broadcastTracker.trackBroadcast( + event = event, + eventName = "Reaction", + relays = relays, + client = account.client, + ) + // Only consume event if at least one relay succeeded + if (result.isSuccess) { + account.consumeReactionEvent(event) + } + } + } else { + // Fire-and-forget (original behavior) + account.reactTo(note, reaction) + } } } } @@ -709,7 +729,29 @@ class AccountViewModel( } } - fun boost(note: Note) = launchSigner { account.boost(note) } + fun boost(note: Note) { + if (settings.isCompleteUIMode()) { + // Tracked broadcasting with progress feedback + launchSigner { + account.createBoostEvent(note)?.let { (event, relays) -> + val result = + broadcastTracker.trackBroadcast( + event = event, + eventName = "Boost", + relays = relays, + client = account.client, + ) + // Only consume event if at least one relay succeeded + if (result.isSuccess) { + account.consumeBoostEvent(event) + } + } + } + } else { + // Fire-and-forget (original behavior) + launchSigner { account.boost(note) } + } + } fun removeEmojiPack(emojiPack: Note) = launchSigner { account.removeEmojiPack(emojiPack) } @@ -731,13 +773,89 @@ class AccountViewModel( fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) - fun addPrivateBookmark(note: Note) = launchSigner { account.addBookmark(note, true) } + fun addPrivateBookmark(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createAddBookmarkEvent(note, true)?.let { (event, relays) -> + val result = + broadcastTracker.trackBroadcast( + event = event, + eventName = "Bookmark", + relays = relays, + client = account.client, + ) + if (result.isSuccess) { + account.consumeBookmarkEvent(event) + } + } + } + } else { + launchSigner { account.addBookmark(note, true) } + } + } - fun addPublicBookmark(note: Note) = launchSigner { account.addBookmark(note, false) } + fun addPublicBookmark(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createAddBookmarkEvent(note, false)?.let { (event, relays) -> + val result = + broadcastTracker.trackBroadcast( + event = event, + eventName = "Bookmark", + relays = relays, + client = account.client, + ) + if (result.isSuccess) { + account.consumeBookmarkEvent(event) + } + } + } + } else { + launchSigner { account.addBookmark(note, false) } + } + } - fun removePrivateBookmark(note: Note) = launchSigner { account.removeBookmark(note, true) } + fun removePrivateBookmark(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createRemoveBookmarkEvent(note, true)?.let { (event, relays) -> + val result = + broadcastTracker.trackBroadcast( + event = event, + eventName = "Remove Bookmark", + relays = relays, + client = account.client, + ) + if (result.isSuccess) { + account.consumeBookmarkEvent(event) + } + } + } + } else { + launchSigner { account.removeBookmark(note, true) } + } + } - fun removePublicBookmark(note: Note) = launchSigner { account.removeBookmark(note, false) } + fun removePublicBookmark(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createRemoveBookmarkEvent(note, false)?.let { (event, relays) -> + val result = + broadcastTracker.trackBroadcast( + event = event, + eventName = "Remove Bookmark", + relays = relays, + client = account.client, + ) + if (result.isSuccess) { + account.consumeBookmarkEvent(event) + } + } + } + } else { + launchSigner { account.removeBookmark(note, false) } + } + } fun broadcast(note: Note) = launchSigner { account.broadcast(note) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 96ef67020..41fd483a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt index bb98b4d51..31249fafd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt index 91d249db6..60495011c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelFromUserFilterSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelPublicFilterSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index e9ff59571..6ba41d901 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 79174b783..a8be570de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt index 9c041e289..6104739fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt index 2007746cc..5ddb60842 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt index 531a781bd..2a49e068a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt index ed4f4f3f1..c3d2f1fdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription @Composable fun CommunityFilterAssemblerSubscription( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt index 9d9f83089..d5048b017 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt index 5a2a10b0c..d34fdd0f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt index ef3f1a91a..f11ea2a23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt index c30204f15..7b18453a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasourc import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt index 80681a5f2..c7b018856 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.filterHomePostsByAuthors import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt index 8b80a73b5..ef29b850b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt index 49bbf8ef1..3e3cf3307 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt index 09b7c405c..a64e66fc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt index 1bf1a8387..e1f882f2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 4318f13c8..99f6d42e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -374,6 +375,8 @@ private fun NewPostScreenBody( VoiceMessagePreview( voiceMetadata = displayMetadata, localFile = postViewModel.activeFile, + onReRecord = { recording -> postViewModel.selectVoiceRecording(recording) }, + isUploading = postViewModel.isUploadingVoice, onRemove = { postViewModel.removeVoiceMessage() }, ) @@ -497,6 +500,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { onVoiceTaken = { recording -> postViewModel.selectVoiceRecording(recording) }, + maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, ) if (postViewModel.canUsePoll) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 90ee62b3a..984bcbfd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -97,6 +97,7 @@ import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag @@ -544,12 +545,48 @@ open class ShortNotePostViewModel : val version = draftTag.current cancel() - accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + if (accountViewModel.settings.isCompleteUIMode()) { + // Tracked broadcasting with progress feedback (non-blocking) + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) + val eventName = getEventName(event) + + // Launch broadcast in background - don't wait for completion + accountViewModel.viewModelScope.launch { + val result = + accountViewModel.broadcastTracker.trackBroadcast( + event = event, + eventName = eventName, + relays = relays, + client = accountViewModel.account.client, + ) + + // Only consume event if at least one relay succeeded + if (result.isSuccess) { + accountViewModel.account.consumePostEvent(event, relays, extras) + } + } + } else { + // Fire-and-forget (original behavior) + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + } + accountViewModel.launchSigner { accountViewModel.account.deleteDraftIgnoreErrors(version) } } + private fun getEventName(event: Event): String = + when (event) { + is TextNoteEvent -> { + val quotes = event.taggedQuotes() + if (quotes.isNotEmpty()) "Quote" else "Post" + } + is PollNoteEvent -> "Poll" + is VoiceEvent -> "Voice" + is VoiceReplyEvent -> "Voice Reply" + else -> "Post" + } + suspend fun sendDraftSync() { if (message.text.isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index a2573663b..5baff1a94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -21,46 +21,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.activity.compose.BackHandler -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.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -103,9 +89,6 @@ fun VoiceReplyScreen( }, ) }, - bottomBar = { - ReRecordButton(viewModel) - }, ) { pad -> Surface( modifier = @@ -161,6 +144,8 @@ private fun VoiceReplyScreenBody( VoiceMessagePreview( voiceMetadata = displayMetadata, localFile = viewModel.activeFile, + onReRecord = { recording -> viewModel.selectRecording(recording) }, + isUploading = viewModel.isUploading, onRemove = { viewModel.cancel() nav.popBack() @@ -192,80 +177,3 @@ private fun VoiceReplyScreenBody( Spacer(modifier = Modifier.height(80.dp)) } } - -@Composable -private fun ReRecordButton(viewModel: VoiceReplyViewModel) { - Column( - modifier = - Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(vertical = 16.dp, horizontal = Size10dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (viewModel.isUploading) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = stringRes(id = R.string.record_a_message), - tint = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringRes(id = R.string.re_record), - color = MaterialTheme.colorScheme.onBackground, - ) - } - return - } - - RecordAudioBox( - modifier = Modifier, - onRecordTaken = { recording -> - viewModel.selectRecording(recording) - }, - ) { isRecording, elapsedSeconds -> - val contentColor = - if (isRecording) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onBackground - } - val icon = - if (isRecording) { - Icons.Default.Stop - } else { - Icons.Default.Mic - } - val label = - if (isRecording) { - formatSecondsToTime(elapsedSeconds) - } else { - stringRes(id = R.string.re_record) - } - val iconDescription = - if (isRecording) { - stringRes(id = R.string.recording_indicator_description) - } else { - stringRes(id = R.string.record_a_message) - } - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = icon, - contentDescription = iconDescription, - tint = contentColor, - ) - Text( - text = label, - color = contentColor, - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt index 4b749ce55..99e2ef0cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxEventsEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt index 34c15b570..21eda3d9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index db70c3229..eb0fd60a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -26,6 +26,8 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState +import com.vitorpamplona.amethyst.commons.ui.notifications.Card +import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt index 7f696f9bf..58c7278b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt @@ -21,8 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState +import com.vitorpamplona.amethyst.commons.ui.notifications.Card import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji @@ -30,14 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.flow.MutableStateFlow - -@Immutable -interface Card { - fun createdAt(): Long - - fun id(): String -} @Immutable class BadgeCard( @@ -113,19 +104,3 @@ class MessageSetCard( override fun id() = note.idHex } - -@Immutable -sealed class CardFeedState { - @Immutable object Loading : CardFeedState() - - @Stable - class Loaded( - val feed: MutableStateFlow>, - ) : CardFeedState() - - @Immutable object Empty : CardFeedState() - - @Immutable class FeedError( - val errorMessage: String, - ) : CardFeedState() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index bffecaa20..df5ee3349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -43,6 +43,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.notifications.Card +import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedError diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt index 5a217020b..3f7b17701 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt index 94af9e02b..deecd3e27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription @Composable fun UserProfileFilterAssemblerSubscription( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt index 07c905301..9d3545f79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt index e389d4501..cce418cd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadFilterSubAssembler import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt index 7954bda88..5360d95c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt index 435189d58..8f4c6b9e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoOutboxEventsFilterSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt index 691cb8dbb..a0dffc8d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index e97ff5381..06a21b6cd 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1197,6 +1197,9 @@ Relé, ke kterému se všichni uživatelé tohoto chatu připojují Sdílet obrázek… Nelze sdílet obrázek, zkuste to prosím později… + Sdílet video… + Video nelze sdílet, zkuste to prosím později… + Stahování videa… Vyhledávání hashtag: #%1$s Nepřekládat z Zde zobrazené jazyky nebudou přeloženy. Vyberte jazyk, který chcete odstranit a nechat je znovu přeložit. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index e2cfb90cb..9015e0965 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -177,6 +177,7 @@ erie gespeichert Keine Tief Hoch + Neutral Anonymisieren Verändert die Tonhöhe deiner Stimme. Hinweis: einfache Tonhöhenänderungen können von entschlossenen Zuhörern möglicherweise rückgängig gemacht werden. Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index bbdb00c1e..5ca190f7b 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -135,6 +135,8 @@ Ajouter un utilisateur Ajouter un relais Nom + Nom (pour @tagging) + Mon nom @tag Nom visible du public Mon nom visible du public Ostrich McGénial @@ -170,6 +172,12 @@ Erreur lors de l\'envoi NIP-95 n\'est pas encore supporté pour les messages vocaux Échec de l\'envoi de la voix : %1$s + Aucun + Profond + Haute + Neutre + Anonymisation + Modifie votre niveau de voix. Remarque : les changements de hauteur de base peuvent potentiellement être inversés par des auditeurs déterminés. L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats "répondre ici" Copie l\'ID de note dans le presse-papiers pour le partage sur Nostr @@ -364,6 +372,7 @@ Division manuelle de zaps Favoris Signets par défaut + Vos favoris par défaut que de nombreux clients prennent en charge Brouillons Favoris Privés Favoris Publics @@ -371,6 +380,40 @@ Ajouter aux Favoris Publics Supprimer des Favoris Privés Supprimer des Favoris Publics + Listes de favoris + Icône pour la liste de favoris + Nouvelle liste de favoris + Métadonnées de la liste de favoris + Dupliquer la liste de favoris + Diffuser la liste de favoris + Supprimer la liste de favoris + Voir les publications + Voir les articles + Voir les liens + Voir les hashtags + Vous n\'avez pas encore de liste de favoris. Appuyez sur le bouton ci-dessous pour en créer une. + Publications privées + Publications privées (%1$s) + Publications publiques + Publications publiques (%1$s) + Articles privés + Articles privés (%1$s) + Articles publics + Articles publics (%1$s) + Gérer %1$s dans les listes de favoris + Ajouter cette publication aux favoris + Ajouter cet article aux favoris + est un favori public ici + est un favori privé ici + n\'est pas un favori ici + Supprimer le favori de la liste + Ajouter un favori à la liste + Ajouter comme favori public + Ajouter comme favori privé + Supprimer de la liste de favoris + Les métadonnées des listes de favoris peuvent être vues par n\'importe qui sur Nostr. Seuls vos membres privés sont chiffrés. + Déplacer en public + Déplacer en privé Service Wallet Connect Autorise un Nostr Secret à payer des zaps sans quitter l\'application. Gardez le secret en toute sécurité et utilisez un relais privé si possible Clé publique Wallet Connect @@ -604,12 +647,23 @@ Contact NIPs supportés Frais d\'admission + Publication + Paiements %1$s URL de payments + Audience cible + Politiques & liens + Frais & paiements Limites Pays Langages Étiquettes + Sujets + Tous les pays + Toutes les langues Politique de publication + Politique de confidentialité + Termes & Conditions + N/A Erreurs et Notifications de ce Relais Longueur du message Abonnements @@ -618,9 +672,19 @@ Préfixe minimum Tags d\'événement maximum Longueur du contenu + Longueur maximale du contenu + Rejeter plus anciens que + Accepte jusqu\'à + %1$s dans le futur + Il y a %1$s + Taille du contenu + Connectivité + Contrôle d\'accès PoW minimum Authentification + Authentification requise Paiement + Paiement requis Jeton Cashu Échanger Envoyer vers le portefeuille Zap diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 8fff23237..eeb0dfa93 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -172,6 +172,12 @@ अनपेक्षित आरोहण स्थिति निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए ध्वनि आरोहण असफल : %1$s + कुछ भी नहीं + गहन + उच्च + मध्यम + अनामीकरण + आपके स्वर का परिवर्तन करता है। ध्यातव्य। आधारभूत स्वर परिवर्तन को पूर्ववत किया जा सकता है दृढ निश्चित श्रोताओं द्वारा। उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके "उत्तर यहाँ दें.. " नोस्ट्र में बाँटने के लिए टीका विभेदक की अनुकृति करता है टाँकाफलक में @@ -1232,4 +1238,5 @@ पोटली प्रसारण सूची मिटाएँ पोटली मिटाएँ + प्रकार diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index d6716c99c..dece24a0f 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -172,6 +172,12 @@ Váratlan feltöltési állapot A NIP-95 még nem támogatja a hangüzeneteket Nem sikerült feltölteni a hangüzenetet: %1$s + Semmi + Mély + Magas + Semleges + Névtelenítés + Módosítja az Ön hangmagasságát. Megjegyzés: az alapvető hangmagasság-változásokat a figyelmes hallgatók visszafordíthatják. A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel "Válasz írása… " A bejegyzés azonosítóját a vágólapra másolja a Nostr-ban való megosztáshoz @@ -697,7 +703,7 @@ Tartalom mérete Kapcsolat Hozzáférés-vezérlés - Spam-szűrés erőssége + Kéretlen tartalmak szűrésének erőssége Hitelesítés Hitelesítés szükséges Fizetés diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 88b468e25..eaf7cce96 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -721,7 +721,7 @@ Skopiowano token do schowka NA ŻYWO OFFLINE - Zakończony + Zakończono ZAPLANOWANE Transmisja wyłączona Transmisja na żywo zakończona @@ -733,7 +733,7 @@ Popularne Wybrane przez algorytm Market - Transmisja na żywo + Na żywo Społeczności Czaty Zatwierdzone posty diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 6cbc2e7d5..7f427f586 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -172,6 +172,12 @@ 未知的上传状态 语音消息尚不支持 NIP-95 语音上传失败:%1$s + + 深沉 + 高音 + 中性 + 匿名化 + 更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。 用户尚未设置闪电地址以接收聪 "🔏在此回复… " 复制笔记ID到剪贴板,供分享 @@ -1232,4 +1238,5 @@ 广播包 删除列表 删除包 + 类型 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a78c01f31..b93fa4d57 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1426,6 +1426,9 @@ The relay that all users of this chat connect to Share image… Unable to share image, please try again later… + Share video… + Unable to share video, please try again later… + Downloading video… Search hashtag: #%1$s diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt new file mode 100644 index 000000000..fbc1a29da --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class ShareHelperTest { + @Test + fun moveTempFileForSharing_renameFails_copiesAndDeletesSource() { + val tempDir = Files.createTempDirectory("sharehelpertest").toFile() + val tempFile = File(tempDir, "video.tmp") + val content = "test-content" + tempFile.writeText(content) + val targetFile = File(tempDir, "shared.mp4") + + ShareHelper.moveTempFileForSharing( + tempFile, + targetFile, + rename = { _, _ -> false }, + ) + + assertTrue(targetFile.exists()) + assertEquals(content, targetFile.readText()) + assertFalse(tempFile.exists()) + + targetFile.delete() + tempDir.delete() + } +} diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index af8cceeac..bf482725f 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -5,7 +5,6 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.mokoResources) } android { @@ -81,9 +80,8 @@ kotlin { // Immutable collections api(libs.kotlinx.collections.immutable) - // Moko Resources for KMP string resources - api(libs.moko.resources) - api(libs.moko.resources.compose) + // Compose Multiplatform Resources + implementation(compose.components.resources) } } @@ -141,7 +139,8 @@ kotlin { } } -multiplatformResources { - resourcesPackage.set("com.vitorpamplona.amethyst.commons") - resourcesClassName.set("SharedRes") +compose.resources { + publicResClass = true + packageOfResClass = "com.vitorpamplona.amethyst.commons.resources" + generateResClass = always } diff --git a/commons/src/commonMain/moko-resources/base/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml similarity index 100% rename from commons/src/commonMain/moko-resources/base/strings.xml rename to commons/src/commonMain/composeResources/values/strings.xml diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt new file mode 100644 index 000000000..368c22e12 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.icons + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Preview +@Composable +private fun VectorPreview() { + Image(Bookmark, null) +} + +@Preview +@Composable +private fun FilledPreview() { + Image(BookmarkFilled, null) +} + +private var bookmark: ImageVector? = null +private var bookmarkFilled: ImageVector? = null + +val Bookmark: ImageVector + get() = + bookmark ?: ImageVector + .Builder( + name = "Bookmark", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + // Material bookmark outline + moveTo(17.0f, 3.0f) + horizontalLineTo(7.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(16.0f) + lineToRelative(7.0f, -3.0f) + lineToRelative(7.0f, 3.0f) + verticalLineTo(5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + moveTo(17.0f, 18.0f) + lineToRelative(-5.0f, -2.18f) + lineTo(7.0f, 18.0f) + verticalLineTo(5.0f) + horizontalLineToRelative(10.0f) + verticalLineToRelative(13.0f) + close() + } + }.build() + .also { bookmark = it } + +val BookmarkFilled: ImageVector + get() = + bookmarkFilled ?: ImageVector + .Builder( + name = "BookmarkFilled", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + // Material bookmark filled + moveTo(17.0f, 3.0f) + horizontalLineTo(7.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(16.0f) + lineToRelative(7.0f, -3.0f) + lineToRelative(7.0f, 3.0f) + verticalLineTo(5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + } + }.build() + .also { bookmarkFilled = it } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt index 721fdc6f8..bd87690ba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt @@ -112,5 +112,27 @@ interface ICacheProvider { */ fun hasBeenDeleted(event: Any): Boolean + /** + * Finds users whose name, displayName, nip05, or lud16 starts with the given prefix. + * Used by search functionality to find users by name. + * + * @param prefix The search prefix to match against user names + * @param limit Maximum number of results to return + * @return List of Users matching the prefix + */ + fun findUsersStartingWith( + prefix: String, + limit: Int = 50, + ): List = emptyList() + + /** + * Gets or creates a User by public key hex. + * Used when processing events that reference users. + * + * @param pubkey The user's public key in hex format + * @return The User (existing or newly created) + */ + fun getOrCreateUser(pubkey: HexKey): Any? + fun justConsumeMyOwnEvent(event: Event): Boolean } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 5485bf5bf..dbf99854d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import java.lang.ref.WeakReference -import kotlin.collections.plus @Stable class Chatroom : NotesGatherer { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt new file mode 100644 index 000000000..87ab2a074 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -0,0 +1,221 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.assemblers + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.PrioritizedSubscriptionQueue +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubscriptionPriority +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.CoroutineScope + +/** + * Coordinates metadata and reactions loading for feed items. + * Ensures metadata (display names, avatars) loads before reactions. + * + * Priority order: + * 1. METADATA - Display names, avatars (highest priority) + * 2. REACTIONS - Likes, zaps, reposts (second priority) + * + * Usage: + * ``` + * val coordinator = FeedMetadataCoordinator(client, scope, indexRelays, preloader) + * coordinator.start() + * + * // When feed loads new notes + * LaunchedEffect(notes) { + * coordinator.loadMetadataForNotes(notes) + * } + * ``` + */ +class FeedMetadataCoordinator( + private val client: INostrClient, + private val scope: CoroutineScope, + private val indexRelays: Set, + private val preloader: MetadataPreloader? = null, + private val onEvent: ((Event, NormalizedRelayUrl) -> Unit)? = null, +) { + private val priorityQueue = PrioritizedSubscriptionQueue(scope) + + // Track what we've already queued to avoid duplicates + private val queuedPubkeys = mutableSetOf() + private val queuedNoteIds = mutableSetOf() + + /** + * Start processing the subscription queue. + * Call once when coordinator is created. + */ + fun start() { + priorityQueue.start { filter -> + // Convert filter to relay-based map for all index relays + val filterMap = indexRelays.associateWith { listOf(filter) } + + // Create listener to pass events to the callback + val listener = + if (onEvent != null) { + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onEvent.invoke(event, relay) + } + } + } else { + null + } + + client.openReqSubscription( + subId = newSubId(), + filters = filterMap, + listener = listener, + ) + } + } + + /** + * Load metadata and reactions for a list of notes. + * Metadata loads first (priority 1), then reactions (priority 2). + * + * @param notes The notes to load metadata/reactions for + */ + fun loadMetadataForNotes(notes: List) { + if (notes.isEmpty()) return + + // Extract unique authors that we haven't already queued + val authors = + notes + .mapNotNull { it.author?.pubkeyHex } + .filter { it !in queuedPubkeys } + .distinct() + + // Extract unique note IDs that we haven't already queued + val noteIds = + notes + .map { it.idHex } + .filter { it !in queuedNoteIds } + .distinct() + + // Queue metadata first (highest priority) + if (authors.isNotEmpty()) { + queuedPubkeys.addAll(authors) + + // Use preloader if available for rate-limited loading + if (preloader != null) { + notes.mapNotNull { it.author }.forEach { user -> + preloader.preloadForUser(user) + } + } else { + // Direct queue without rate limiting + val metadataFilter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = authors, + limit = authors.size, + ) + priorityQueue.enqueue( + SubscriptionPriority.METADATA, + metadataFilter, + tag = "feed-metadata", + ) + } + } + + // Queue reactions second (lower priority) + if (noteIds.isNotEmpty()) { + queuedNoteIds.addAll(noteIds) + + val reactionsFilter = + Filter( + kinds = listOf(ReactionEvent.KIND), + tags = mapOf("e" to noteIds), + ) + priorityQueue.enqueue( + SubscriptionPriority.REACTIONS, + reactionsFilter, + tag = "feed-reactions", + ) + } + } + + /** + * Load metadata for specific pubkeys. + * Useful for loading follower/following metadata. + */ + fun loadMetadataForPubkeys(pubkeys: List) { + val newPubkeys = pubkeys.filter { it !in queuedPubkeys } + if (newPubkeys.isEmpty()) return + + queuedPubkeys.addAll(newPubkeys) + + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = newPubkeys, + limit = newPubkeys.size, + ) + priorityQueue.enqueue( + SubscriptionPriority.METADATA, + filter, + tag = "pubkey-metadata", + ) + } + + /** + * Load reactions for specific note IDs. + */ + fun loadReactionsForNotes(noteIds: List) { + val newNoteIds = noteIds.filter { it !in queuedNoteIds } + if (newNoteIds.isEmpty()) return + + queuedNoteIds.addAll(newNoteIds) + + val filter = + Filter( + kinds = listOf(ReactionEvent.KIND), + tags = mapOf("e" to newNoteIds), + ) + priorityQueue.enqueue( + SubscriptionPriority.REACTIONS, + filter, + tag = "note-reactions", + ) + } + + /** + * Clear queued items. Call when switching feeds. + */ + fun clear() { + priorityQueue.clear() + queuedPubkeys.clear() + queuedNoteIds.clear() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt new file mode 100644 index 000000000..fa148168a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.assemblers + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Query state for metadata subscriptions. + * Groups pubkeys with their preferred index relays. + */ +data class MetadataQueryState( + val pubkeys: Set, + val indexRelays: Set, +) + +/** + * Subscribes to Kind 0 (user metadata) for a set of pubkeys. + * Used to load display names, avatars, and other profile information. + * + * This assembler: + * - Batches multiple pubkey requests into single subscription + * - Sends requests to index relays for efficient discovery + * - Caches EOSE to avoid re-fetching known metadata + */ +class MetadataFilterAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun distinct(key: MetadataQueryState): Any = key.pubkeys.hashCode() + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + // Collect all pubkeys and relays from all query states + val allPubkeys = mutableSetOf() + val allRelays = mutableSetOf() + + keys.forEach { state -> + allPubkeys.addAll(state.pubkeys) + allRelays.addAll(state.indexRelays) + } + + if (allPubkeys.isEmpty() || allRelays.isEmpty()) return null + + val pubkeyList = allPubkeys.toList() + + // Create filter for metadata (Kind 0) + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = pubkeyList, + limit = pubkeyList.size, + ) + + // Apply since times per relay + return allRelays.map { relay -> + val sinceTime = since?.get(relay)?.time + val filterWithSince = + if (sinceTime != null) { + filter.copy(since = sinceTime) + } else { + filter + } + RelayBasedFilter(relay, filterWithSince) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt new file mode 100644 index 000000000..213b4e34d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.assemblers + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + +/** + * Query state for reactions subscriptions. + * Groups note IDs with their preferred relays. + */ +data class ReactionsQueryState( + val noteIds: Set, + val relays: Set, +) + +/** + * Subscribes to Kind 7 (reactions) for a set of note IDs. + * Used to load like counts, zap counts, and other reactions. + * + * This assembler: + * - Batches multiple note ID requests into single subscription + * - Uses e-tags to filter reactions for specific notes + * - Caches EOSE to avoid re-fetching known reactions + */ +class ReactionsFilterAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun distinct(key: ReactionsQueryState): Any = key.noteIds.hashCode() + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + // Collect all note IDs and relays from all query states + val allNoteIds = mutableSetOf() + val allRelays = mutableSetOf() + + keys.forEach { state -> + allNoteIds.addAll(state.noteIds) + allRelays.addAll(state.relays) + } + + if (allNoteIds.isEmpty() || allRelays.isEmpty()) return null + + val noteIdList = allNoteIds.toList() + + // Create filter for reactions (Kind 7) targeting these notes via e-tags + val filter = + Filter( + kinds = listOf(ReactionEvent.KIND), + tags = mapOf("e" to noteIdList), + ) + + // Apply since times per relay + return allRelays.map { relay -> + val sinceTime = since?.get(relay)?.time + val filterWithSince = + if (sinceTime != null) { + filter.copy(since = sinceTime) + } else { + filter + } + RelayBasedFilter(relay, filterWithSince) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index 4c9bb3f66..e6c6c8202 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers +package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import java.util.concurrent.ConcurrentHashMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt index f26b1752d..7ee190b8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers +package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers interface ComposeSubscriptionManagerControls { fun invalidateKeys() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index 9966f901d..aa890e82b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers +package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index 970e79779..7e5322fef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers -import com.vitorpamplona.amethyst.isDebug -import com.vitorpamplona.amethyst.service.BundledUpdate +import com.vitorpamplona.amethyst.commons.service.BundledUpdate +import com.vitorpamplona.amethyst.commons.utils.isDebug import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId @@ -29,18 +29,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscriptio import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers -interface IEoseManager { - fun invalidateFilters(ignoreIfDoing: Boolean = false) - - fun destroy() -} - abstract class BaseEoseManager( val client: INostrClient, val allKeys: () -> Set, val sampleTime: Long = 500, ) : IEoseManager { - protected val logTag: String = this.javaClass.simpleName + protected val logTag: String = this::class.simpleName ?: "BaseEoseManager" private val orchestrator = SubscriptionController(client) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/IEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/IEoseManager.kt new file mode 100644 index 000000000..787e9a64c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/IEoseManager.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers + +interface IEoseManager { + fun invalidateFilters(ignoreIfDoing: Boolean = false) + + fun destroy() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt new file mode 100644 index 000000000..4768f18c8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt @@ -0,0 +1,162 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.commons.relays.EOSECache +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Generic per-key EOSE manager that creates a subscription for each unique key. + * + * This query type creates a new relay subscription for every distinct key K + * extracted from query state T. It is ideal for screens that need separate + * subscriptions per entity (user, thread, etc.). + * + * @param T The query state type (e.g., ThreadQueryState) + * @param K The key type used to deduplicate subscriptions (e.g., String noteId) + * + * This class keeps EOSEs for each key for as long as possible and can be + * shared among multiple query states that map to the same key. + */ +abstract class PerKeyEoseManager( + client: INostrClient, + allKeys: () -> Set, + val invalidateAfterEose: Boolean = false, + cacheSize: Int = 200, +) : BaseEoseManager(client, allKeys) { + // EOSE cache keyed by K + private val latestEOSEs = EOSECache(cacheSize) + + // Map from key K to subscription ID + private val keySubscriptionMap = mutableMapOf() + + /** + * Get the since map for a query state's key. + */ + fun since(queryState: T): SincePerRelayMap? = latestEOSEs.since(extractKey(queryState)) + + /** + * Record a new EOSE for a query state. + */ + open fun newEose( + queryState: T, + relayUrl: NormalizedRelayUrl, + time: Long, + filters: List? = null, + ) { + latestEOSEs.newEose(extractKey(queryState), relayUrl, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } + + /** + * Create a new subscription for a query state. + */ + open fun newSub(queryState: T): Subscription = + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(queryState, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(queryState, relay, TimeUtils.now(), forFilters) + } + } + }, + ) + + /** + * End a subscription for a key. + */ + open fun endSub( + key: K, + subId: String, + ) { + dismissSubscription(subId) + keySubscriptionMap.remove(key) + } + + /** + * Find or create a subscription for a query state. + */ + fun findOrCreateSubFor(queryState: T): Subscription { + val key = extractKey(queryState) + val subId = keySubscriptionMap[key] + return if (subId == null) { + newSub(queryState).also { keySubscriptionMap[key] = it.id } + } else { + getSubscription(subId) ?: newSub(queryState).also { keySubscriptionMap[key] = it.id } + } + } + + override fun updateSubscriptions(keys: Set) { + val uniqueByKey = keys.distinctBy { extractKey(it) } + + val updatedKeys = mutableSetOf() + + uniqueByKey.forEach { queryState -> + val key = extractKey(queryState) + val newFilters = updateFilter(queryState, since(queryState))?.ifEmpty { null } + findOrCreateSubFor(queryState).updateFilters(newFilters?.groupByRelay()) + updatedKeys.add(key) + } + + // Clean up subscriptions for keys no longer active + keySubscriptionMap.filter { it.key !in updatedKeys }.forEach { + endSub(it.key, it.value) + } + } + + /** + * Build filters for a query state. + * @return List of relay-based filters, or null to clear the subscription + */ + abstract fun updateFilter( + queryState: T, + since: SincePerRelayMap?, + ): List? + + /** + * Extract the deduplication key from a query state. + * Subscriptions are shared among query states with the same key. + */ + abstract fun extractKey(queryState: T): K +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt index 795af8564..04ab7376c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers -import com.vitorpamplona.amethyst.service.relays.EOSERelayList -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relays.EOSERelayList +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataPreloader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataPreloader.kt new file mode 100644 index 000000000..14771a607 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataPreloader.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.preload + +import com.vitorpamplona.amethyst.commons.model.User + +/** + * Preloads user metadata and avatar images for feed items. + * Coordinates between metadata subscription (rate-limited) and image preloading. + * + * Priority order: + * 1. Metadata (display names, avatar URLs) - FIRST + * 2. Avatar images (prefetch when metadata arrives) - SECOND + */ +class MetadataPreloader( + private val rateLimiter: MetadataRateLimiter, + private val imagePrefetcher: ImagePrefetcher? = null, +) { + /** + * Queue users for metadata preloading. + * If user already has metadata, prefetch their avatar image. + * Otherwise, queue for metadata subscription. + */ + fun preloadForUsers(users: Collection) { + users.forEach { user -> + val metadata = user.info + if (metadata != null) { + // Already have metadata, prefetch avatar + metadata.picture?.let { avatarUrl -> + imagePrefetcher?.prefetch(avatarUrl) + } + } else { + // Need to fetch metadata first + rateLimiter.enqueue(user.pubkeyHex) + } + } + } + + /** + * Queue a single user for metadata preloading. + */ + fun preloadForUser(user: User) { + val metadata = user.info + if (metadata != null) { + metadata.picture?.let { avatarUrl -> + imagePrefetcher?.prefetch(avatarUrl) + } + } else { + rateLimiter.enqueue(user.pubkeyHex) + } + } + + /** + * Called when metadata arrives for a user. + * Triggers avatar image prefetch. + */ + fun onMetadataReceived(user: User) { + user.info?.picture?.let { avatarUrl -> + imagePrefetcher?.prefetch(avatarUrl) + } + } + + /** + * Prefetch avatar images for users that already have metadata. + */ + fun prefetchAvatars(users: Collection) { + users.forEach { user -> + user.info?.picture?.let { avatarUrl -> + imagePrefetcher?.prefetch(avatarUrl) + } + } + } +} + +/** + * Interface for image prefetching. + * Platform-specific implementations use Coil (Android) or similar (Desktop). + */ +interface ImagePrefetcher { + /** + * Prefetch an image URL into the cache. + */ + fun prefetch(url: String) + + /** + * Prefetch multiple image URLs. + */ + fun prefetchAll(urls: Collection) { + urls.forEach { prefetch(it) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt new file mode 100644 index 000000000..f359f0012 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.preload + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch + +/** + * Global rate limiter for metadata requests to prevent thundering herd on fast scroll. + * Batches pubkeys and processes at a controlled rate. + * + * @param maxRequestsPerSecond Maximum requests to process per second (default 20) + * @param scope CoroutineScope for processing + */ +class MetadataRateLimiter( + private val maxRequestsPerSecond: Int = 20, + private val scope: CoroutineScope, +) { + private val queue = Channel(Channel.BUFFERED) + private val processed = mutableSetOf() + + /** + * Enqueue a pubkey for metadata fetching. + * Duplicates within the same batch are automatically filtered. + */ + fun enqueue(pubkey: String) { + if (pubkey !in processed) { + queue.trySend(pubkey) + } + } + + /** + * Enqueue multiple pubkeys for metadata fetching. + */ + fun enqueueAll(pubkeys: Collection) { + pubkeys.forEach { enqueue(it) } + } + + /** + * Start processing the queue with rate limiting. + * @param onRequest Callback invoked for each pubkey to process + */ + fun start(onRequest: suspend (String) -> Unit) { + scope.launch { + val batch = mutableListOf() + queue.consumeAsFlow().collect { pubkey -> + if (pubkey !in processed) { + batch.add(pubkey) + processed.add(pubkey) + } + + // Process batch when we hit the limit or queue is empty + if (batch.size >= maxRequestsPerSecond) { + processBatch(batch, onRequest) + batch.clear() + delay(1000) // Wait 1 second before next batch + } + } + + // Process remaining + if (batch.isNotEmpty()) { + processBatch(batch, onRequest) + } + } + } + + private suspend fun processBatch( + batch: List, + onRequest: suspend (String) -> Unit, + ) { + batch.forEach { pubkey -> + onRequest(pubkey) + } + } + + /** + * Clear the processed set to allow re-fetching. + * Call this when switching accounts or clearing cache. + */ + fun reset() { + processed.clear() + } + + /** + * Check if a pubkey has already been processed. + */ + fun isProcessed(pubkey: String): Boolean = pubkey in processed +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt index 039b715fe..60cb75256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState @Composable fun KeyDataSourceSubscription( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/PrioritizedSubscriptionQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/PrioritizedSubscriptionQueue.kt new file mode 100644 index 000000000..8eac02945 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/PrioritizedSubscriptionQueue.kt @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch + +/** + * A subscription request with its priority. + */ +data class PrioritizedFilter( + val priority: SubscriptionPriority, + val filter: Filter, + val tag: String? = null, +) + +/** + * Queue that processes subscription requests in priority order. + * Ensures metadata loads before reactions, reactions before replies, etc. + * + * Usage: + * ``` + * val queue = PrioritizedSubscriptionQueue(scope) + * queue.start { filter -> relayClient.subscribe(filter) } + * + * // Add subscriptions - they'll be processed in priority order + * queue.enqueue(SubscriptionPriority.METADATA, metadataFilter) + * queue.enqueue(SubscriptionPriority.REACTIONS, reactionsFilter) + * ``` + */ +class PrioritizedSubscriptionQueue( + private val scope: CoroutineScope, +) { + // Separate channels per priority for efficient processing + private val queues = + SubscriptionPriority.entries.associateWith { + Channel(Channel.BUFFERED) + } + + private var isRunning = false + + /** + * Enqueue a filter with the given priority. + */ + fun enqueue( + priority: SubscriptionPriority, + filter: Filter, + tag: String? = null, + ) { + queues[priority]?.trySend(PrioritizedFilter(priority, filter, tag)) + } + + /** + * Enqueue multiple filters with the same priority. + */ + fun enqueueAll( + priority: SubscriptionPriority, + filters: List, + tag: String? = null, + ) { + filters.forEach { enqueue(priority, it, tag) } + } + + /** + * Start processing the queue. + * Processes higher priority items first within each batch. + * + * @param onSubscribe Callback to execute the subscription + */ + fun start(onSubscribe: suspend (Filter) -> Unit) { + if (isRunning) return + isRunning = true + + // Process each priority queue in order + SubscriptionPriority.sortedByPriority().forEach { priority -> + scope.launch { + queues[priority]?.consumeAsFlow()?.collect { item -> + onSubscribe(item.filter) + } + } + } + } + + /** + * Process all pending items immediately in priority order. + * Useful for batch operations. + */ + suspend fun flush(onSubscribe: suspend (Filter) -> Unit) { + SubscriptionPriority.sortedByPriority().forEach { priority -> + val queue = queues[priority] ?: return@forEach + while (true) { + val item = queue.tryReceive().getOrNull() ?: break + onSubscribe(item.filter) + } + } + } + + /** + * Clear all pending items. + */ + fun clear() { + queues.values.forEach { channel -> + while (channel.tryReceive().isSuccess) { + // Drain the channel + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubscriptionPriority.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubscriptionPriority.kt new file mode 100644 index 000000000..b752ca47a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubscriptionPriority.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +/** + * Priority levels for relay subscriptions. + * Lower order = higher priority = processed first. + * + * Priority order ensures progressive rendering: + * 1. METADATA - Display names, avatars load first + * 2. REACTIONS - Like/zap counts load second + * 3. REPLIES - Reply counts + * 4. CONTENT - Additional content + */ +enum class SubscriptionPriority( + val order: Int, +) { + /** User metadata (Kind 0) - display names, avatars. Highest priority. */ + METADATA(1), + + /** Reactions (Kind 7) - likes, zaps, reposts */ + REACTIONS(2), + + /** Replies - reply counts and threads */ + REPLIES(3), + + /** Additional content - lower priority items */ + CONTENT(4), + ; + + companion object { + /** + * Get priorities sorted by order (highest priority first). + */ + fun sortedByPriority(): List = entries.sortedBy { it.order } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt new file mode 100644 index 000000000..a4f5d4eaa --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relays + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Generic EOSE cache keyed by any type K. + * KMP-compatible version (no LruCache dependency). + * + * For memory-constrained environments, consider using platform-specific + * LRU implementations in androidMain/jvmMain. + */ +open class EOSECache( + private val maxSize: Int = 200, +) { + private val cache = linkedMapOf() + private val lock = Any() + + fun addOrUpdate( + key: K, + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + synchronized(lock) { + val relayList = cache[key] + if (relayList == null) { + // Evict oldest if at capacity + if (cache.size >= maxSize) { + cache.remove(cache.keys.first()) + } + val newList = EOSERelayList() + newList.addOrUpdate(relayUrl, time) + cache[key] = newList + } else { + relayList.addOrUpdate(relayUrl, time) + } + } + } + + fun since(key: K): SincePerRelayMap? = + synchronized(lock) { + cache[key]?.relayList?.toMutableMap() + } + + fun newEose( + key: K, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(key, relayUrl, time) + + fun remove(key: K) { + synchronized(lock) { + cache.remove(key) + } + } + + fun clear() { + synchronized(lock) { + cache.clear() + } + } + + fun size(): Int = synchronized(lock) { cache.size } +} + +/** + * Two-level EOSE cache: outer key -> inner key -> relay list. + * Useful for user + list combinations. + */ +open class EOSETwoLevelCache( + private val outerMaxSize: Int = 20, + private val innerMaxSize: Int = 200, +) { + private val cache = linkedMapOf>() + private val lock = Any() + + fun addOrUpdate( + outerKey: K1, + innerKey: K2, + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + synchronized(lock) { + val innerCache = cache[outerKey] + if (innerCache == null) { + // Evict oldest if at capacity + if (cache.size >= outerMaxSize) { + cache.remove(cache.keys.first()) + } + val newCache = EOSECache(innerMaxSize) + newCache.addOrUpdate(innerKey, relayUrl, time) + cache[outerKey] = newCache + } else { + innerCache.addOrUpdate(innerKey, relayUrl, time) + } + } + } + + fun since( + outerKey: K1, + innerKey: K2, + ): SincePerRelayMap? = + synchronized(lock) { + cache[outerKey]?.since(innerKey) + } + + fun newEose( + outerKey: K1, + innerKey: K2, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(outerKey, innerKey, relayUrl, time) + + fun removeOuter(key: K1) { + synchronized(lock) { + cache.remove(key) + } + } + + fun clear() { + synchronized(lock) { + cache.clear() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt index b588ea572..428428bce 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import java.util.Base64 object Base64Image { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 70d90061d..b5dcf22fc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -45,7 +45,6 @@ import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL import kotlin.coroutines.cancellation.CancellationException -import kotlin.text.iterator class RichTextParser { fun createMediaContent( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt new file mode 100644 index 000000000..cbd899ef9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.utils.Hex + +/** + * Parses search input and returns matching results. + * Supports: npub, nprofile, nsec (extracts pubkey), note, nevent, naddr, hex keys, hashtags + * + * Shared between Android and Desktop for consistent Bech32 parsing. + */ +fun parseSearchInput(input: String): List { + if (input.isBlank()) return emptyList() + + val trimmed = input.trim() + val results = mutableListOf() + + // Check for hashtag + if (trimmed.startsWith("#") && trimmed.length > 1) { + results.add(SearchResult.HashtagResult(trimmed.substring(1))) + return results + } + + // Try to parse as Bech32 (npub, nevent, naddr, etc.) + val parsed = Nip19Parser.uriToRoute(trimmed)?.entity + if (parsed != null) { + when (parsed) { + is NPub -> { + results.add( + SearchResult.UserResult( + pubKeyHex = parsed.hex, + displayId = trimmed.take(20) + "...", + ), + ) + } + is NProfile -> { + results.add( + SearchResult.UserResult( + pubKeyHex = parsed.hex, + displayId = trimmed.take(20) + "...", + ), + ) + } + is NSec -> { + results.add( + SearchResult.UserResult( + pubKeyHex = parsed.toPubKeyHex(), + displayId = "User from nsec", + ), + ) + } + is NNote -> { + results.add( + SearchResult.NoteResult( + noteIdHex = parsed.hex, + displayId = trimmed.take(20) + "...", + ), + ) + } + is NEvent -> { + results.add( + SearchResult.NoteResult( + noteIdHex = parsed.hex, + displayId = trimmed.take(20) + "...", + ), + ) + } + is NAddress -> { + results.add( + SearchResult.AddressResult( + kind = parsed.kind, + pubKeyHex = parsed.author, + dTag = parsed.dTag, + displayId = trimmed.take(20) + "...", + ), + ) + } + else -> { } + } + return results + } + + // Try to parse as hex (64-char pubkey or event id) + if (trimmed.length == 64 && Hex.isHex(trimmed)) { + results.add( + SearchResult.UserResult( + pubKeyHex = trimmed, + displayId = trimmed.take(16) + "..." + trimmed.takeLast(8), + ), + ) + results.add( + SearchResult.NoteResult( + noteIdHex = trimmed, + displayId = trimmed.take(16) + "..." + trimmed.takeLast(8), + ), + ) + return results + } + + return results +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt new file mode 100644 index 000000000..d8260354c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.amethyst.commons.model.User + +/** + * Represents a parsed search result from Bech32/hex input. + * Shared between Android and Desktop for consistent search behavior. + */ +sealed class SearchResult { + /** + * Direct user lookup from npub, nprofile, nsec, or hex pubkey. + */ + data class UserResult( + val pubKeyHex: String, + val displayId: String, + ) : SearchResult() + + /** + * User from local cache with full metadata. + */ + data class CachedUserResult( + val user: User, + ) : SearchResult() + + /** + * Note lookup from note1 or nevent. + */ + data class NoteResult( + val noteIdHex: String, + val displayId: String, + ) : SearchResult() + + /** + * Addressable event lookup from naddr. + */ + data class AddressResult( + val kind: Int, + val pubKeyHex: String, + val dTag: String, + val displayId: String, + ) : SearchResult() + + /** + * Hashtag search. + */ + data class HashtagResult( + val hashtag: String, + ) : SearchResult() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt index daeabe2fe..d2d4a0003 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt @@ -90,6 +90,7 @@ class EventCollectionState( mutex.withLock { val itemId = getId(item) if (itemId !in seenIds) { + seenIds.add(itemId) pendingItems.add(item) scheduleBatchUpdate() } @@ -108,6 +109,7 @@ class EventCollectionState( mutex.withLock { val newItems = items.filter { getId(it) !in seenIds } if (newItems.isNotEmpty()) { + newItems.forEach { seenIds.add(getId(it)) } pendingItems.addAll(newItems) scheduleBatchUpdate() } @@ -191,8 +193,7 @@ class EventCollectionState( mutex.withLock { if (pendingItems.isEmpty()) return - // Add pending IDs to seenIds - pendingItems.forEach { seenIds.add(getId(it)) } + // seenIds already updated in addItem/addItems // Merge with existing items val merged = _items.value + pendingItems diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt index 2ed9eb625..bab70c642 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt @@ -34,8 +34,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.SharedRes -import dev.icerock.moko.resources.compose.stringResource +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.action_refresh +import com.vitorpamplona.amethyst.commons.resources.action_try_again +import com.vitorpamplona.amethyst.commons.resources.error_loading_feed +import com.vitorpamplona.amethyst.commons.resources.feed_empty +import org.jetbrains.compose.resources.stringResource /** * A centered loading state with a progress indicator and message. @@ -71,7 +75,7 @@ fun EmptyState( onRefresh: (() -> Unit)? = null, refreshLabel: String? = null, ) { - val actualRefreshLabel = refreshLabel ?: stringResource(SharedRes.strings.action_refresh) + val actualRefreshLabel = refreshLabel ?: stringResource(Res.string.action_refresh) Column( modifier = modifier.fillMaxSize(), @@ -110,7 +114,7 @@ fun ErrorState( onRetry: (() -> Unit)? = null, retryLabel: String? = null, ) { - val actualRetryLabel = retryLabel ?: stringResource(SharedRes.strings.action_try_again) + val actualRetryLabel = retryLabel ?: stringResource(Res.string.action_try_again) Column( modifier = modifier.fillMaxSize(), @@ -140,7 +144,7 @@ fun FeedEmptyState( title: String? = null, onRefresh: (() -> Unit)? = null, ) { - val actualTitle = title ?: stringResource(SharedRes.strings.feed_empty) + val actualTitle = title ?: stringResource(Res.string.feed_empty) EmptyState( title = actualTitle, @@ -158,7 +162,7 @@ fun FeedErrorState( modifier: Modifier = Modifier, onRetry: (() -> Unit)? = null, ) { - val formattedMessage = stringResource(SharedRes.strings.error_loading_feed).format(errorMessage) + val formattedMessage = stringResource(Res.string.error_loading_feed).format(errorMessage) ErrorState( message = formattedMessage, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt index fc9ef4ddc..df2122c58 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Face @@ -62,8 +63,10 @@ fun UserAvatar( loadRobohash: Boolean = true, ) { val avatarModifier = - remember(size) { - modifier.clip(shape = CircleShape) + remember(size, modifier) { + modifier + .size(size) + .clip(shape = CircleShape) } if (pictureUrl != null && loadProfilePicture) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt new file mode 100644 index 000000000..d2df0abeb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.User + +/** + * A card displaying user search result with avatar, name, and nip05/pubkey. + * Shared between Android and Desktop search screens. + */ +@Composable +fun UserSearchCard( + user: User, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = user.pubkeyHex, + pictureUrl = user.profilePicture(), + size = 40.dp, + contentDescription = "Profile picture", + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + user.toBestDisplayName(), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + val nip05 = user.nip05() + if (nip05 != null) { + Text( + nip05, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + user.pubkeyDisplayHex(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Navigate", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt new file mode 100644 index 000000000..53406c4f6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.notifications + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Base interface for notification cards. + * + * Cards represent grouped or individual notification items in the feed. + * Platform implementations provide concrete card types (NoteCard, MultiSetCard, etc.). + */ +@Immutable +interface Card { + /** + * Returns the creation timestamp of this card. + * Used for sorting cards in chronological order. + */ + fun createdAt(): Long + + /** + * Returns a unique identifier for this card. + * Used for list diffing and deduplication. + */ + fun id(): String +} + +/** + * Feed state for notification cards. + * + * Mirrors FeedState but specifically typed for Card content. + */ +@Stable +sealed class CardFeedState { + @Immutable + object Loading : CardFeedState() + + @Stable + class Loaded( + val feed: MutableStateFlow>, + ) : CardFeedState() + + @Immutable + object Empty : CardFeedState() + + @Immutable + class FeedError( + val errorMessage: String, + ) : CardFeedState() +} + +/** + * Comparator for sorting cards by creation time (newest first). + */ +val DefaultCardComparator: Comparator = + compareByDescending { it.createdAt() } + .thenByDescending { it.id() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt index 3a30ba107..82ef5f82e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt @@ -28,8 +28,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.SharedRes -import dev.icerock.moko.resources.compose.stringResource +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.screen_messages_description +import com.vitorpamplona.amethyst.commons.resources.screen_messages_title +import com.vitorpamplona.amethyst.commons.resources.screen_notifications_description +import com.vitorpamplona.amethyst.commons.resources.screen_notifications_title +import com.vitorpamplona.amethyst.commons.resources.screen_search_description +import com.vitorpamplona.amethyst.commons.resources.screen_search_title +import org.jetbrains.compose.resources.stringResource /** * Generic placeholder screen with title and description. @@ -60,8 +66,8 @@ fun PlaceholderScreen( @Composable fun SearchPlaceholder(modifier: Modifier = Modifier) { PlaceholderScreen( - title = stringResource(SharedRes.strings.screen_search_title), - description = stringResource(SharedRes.strings.screen_search_description), + title = stringResource(Res.string.screen_search_title), + description = stringResource(Res.string.screen_search_description), modifier = modifier, ) } @@ -72,8 +78,8 @@ fun SearchPlaceholder(modifier: Modifier = Modifier) { @Composable fun MessagesPlaceholder(modifier: Modifier = Modifier) { PlaceholderScreen( - title = stringResource(SharedRes.strings.screen_messages_title), - description = stringResource(SharedRes.strings.screen_messages_description), + title = stringResource(Res.string.screen_messages_title), + description = stringResource(Res.string.screen_messages_description), modifier = modifier, ) } @@ -84,8 +90,8 @@ fun MessagesPlaceholder(modifier: Modifier = Modifier) { @Composable fun NotificationsPlaceholder(modifier: Modifier = Modifier) { PlaceholderScreen( - title = stringResource(SharedRes.strings.screen_notifications_title), - description = stringResource(SharedRes.strings.screen_notifications_description), + title = stringResource(Res.string.screen_notifications_title), + description = stringResource(Res.string.screen_notifications_description), modifier = modifier, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt new file mode 100644 index 000000000..6e873eae4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.search.SearchResult +import com.vitorpamplona.amethyst.commons.search.parseSearchInput +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * State holder for search bar functionality. + * Shared between Android and Desktop search screens. + * + * Handles: + * - Search text input + * - Bech32/hex parsing + * - Local cache search (with debounce) + * - Relay search results aggregation + * + * Platform-specific concerns (relay subscriptions, navigation) remain in the UI layer. + */ +class SearchBarState( + private val cache: ICacheProvider, + private val scope: CoroutineScope, + private val debounceMs: Long = 300L, +) { + private val _searchText = MutableStateFlow("") + val searchText: StateFlow = _searchText.asStateFlow() + + private val _bech32Results = MutableStateFlow>(emptyList()) + val bech32Results: StateFlow> = _bech32Results.asStateFlow() + + private val _cachedUserResults = MutableStateFlow>(emptyList()) + val cachedUserResults: StateFlow> = _cachedUserResults.asStateFlow() + + private val _relaySearchResults = MutableStateFlow>(emptyList()) + val relaySearchResults: StateFlow> = _relaySearchResults.asStateFlow() + + private val _isSearchingRelays = MutableStateFlow(false) + val isSearchingRelays: StateFlow = _isSearchingRelays.asStateFlow() + + val hasResults: Boolean + get() = + _bech32Results.value.isNotEmpty() || + _cachedUserResults.value.isNotEmpty() || + _relaySearchResults.value.isNotEmpty() + + val shouldSearchRelays: Boolean + get() = + _searchText.value.length >= 2 && + _bech32Results.value.isEmpty() && + _cachedUserResults.value.size < 5 + + init { + setupSearchTextObserver() + } + + @OptIn(FlowPreview::class) + private fun setupSearchTextObserver() { + // Debounced cache search + _searchText + .debounce(debounceMs) + .onEach { query -> + if (query.length >= 2 && _bech32Results.value.isEmpty()) { + @Suppress("UNCHECKED_CAST") + _cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List + } else { + _cachedUserResults.value = emptyList() + } + }.launchIn(scope) + } + + fun updateSearchText(text: String) { + _searchText.value = text + _relaySearchResults.value = emptyList() + _isSearchingRelays.value = false + + // Parse Bech32/hex immediately (no debounce) + _bech32Results.value = parseSearchInput(text) + + // Clear cached results if query too short or is bech32 + if (text.length < 2 || _bech32Results.value.isNotEmpty()) { + _cachedUserResults.value = emptyList() + } + } + + fun clearSearch() { + updateSearchText("") + } + + fun startRelaySearch() { + _isSearchingRelays.value = true + } + + fun endRelaySearch() { + _isSearchingRelays.value = false + } + + fun addRelaySearchResult(user: User) { + if (!_relaySearchResults.value.any { it.pubkeyHex == user.pubkeyHex }) { + _relaySearchResults.value = _relaySearchResults.value + user + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt new file mode 100644 index 000000000..52c59420b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip51Bookmarks + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark + +/** + * Handles NIP-51 bookmark operations. + * Shared between Android and Desktop. + */ +object BookmarkAction { + /** + * Creates a new bookmark list with a single event bookmarked. + */ + suspend fun createWithBookmark( + eventId: HexKey, + relayHint: NormalizedRelayUrl? = null, + isPrivate: Boolean = false, + signer: NostrSigner, + ): BookmarkListEvent { + val bookmark = EventBookmark(eventId, relayHint) + return BookmarkListEvent.create( + bookmarkIdTag = bookmark, + isPrivate = isPrivate, + signer = signer, + ) + } + + /** + * Adds an event to an existing bookmark list. + */ + suspend fun addBookmark( + existingList: BookmarkListEvent, + eventId: HexKey, + relayHint: NormalizedRelayUrl? = null, + isPrivate: Boolean = false, + signer: NostrSigner, + ): BookmarkListEvent { + val bookmark = EventBookmark(eventId, relayHint) + return BookmarkListEvent.add( + earlierVersion = existingList, + bookmarkIdTag = bookmark, + isPrivate = isPrivate, + signer = signer, + ) + } + + /** + * Removes an event from a bookmark list. + * Checks both public and private bookmarks. + */ + suspend fun removeBookmark( + existingList: BookmarkListEvent, + eventId: HexKey, + signer: NostrSigner, + ): BookmarkListEvent { + val bookmark = EventBookmark(eventId) + return BookmarkListEvent.remove( + earlierVersion = existingList, + bookmarkIdTag = bookmark, + signer = signer, + ) + } + + /** + * Removes an event from a bookmark list (public or private specifically). + */ + suspend fun removeBookmark( + existingList: BookmarkListEvent, + eventId: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + ): BookmarkListEvent { + val bookmark = EventBookmark(eventId) + return BookmarkListEvent.remove( + earlierVersion = existingList, + bookmarkIdTag = bookmark, + isPrivate = isPrivate, + signer = signer, + ) + } + + /** + * Checks if an event ID is in the public bookmarks. + */ + fun isInPublicBookmarks( + bookmarkList: BookmarkListEvent?, + eventId: HexKey, + ): Boolean { + if (bookmarkList == null) return false + return bookmarkList.publicBookmarks().any { + it is EventBookmark && it.eventId == eventId + } + } + + /** + * Checks if an event ID is in the private bookmarks. + * Requires decryption via signer. + */ + suspend fun isInPrivateBookmarks( + bookmarkList: BookmarkListEvent?, + eventId: HexKey, + signer: NostrSigner, + ): Boolean { + if (bookmarkList == null) return false + val privateBookmarks = bookmarkList.privateBookmarks(signer) ?: return false + return privateBookmarks.any { + it is EventBookmark && it.eventId == eventId + } + } + + /** + * Checks if an event ID is bookmarked (public or private). + */ + suspend fun isBookmarked( + bookmarkList: BookmarkListEvent?, + eventId: HexKey, + signer: NostrSigner, + ): Boolean = + isInPublicBookmarks(bookmarkList, eventId) || + isInPrivateBookmarks(bookmarkList, eventId, signer) + + /** + * Gets the bookmark list address for a user. + */ + fun getBookmarkListAddress(pubKey: HexKey) = BookmarkListEvent.createBookmarkAddress(pubKey) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt new file mode 100644 index 000000000..3fb510267 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip57Zaps + +import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +/** + * Handles NIP-57 zap requests and invoice fetching. + * Shared between Android and Desktop. + */ +object ZapAction { + /** + * Result of a zap operation. + */ + sealed class ZapResult { + data class Invoice( + val bolt11: String, + ) : ZapResult() + + data class Error( + val message: String, + ) : ZapResult() + } + + /** + * Creates a zap request and fetches a BOLT11 invoice. + * + * @param targetEvent Event to zap + * @param lnAddress Lightning address of recipient + * @param amountSats Amount in satoshis + * @param message Optional zap message + * @param relays Relay hints (normalized URLs) + * @param signer Signer for the request + * @param resolver Lightning address resolver + * @param zapType Type of zap (default PUBLIC) + * @param onProgress Progress callback + */ + suspend fun fetchZapInvoice( + targetEvent: Event, + lnAddress: String, + amountSats: Long, + message: String = "", + relays: Set, + signer: NostrSigner, + resolver: LightningAddressResolver, + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + onProgress: (Float) -> Unit = {}, + ): ZapResult { + if (!signer.isWriteable()) { + return ZapResult.Error("Signer is not writeable") + } + + // Create zap request using quartz factory + val zapRequest = + try { + LnZapRequestEvent.create( + zappedEvent = targetEvent, + relays = relays, + signer = signer, + pollOption = null, + message = message, + zapType = zapType, + toUserPubHex = null, + ) + } catch (e: Exception) { + return ZapResult.Error("Failed to create zap request: ${e.message}") + } + + onProgress(0.3f) + + // Fetch invoice + val result = + resolver.fetchInvoice( + lnAddress = lnAddress, + milliSats = amountSats * 1000, + message = message, + zapRequest = zapRequest, + onProgress = { progress -> + onProgress(0.3f + progress * 0.7f) + }, + ) + + return when (result) { + is LightningAddressResolver.Result.Success -> ZapResult.Invoice(result.invoice) + is LightningAddressResolver.Result.Error -> ZapResult.Error(result.message) + } + } + + /** + * Creates a zap request for a user profile (no event). + */ + suspend fun fetchZapInvoiceForUser( + userPubHex: String, + lnAddress: String, + amountSats: Long, + message: String = "", + relays: Set, + signer: NostrSigner, + resolver: LightningAddressResolver, + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + onProgress: (Float) -> Unit = {}, + ): ZapResult { + if (!signer.isWriteable()) { + return ZapResult.Error("Signer is not writeable") + } + + // Create zap request for user + val zapRequest = + try { + LnZapRequestEvent.create( + userHex = userPubHex, + relays = relays, + signer = signer, + message = message, + zapType = zapType, + ) + } catch (e: Exception) { + return ZapResult.Error("Failed to create zap request: ${e.message}") + } + + onProgress(0.3f) + + // Fetch invoice + val result = + resolver.fetchInvoice( + lnAddress = lnAddress, + milliSats = amountSats * 1000, + message = message, + zapRequest = zapRequest, + onProgress = { progress -> + onProgress(0.3f + progress * 0.7f) + }, + ) + + return when (result) { + is LightningAddressResolver.Result.Success -> ZapResult.Invoice(result.invoice) + is LightningAddressResolver.Result.Error -> ZapResult.Error(result.message) + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt new file mode 100644 index 000000000..98a60a57d --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt @@ -0,0 +1,224 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.services.lnurl + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.math.BigDecimal +import java.math.RoundingMode +import java.net.URLEncoder +import kotlin.coroutines.cancellation.CancellationException + +/** + * Platform-agnostic Lightning Address resolver for LNURL-pay flow. + * Shared between Android and Desktop for zap functionality. + * + * Flow: + * 1. Lightning address (user@domain) → LNURL endpoint URL + * 2. Fetch LNURL-pay JSON → extract callback URL + * 3. Call callback with amount → get BOLT11 invoice + */ +class LightningAddressResolver( + private val httpClient: OkHttpClient, +) { + private val mapper = jacksonObjectMapper() + + /** + * Result of resolving a lightning address to a BOLT11 invoice. + */ + sealed class Result { + data class Success( + val invoice: String, + ) : Result() + + data class Error( + val message: String, + ) : Result() + } + + /** + * Converts a lightning address to its LNURL-pay endpoint URL. + * Supports: user@domain, LNURL bech32 + */ + fun assembleUrl(lnAddress: String): String? { + val parts = lnAddress.split("@") + + if (parts.size == 2) { + return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" + } + + if (lnAddress.lowercase().startsWith("lnurl")) { + return Lud06().toLnUrlp(lnAddress) + } + + return null + } + + /** + * Resolves a lightning address to a BOLT11 invoice. + * + * @param lnAddress Lightning address (user@domain) or LNURL + * @param milliSats Amount in millisatoshis + * @param message Optional comment for the payment + * @param zapRequest Optional NIP-57 zap request event + * @param onProgress Progress callback (0.0 to 1.0) + */ + suspend fun fetchInvoice( + lnAddress: String, + milliSats: Long, + message: String = "", + zapRequest: LnZapRequestEvent? = null, + onProgress: (Float) -> Unit = {}, + ): Result = + withContext(Dispatchers.IO) { + try { + // Step 1: Resolve LN address to LNURL endpoint + val url = + assembleUrl(lnAddress) + ?: return@withContext Result.Error("Invalid lightning address: $lnAddress") + + onProgress(0.2f) + + // Step 2: Fetch LNURL-pay JSON + val lnurlJson = + fetchUrl(url) + ?: return@withContext Result.Error("Failed to fetch LNURL endpoint: $url") + + onProgress(0.4f) + + val lnurlp = + try { + mapper.readTree(lnurlJson) + } catch (e: Exception) { + return@withContext Result.Error("Failed to parse LNURL response") + } + + val callbackUrl = + lnurlp?.get("callback")?.asText()?.ifBlank { null } + ?: return@withContext Result.Error("No callback URL in LNURL response") + + val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false + + onProgress(0.5f) + + // Step 3: Fetch invoice from callback + val invoiceJson = + fetchInvoiceFromCallback( + callbackUrl = callbackUrl, + milliSats = milliSats, + message = message, + zapRequest = if (allowsNostr) zapRequest else null, + ) ?: return@withContext Result.Error("Failed to fetch invoice from callback") + + onProgress(0.7f) + + val invoiceResponse = + try { + mapper.readTree(invoiceJson) + } catch (e: Exception) { + return@withContext Result.Error("Failed to parse invoice response") + } + + val pr = invoiceResponse?.get("pr")?.asText()?.ifBlank { null } + + if (pr == null) { + val reason = invoiceResponse?.get("reason")?.asText()?.ifBlank { null } + return@withContext Result.Error(reason ?: "No invoice in response") + } + + // Step 4: Validate invoice amount + val expectedAmountInSats = + BigDecimal(milliSats) + .divide(BigDecimal(1000), RoundingMode.HALF_UP) + .toLong() + + val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) + + if (invoiceAmount.toLong() != expectedAmountInSats) { + return@withContext Result.Error( + "Invoice amount mismatch: got ${invoiceAmount.toLong()} sats, expected $expectedAmountInSats sats", + ) + } + + onProgress(1.0f) + + Result.Success(pr) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.Error(e.message ?: "Unknown error") + } + } + + private suspend fun fetchUrl(url: String): String? = + withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body?.string() + } else { + null + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + + private suspend fun fetchInvoiceFromCallback( + callbackUrl: String, + milliSats: Long, + message: String, + zapRequest: LnZapRequestEvent?, + ): String? = + withContext(Dispatchers.IO) { + try { + val encodedMessage = URLEncoder.encode(message, "utf-8") + val urlBinder = if (callbackUrl.contains("?")) "&" else "?" + var url = "$callbackUrl${urlBinder}amount=$milliSats&comment=$encodedMessage" + + if (zapRequest != null) { + val encodedRequest = URLEncoder.encode(zapRequest.toJson(), "utf-8") + url += "&nostr=$encodedRequest" + } + + val request = Request.Builder().url(url).build() + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body?.string() + } else { + null + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt new file mode 100644 index 000000000..68100f6a7 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.services.nwc + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks pending NIP-47 (Nostr Wallet Connect) payment requests awaiting responses. + * + * Shared between Android and Desktop to provide consistent payment tracking behavior. + * Platform-specific caches (LocalCache, DesktopLocalCache) delegate to this tracker + * for the core request/response matching logic. + * + * Flow: + * 1. When sending payment request: [registerRequest] stores callback + * 2. When response arrives: [onResponseReceived] retrieves and removes pending request + * 3. Caller invokes callback and links notes via Note.addZapPayment() + */ +class NwcPaymentTracker { + /** + * Data for a pending payment request. + * + * @property zappedNote The note being zapped, if payment is for a zap + * @property onResponse Callback to invoke when wallet responds + */ + data class PendingRequest( + val zappedNote: Note?, + val onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + ) + + private val awaitingRequests = ConcurrentHashMap(10) + + /** + * Registers a pending payment request. + * + * @param requestId Event ID of the LnZapPaymentRequestEvent + * @param zappedNote The note being zapped (null if not a zap payment) + * @param onResponse Callback invoked when response arrives + */ + fun registerRequest( + requestId: HexKey, + zappedNote: Note?, + onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + ) { + awaitingRequests[requestId] = PendingRequest(zappedNote, onResponse) + } + + /** + * Called when a payment response event is received. + * Retrieves and removes the pending request for the given request ID. + * + * @param requestId The 'e' tag from the response, pointing to original request + * @return PendingRequest if found, null otherwise + */ + fun onResponseReceived(requestId: HexKey?): PendingRequest? { + if (requestId == null) return null + return awaitingRequests.remove(requestId) + } + + /** + * Checks if there's a pending request for the given ID. + */ + fun hasPendingRequest(requestId: HexKey): Boolean = awaitingRequests.containsKey(requestId) + + /** + * Manually removes a pending request (e.g., on timeout). + */ + fun cleanup(requestId: HexKey) { + awaitingRequests.remove(requestId) + } + + /** + * Returns count of pending requests (for debugging/monitoring). + */ + fun pendingCount(): Int = awaitingRequests.size +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index b82eb646b..c9d5ee7c4 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation(compose.desktop.currentOs) implementation(compose.material3) implementation(compose.materialIconsExtended) + implementation(compose.components.resources) // Quartz Nostr library (will use JVM target) implementation(project(":quartz")) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt new file mode 100644 index 000000000..d0004dec5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop + +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import java.util.prefs.Preferences + +/** + * Simple preferences storage using Java's Preferences API. + * Data is stored in platform-appropriate location: + * - macOS: ~/Library/Preferences/com.apple.java.util.prefs.plist + * - Linux: ~/.java/.userPrefs/ + * - Windows: Registry under HKEY_CURRENT_USER\Software\JavaSoft\Prefs + */ +object DesktopPreferences { + private val prefs: Preferences = Preferences.userNodeForPackage(DesktopPreferences::class.java) + + private const val KEY_FEED_MODE = "feed_mode" + private const val KEY_LAST_SCREEN = "last_screen" + + var feedMode: FeedMode + get() { + val name = prefs.get(KEY_FEED_MODE, FeedMode.GLOBAL.name) + return try { + FeedMode.valueOf(name) + } catch (e: Exception) { + FeedMode.GLOBAL + } + } + set(value) { + prefs.put(KEY_FEED_MODE, value.name) + } + + var lastScreen: String + get() = prefs.get(KEY_LAST_SCREEN, "Feed") + set(value) { + prefs.put(KEY_LAST_SCREEN, value) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 7493784ee..1f42eeabd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications @@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -50,12 +52,15 @@ import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -74,18 +79,24 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder -import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen +import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen +import com.vitorpamplona.amethyst.desktop.ui.SearchScreen import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -99,8 +110,12 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") sealed class DesktopScreen { object Feed : DesktopScreen() + object Reads : DesktopScreen() + object Search : DesktopScreen() + object Bookmarks : DesktopScreen() + object Messages : DesktopScreen() object Notifications : DesktopScreen() @@ -127,6 +142,8 @@ fun main() = position = WindowPosition.Aligned(Alignment.Center), ) var showComposeDialog by remember { mutableStateOf(false) } + var replyToNote by remember { mutableStateOf(null) } + var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } Window( onCloseRequest = ::exitApplication, @@ -154,7 +171,7 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { /* TODO: Open settings */ }, + onClick = { currentScreen = DesktopScreen.Settings }, ) Separator() Item( @@ -202,25 +219,50 @@ fun main() = } App( + currentScreen = currentScreen, + onScreenChange = { currentScreen = it }, showComposeDialog = showComposeDialog, onShowComposeDialog = { showComposeDialog = true }, - onDismissComposeDialog = { showComposeDialog = false }, + onShowReplyDialog = { event -> + replyToNote = event + showComposeDialog = true + }, + onDismissComposeDialog = { + showComposeDialog = false + replyToNote = null + }, + replyToNote = replyToNote, ) } } @Composable fun App( + currentScreen: DesktopScreen, + onScreenChange: (DesktopScreen) -> Unit, showComposeDialog: Boolean, onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, + replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { - var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } val relayManager = remember { DesktopRelayConnectionManager() } + val localCache = remember { DesktopLocalCache() } val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Subscriptions coordinator for metadata/reactions loading + val subscriptionsCoordinator = + remember(relayManager, localCache) { + DesktopRelaySubscriptionsCoordinator( + client = relayManager.client, + scope = scope, + indexRelays = relayManager.availableRelays.value, + localCache = localCache, + ) + } + // Try to load saved account on startup DisposableEffect(Unit) { scope.launch(Dispatchers.IO) { @@ -230,7 +272,12 @@ fun App( relayManager.addDefaultRelays() relayManager.connect() + + // Start subscriptions coordinator + subscriptionsCoordinator.start() + onDispose { + subscriptionsCoordinator.clear() relayManager.disconnect() } } @@ -246,19 +293,29 @@ fun App( is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { currentScreen = DesktopScreen.Feed }, + onLoginSuccess = { onScreenChange(DesktopScreen.Feed) }, ) } is AccountState.LoggedIn -> { val account = accountState as AccountState.LoggedIn + val nwcConnection by accountManager.nwcConnection.collectAsState() + + // Load NWC connection on first composition + LaunchedEffect(Unit) { + accountManager.loadNwcConnection() + } MainContent( currentScreen = currentScreen, - onScreenChange = { currentScreen = it }, + onScreenChange = onScreenChange, relayManager = relayManager, + localCache = localCache, accountManager = accountManager, account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, ) // Compose dialog @@ -267,6 +324,7 @@ fun App( onDismiss = onDismissComposeDialog, relayManager = relayManager, account = account, + replyTo = replyToNote, ) } } @@ -280,127 +338,225 @@ fun MainContent( currentScreen: DesktopScreen, onScreenChange: (DesktopScreen) -> Unit, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + nwcConnection: Nip47WalletConnect.Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, ) { - Row(Modifier.fillMaxSize()) { - // Sidebar Navigation - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - Spacer(Modifier.height(16.dp)) + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() - NavigationRailItem( - icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, - label = { Text("Feed") }, - selected = currentScreen == DesktopScreen.Feed, - onClick = { onScreenChange(DesktopScreen.Feed) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, - label = { Text("Search") }, - selected = currentScreen == DesktopScreen.Search, - onClick = { onScreenChange(DesktopScreen.Search) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, - label = { Text("DMs") }, - selected = currentScreen == DesktopScreen.Messages, - onClick = { onScreenChange(DesktopScreen.Messages) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, - label = { Text("Alerts") }, - selected = currentScreen == DesktopScreen.Notifications, - onClick = { onScreenChange(DesktopScreen.Notifications) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, - onClick = { onScreenChange(DesktopScreen.MyProfile) }, - ) - - Spacer(Modifier.weight(1f)) - - HorizontalDivider(Modifier.padding(horizontal = 16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, - selected = currentScreen == DesktopScreen.Settings, - onClick = { onScreenChange(DesktopScreen.Settings) }, - ) - - Spacer(Modifier.height(16.dp)) + val onZapFeedback: (ZapFeedback) -> Unit = { feedback -> + scope.launch { + val message = + when (feedback) { + is ZapFeedback.Success -> "Zapped ${feedback.amountSats} sats" + is ZapFeedback.ExternalWallet -> "Invoice sent to wallet (${feedback.amountSats} sats)" + is ZapFeedback.Error -> "Zap failed: ${feedback.message}" + is ZapFeedback.Timeout -> "Zap timed out" + is ZapFeedback.NoLightningAddress -> "User has no lightning address" + } + snackbarHostState.showSnackbar(message) } + } - VerticalDivider() + Box(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxSize()) { + // Sidebar Navigation + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + Spacer(Modifier.height(16.dp)) - // Main Content - Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), - ) { - when (currentScreen) { - DesktopScreen.Feed -> - FeedScreen( - relayManager = relayManager, - account = account, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - DesktopScreen.Search -> SearchPlaceholder() - DesktopScreen.Messages -> MessagesPlaceholder() - DesktopScreen.Notifications -> NotificationsScreen(relayManager, account) - DesktopScreen.MyProfile -> - UserProfileScreen( - pubKeyHex = account.pubKeyHex, - relayManager = relayManager, - account = account, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - ) - is DesktopScreen.UserProfile -> - UserProfileScreen( - pubKeyHex = currentScreen.pubKeyHex, - relayManager = relayManager, - account = account, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - ) - is DesktopScreen.Thread -> - ThreadScreen( - noteId = currentScreen.noteId, - relayManager = relayManager, - account = account, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account) + NavigationRailItem( + icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == DesktopScreen.Feed, + onClick = { onScreenChange(DesktopScreen.Feed) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") }, + label = { Text("Reads") }, + selected = currentScreen == DesktopScreen.Reads, + onClick = { onScreenChange(DesktopScreen.Reads) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + label = { Text("Search") }, + selected = currentScreen == DesktopScreen.Search, + onClick = { onScreenChange(DesktopScreen.Search) }, + ) + + NavigationRailItem( + icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") }, + label = { Text("Bookmarks") }, + selected = currentScreen == DesktopScreen.Bookmarks, + onClick = { onScreenChange(DesktopScreen.Bookmarks) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == DesktopScreen.Messages, + onClick = { onScreenChange(DesktopScreen.Messages) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == DesktopScreen.Notifications, + onClick = { onScreenChange(DesktopScreen.Notifications) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, + onClick = { onScreenChange(DesktopScreen.MyProfile) }, + ) + + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == DesktopScreen.Settings, + onClick = { onScreenChange(DesktopScreen.Settings) }, + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // Main Content + Box( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), + ) { + when (currentScreen) { + DesktopScreen.Feed -> + FeedScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + onZapFeedback = onZapFeedback, + ) + DesktopScreen.Reads -> + ReadsScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToArticle = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + ) + DesktopScreen.Search -> + SearchScreen( + localCache = localCache, + relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + ) + DesktopScreen.Bookmarks -> + BookmarksScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + onZapFeedback = onZapFeedback, + ) + DesktopScreen.Messages -> MessagesPlaceholder() + DesktopScreen.Notifications -> NotificationsScreen(relayManager, account, subscriptionsCoordinator) + DesktopScreen.MyProfile -> + UserProfileScreen( + pubKeyHex = account.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onZapFeedback = onZapFeedback, + ) + is DesktopScreen.UserProfile -> + UserProfileScreen( + pubKeyHex = currentScreen.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onZapFeedback = onZapFeedback, + ) + is DesktopScreen.Thread -> + ThreadScreen( + noteId = currentScreen.noteId, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + onZapFeedback = onZapFeedback, + onReply = onShowReplyDialog, + ) + DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account, accountManager) + } } } + + // Snackbar for zap feedback + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + ) } } @@ -443,10 +599,19 @@ fun ProfileScreen( fun RelaySettingsScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, + accountManager: AccountManager, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() + val nwcConnection by accountManager.nwcConnection.collectAsState() var newRelayUrl by remember { mutableStateOf("") } + var nwcInput by remember { mutableStateOf("") } + var nwcError by remember { mutableStateOf(null) } + + // Load NWC on first composition + LaunchedEffect(Unit) { + accountManager.loadNwcConnection() + } Column(modifier = Modifier.fillMaxSize()) { Text( @@ -457,6 +622,88 @@ fun RelaySettingsScreen( Spacer(Modifier.height(24.dp)) + // Wallet Connect Section + Text( + "Wallet Connect (NWC)", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Text( + "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + if (nwcConnection != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + "Wallet Connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "Relay: ${nwcConnection!!.relayUri.url}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + onClick = { accountManager.clearNwcConnection() }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Disconnect") + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = nwcInput, + onValueChange = { + nwcInput = it + nwcError = null + }, + label = { Text("NWC Connection String") }, + placeholder = { Text("nostr+walletconnect://...") }, + modifier = Modifier.weight(1f), + singleLine = true, + isError = nwcError != null, + supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + ) + Button( + onClick = { + val result = accountManager.setNwcConnection(nwcInput) + result.fold( + onSuccess = { nwcInput = "" }, + onFailure = { nwcError = it.message ?: "Invalid connection string" }, + ) + }, + enabled = nwcInput.isNotBlank(), + ) { + Text("Connect") + } + } + } + + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + // Developer Settings Section (only in debug mode) if (DebugConfig.isDebugMode) { com.vitorpamplona.amethyst.desktop.ui diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 60c902d78..49098ee98 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -69,6 +70,9 @@ class AccountManager private constructor( private val _accountState = MutableStateFlow(AccountState.LoggedOut) val accountState: StateFlow = _accountState.asStateFlow() + private val _nwcConnection = MutableStateFlow(null) + val nwcConnection: StateFlow = _nwcConnection.asStateFlow() + /** * Loads the last saved account from secure storage. * Call on app startup. @@ -206,6 +210,47 @@ class AccountManager private constructor( fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn + // NWC (Nostr Wallet Connect) methods + fun hasNwcSetup(): Boolean = _nwcConnection.value != null + + fun setNwcConnection(uri: String): Result = + try { + val parsed = Nip47WalletConnect.parse(uri) + _nwcConnection.value = parsed + saveNwcUri(uri) + Result.success(parsed) + } catch (e: Exception) { + Result.failure(e) + } + + fun clearNwcConnection() { + _nwcConnection.value = null + getNwcFile().delete() + } + + fun loadNwcConnection() { + val uri = getNwcFile().takeIf { it.exists() }?.readText()?.trim() + if (!uri.isNullOrEmpty()) { + try { + _nwcConnection.value = Nip47WalletConnect.parse(uri) + } catch (e: Exception) { + // Invalid stored URI, clear it + getNwcFile().delete() + } + } + } + + private fun saveNwcUri(uri: String) { + val file = getNwcFile() + file.parentFile?.mkdirs() + file.writeText(uri) + } + + private fun getNwcFile(): java.io.File { + val homeDir = System.getProperty("user.home") + return java.io.File(homeDir, ".amethyst/nwc_connection.txt") + } + // Simple file-based storage for last npub (non-sensitive data) private fun getLastNpub(): String? { val file = getPrefsFile() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt new file mode 100644 index 000000000..844851ceb --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -0,0 +1,279 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Desktop implementation of ICacheProvider. + * + * Provides in-memory caching of Users and Notes for the desktop application. + * Supports searching users by name prefix for the search functionality. + */ +class DesktopLocalCache : ICacheProvider { + private val users = ConcurrentHashMap() + private val notes = ConcurrentHashMap() + private val addressableNotes = ConcurrentHashMap() + private val deletedEvents = ConcurrentHashMap.newKeySet() + + private val eventStream = DesktopCacheEventStream() + + val paymentTracker = NwcPaymentTracker() + + // ----- User operations ----- + + override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] + + override fun getOrCreateUser(pubkey: HexKey): User = + users.getOrPut(pubkey) { + // Create placeholder notes for relay lists + val nip65Note = getOrCreateNote("nip65:$pubkey") + val dmNote = getOrCreateNote("dm:$pubkey") + User(pubkey, nip65Note, dmNote) + } + + override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) } + + override fun findUsersStartingWith( + prefix: String, + limit: Int, + ): List { + if (prefix.isBlank()) return emptyList() + + // Check if it's a valid pubkey/npub first + val pubkeyHex = decodePublicKeyAsHexOrNull(prefix) + if (pubkeyHex != null) { + val user = getUserIfExists(pubkeyHex) + if (user != null) return listOf(user) + } + + // Search by name/displayName/nip05/lud16 + return users.values + .filter { user -> + user.anyNameStartsWith(prefix) || + user.pubkeyHex.startsWith(prefix, ignoreCase = true) || + user.pubkeyNpub().startsWith(prefix, ignoreCase = true) + }.sortedWith( + compareBy( + { !it.toBestDisplayName().startsWith(prefix, ignoreCase = true) }, + { it.toBestDisplayName().lowercase() }, + { it.pubkeyHex }, + ), + ).take(limit) + } + + /** + * Updates user metadata from a MetadataEvent. + * Called when receiving kind 0 events from relays. + */ + fun consumeMetadata(event: MetadataEvent) { + val user = getOrCreateUser(event.pubKey) + + // Only update if newer + val currentMetadata = user.latestMetadata + if (currentMetadata == null || event.createdAt > currentMetadata.createdAt) { + user.latestMetadata = event + user.info = event.contactMetaData() + } + } + + // ----- NWC Payment operations ----- + + /** + * Consumes a NIP-47 payment request event. + * Registers the request with the tracker and links it to the zapped note. + * + * @param event The payment request event + * @param zappedNote The note being zapped (if this payment is for a zap) + * @param relay The relay this event came from + * @param onResponse Callback invoked when wallet responds + * @return true if event was processed, false if already seen + */ + fun consume( + event: LnZapPaymentRequestEvent, + zappedNote: Note?, + relay: NormalizedRelayUrl?, + onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + ): Boolean { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event + if (note.event != null) return false + + note.loadEvent(event, author, emptyList()) + relay?.let { note.addRelay(it) } + + zappedNote?.addZapPayment(note, null) + paymentTracker.registerRequest(event.id, zappedNote, onResponse) + + return true + } + + /** + * Consumes a NIP-47 payment response event. + * Matches to pending request, links notes, and invokes callback. + * + * @param event The payment response event + * @param relay The relay this event came from + * @return true if event was processed, false if no matching request + */ + fun consume( + event: LnZapPaymentResponseEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val requestId = event.requestId() + val pending = paymentTracker.onResponseReceived(requestId) ?: return false + + val requestNote = requestId?.let { getNoteIfExists(it) } + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event + if (note.event != null) return false + + note.loadEvent(event, author, emptyList()) + relay?.let { note.addRelay(it) } + + // Link response to zapped note via request + requestNote?.let { req -> pending.zappedNote?.addZapPayment(req, note) } + + // Invoke callback on IO dispatcher + GlobalScope.launch(Dispatchers.IO) { + pending.onResponse(event) + } + + return true + } + + // ----- Note operations ----- + + override fun getNoteIfExists(hexKey: HexKey): Note? = notes[hexKey] + + override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey) + + fun getOrCreateNote(hexKey: HexKey): Note = + notes.getOrPut(hexKey) { + Note(hexKey) + } + + override fun getOrCreateAddressableNote(key: Address): AddressableNote = + addressableNotes.getOrPut(key.toValue()) { + AddressableNote(key) + } + + // ----- Channel operations ----- + + override fun getAnyChannel(note: Note): Channel? { + // Desktop doesn't support channels yet + return null + } + + // ----- Deletion tracking ----- + + override fun hasBeenDeleted(event: Any): Boolean = + when (event) { + is Note -> deletedEvents.contains(event.idHex) + is Event -> deletedEvents.contains(event.id) + else -> false + } + + fun markAsDeleted(eventId: HexKey) { + deletedEvents.add(eventId) + } + + // ----- Own event consumption ----- + + override fun justConsumeMyOwnEvent(event: Event): Boolean { + // Desktop doesn't track own events separately + return false + } + + // ----- Event stream ----- + + override fun getEventStream(): ICacheEventStream = eventStream + + /** + * Emits a new note bundle to observers. + */ + suspend fun emitNewNotes(notes: Set) { + eventStream.emitNewNotes(notes) + } + + /** + * Emits deleted notes to observers. + */ + suspend fun emitDeletedNotes(notes: Set) { + eventStream.emitDeletedNotes(notes) + } + + // ----- Stats ----- + + fun userCount(): Int = users.size + + fun noteCount(): Int = notes.size + + fun clear() { + users.clear() + notes.clear() + addressableNotes.clear() + deletedEvents.clear() + } +} + +/** + * Desktop implementation of ICacheEventStream. + */ +class DesktopCacheEventStream : ICacheEventStream { + private val _newEventBundles = MutableSharedFlow>(replay = 0) + private val _deletedEventBundles = MutableSharedFlow>(replay = 0) + + override val newEventBundles: SharedFlow> = _newEventBundles + override val deletedEventBundles: SharedFlow> = _deletedEventBundles + + suspend fun emitNewNotes(notes: Set) { + _newEventBundles.emit(notes) + } + + suspend fun emitDeletedNotes(notes: Set) { + _deletedEventBundles.emit(notes) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index 18e7a9873..1b0a019ed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.network -import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt index 8ef7207a2..221cbf35d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt @@ -44,24 +44,27 @@ import kotlinx.coroutines.flow.asStateFlow open class RelayConnectionManager( websocketBuilder: WebsocketBuilder, ) : IRelayClientListener { - private val client = NostrClient(websocketBuilder) + private val _client = NostrClient(websocketBuilder) + + /** Exposes the underlying INostrClient for subscription coordinators */ + val client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient get() = _client private val _relayStatuses = MutableStateFlow>(emptyMap()) val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() - val connectedRelays: StateFlow> = client.connectedRelaysFlow() - val availableRelays: StateFlow> = client.availableRelaysFlow() + val connectedRelays: StateFlow> = _client.connectedRelaysFlow() + val availableRelays: StateFlow> = _client.availableRelaysFlow() init { - client.subscribe(this) + _client.subscribe(this) } fun connect() { - client.connect() + _client.connect() } fun disconnect() { - client.disconnect() + _client.disconnect() } fun addRelay(url: String): NormalizedRelayUrl? { @@ -85,18 +88,18 @@ open class RelayConnectionManager( listener: IRequestListener? = null, ) { val filterMap = relays.associateWith { filters } - client.openReqSubscription(subId, filterMap, listener) + _client.openReqSubscription(subId, filterMap, listener) } fun unsubscribe(subId: String) { - client.close(subId) + _client.close(subId) } fun send( event: Event, relays: Set = connectedRelays.value, ) { - client.send(event, relays) + _client.send(event, relays) } /** @@ -107,6 +110,61 @@ open class RelayConnectionManager( send(event, connected) } + /** + * Sends an event to a specific relay (for NWC). + * Adds the relay if not already in the list. + */ + fun sendToRelay( + relay: NormalizedRelayUrl, + event: Event, + ) { + if (relay !in availableRelays.value) { + updateRelayStatus(relay) { it.copy(connected = false, error = null) } + } + _client.send(event, setOf(relay)) + } + + /** + * Subscribes on a specific relay (for NWC). + * Adds the relay if not already in the list. + */ + fun subscribeOnRelay( + relay: NormalizedRelayUrl, + subId: String, + filters: List, + onEvent: (Event, NormalizedRelayUrl) -> Unit, + ) { + if (relay !in availableRelays.value) { + updateRelayStatus(relay) { it.copy(connected = false, error = null) } + } + val filterMap = mapOf(relay to filters) + _client.openReqSubscription( + subId = subId, + filters = filterMap, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onEvent(event, relay) + } + }, + ) + } + + /** + * Closes a subscription on a specific relay. + */ + fun closeSubscription( + relay: NormalizedRelayUrl, + subId: String, + ) { + _client.close(subId) + } + private fun updateRelayStatus( url: NormalizedRelayUrl, update: (RelayStatus) -> RelayStatus, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt new file mode 100644 index 000000000..2f9388e38 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nwc + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume + +/** + * Handles NIP-47 (Nostr Wallet Connect) payments for desktop. + * + * Flow: + * 1. Create payment request event with BOLT11 invoice + * 2. Register with tracker for persistent tracking in Note.zapPayments + * 3. Send to wallet's relay + * 4. Subscribe and wait for wallet response + * + * @param relayManager Manages relay connections for sending/subscribing + * @param localCache Cache for persistent payment tracking + */ +class NwcPaymentHandler( + private val relayManager: DesktopRelayConnectionManager, + private val localCache: DesktopLocalCache, +) { + sealed class PaymentResult { + data class Success( + val preimage: String?, + ) : PaymentResult() + + data class Error( + val message: String, + ) : PaymentResult() + + data object Timeout : PaymentResult() + } + + /** + * Sends a payment request via NWC and waits for response. + * Payment is tracked in Note.zapPayments for the zapped note. + * + * @param bolt11 The BOLT11 invoice to pay + * @param nwcConnection The NWC connection details (pubkey, relay, secret) + * @param zappedNote The note being zapped (for tracking in Note.zapPayments) + * @param timeoutMs How long to wait for payment response (default 60s) + * @return PaymentResult indicating success, error, or timeout + */ + suspend fun payInvoice( + bolt11: String, + nwcConnection: Nip47WalletConnect.Nip47URINorm, + zappedNote: Note? = null, + timeoutMs: Long = 60_000, + ): PaymentResult { + val secret = nwcConnection.secret ?: return PaymentResult.Error("NWC connection has no secret") + + // Create signer from NWC secret + val nwcSigner = NostrSignerInternal(KeyPair(secret.hexToByteArray())) + + // Create payment request event + val requestEvent = + LnZapPaymentRequestEvent.create( + lnInvoice = bolt11, + walletServicePubkey = nwcConnection.pubKeyHex, + signer = nwcSigner, + ) + + // Register request note in cache for tracking + val requestNote = localCache.getOrCreateNote(requestEvent.id) + requestNote.loadEvent(requestEvent, localCache.getOrCreateUser(requestEvent.pubKey), emptyList()) + requestNote.addRelay(nwcConnection.relayUri) + + // Link to zapped note for persistent tracking + zappedNote?.addZapPayment(requestNote, null) + + // Send request to wallet's relay + relayManager.sendToRelay(nwcConnection.relayUri, requestEvent) + + // Subscribe and wait for response with timeout + return withTimeoutOrNull(timeoutMs) { + waitForResponse(requestEvent.id, nwcConnection, nwcSigner, zappedNote, requestNote) + } ?: PaymentResult.Timeout + } + + private suspend fun waitForResponse( + requestId: String, + nwcConnection: Nip47WalletConnect.Nip47URINorm, + nwcSigner: NostrSignerInternal, + zappedNote: Note?, + requestNote: Note, + ): PaymentResult = + suspendCancellableCoroutine { continuation -> + val filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(nwcConnection.pubKeyHex), + tags = mapOf("e" to listOf(requestId)), + ) + + val subId = "nwc-response-${requestId.take(8)}" + + relayManager.subscribeOnRelay( + relay = nwcConnection.relayUri, + subId = subId, + filters = listOf(filter), + onEvent = { event, relay -> + if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) { + // Unsubscribe + relayManager.closeSubscription(nwcConnection.relayUri, subId) + + // Store response note and link to zapped note + val responseNote = localCache.getOrCreateNote(event.id) + responseNote.loadEvent(event, localCache.getOrCreateUser(event.pubKey), emptyList()) + responseNote.addRelay(relay) + zappedNote?.addZapPayment(requestNote, responseNote) + + // Decrypt and process response + try { + kotlinx.coroutines.runBlocking { + val response = event.decrypt(nwcSigner) + val result = processResponse(response) + if (continuation.isActive) { + continuation.resume(result) + } + } + } catch (e: Exception) { + if (continuation.isActive) { + continuation.resume(PaymentResult.Error("Failed to decrypt response: ${e.message}")) + } + } + } + }, + ) + + continuation.invokeOnCancellation { + relayManager.closeSubscription(nwcConnection.relayUri, subId) + } + } + + private fun processResponse(response: Response): PaymentResult = + when (response) { + is PayInvoiceSuccessResponse -> { + PaymentResult.Success(response.result?.preimage) + } + is PayInvoiceErrorResponse -> { + PaymentResult.Error(response.error?.message ?: "Unknown error") + } + else -> { + PaymentResult.Error("Unexpected response type: ${response.resultType}") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt new file mode 100644 index 000000000..5076561bd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.subscriptions + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator +import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader +import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope + +/** + * Desktop-specific relay subscriptions coordinator. + * Manages metadata and reactions loading with rate limiting and prioritization. + * + * This coordinator ensures: + * - Display names and avatars load before reactions + * - Metadata requests are rate-limited (20/sec) to avoid relay flooding + * - Subscriptions are batched efficiently + * + * Usage: + * ``` + * val coordinator = DesktopRelaySubscriptionsCoordinator( + * client = relayManager.client, + * scope = viewModelScope, + * indexRelays = relayManager.availableRelays.value, + * ) + * coordinator.start() + * + * // In screens: + * LaunchedEffect(notes) { + * coordinator.loadMetadataForNotes(notes) + * } + * ``` + */ +class DesktopRelaySubscriptionsCoordinator( + private val client: INostrClient, + private val scope: CoroutineScope, + private val indexRelays: Set, + private val localCache: DesktopLocalCache, +) { + // Rate limiter: 20 requests per second to avoid flooding relays + private val rateLimiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope) + + // Preloader handles metadata + avatar prefetching + private val preloader = MetadataPreloader(rateLimiter, imagePrefetcher = null) + + // Feed metadata coordinator with priority queue + val feedMetadata = + FeedMetadataCoordinator( + client = client, + scope = scope, + indexRelays = indexRelays, + preloader = preloader, + onEvent = { event, _ -> + // Consume metadata events into local cache + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + + /** + * Start the coordinator. + * Call once when app starts or user logs in. + */ + fun start() { + // Start rate limiter to process queued metadata requests + rateLimiter.start { pubkey -> + // When rate limiter dequeues a pubkey, subscribe to its metadata + client.openReqSubscription( + filters = + indexRelays.associateWith { + listOf( + com.vitorpamplona.quartz.nip01Core.relay.filters.Filter( + kinds = listOf(com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.KIND), + authors = listOf(pubkey), + limit = 1, + ), + ) + }, + ) + } + + // Start feed metadata coordinator + feedMetadata.start() + } + + /** + * Load metadata and reactions for notes. + * Delegates to FeedMetadataCoordinator. + */ + fun loadMetadataForNotes(notes: List) { + feedMetadata.loadMetadataForNotes(notes) + } + + /** + * Load metadata for specific pubkeys. + */ + fun loadMetadataForPubkeys(pubkeys: List) { + feedMetadata.loadMetadataForPubkeys(pubkeys) + } + + /** + * Load reactions for specific notes. + */ + fun loadReactionsForNotes(noteIds: List) { + feedMetadata.loadReactionsForNotes(noteIds) + } + + /** + * Clear all queued requests. + * Call when switching accounts or during cleanup. + */ + fun clear() { + feedMetadata.clear() + rateLimiter.reset() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index 016d2eff4..5a1c59677 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -131,3 +131,187 @@ fun createThreadRepliesSubscription( onEvent = onEvent, onEose = onEose, ) + +/** + * Creates a NIP-50 search subscription for user profiles. + * Requires NIP-50 compatible relays (e.g., relay.nostr.band, nostr.wine). + * + * @param searchQuery Text to search for in user profiles + * @param limit Maximum results to return + */ +fun createSearchPeopleSubscription( + relays: Set, + searchQuery: String, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (searchQuery.isBlank()) return null + + return SubscriptionConfig( + subId = generateSubId("search-people-${searchQuery.take(8)}"), + filters = listOf(FilterBuilders.searchPeople(searchQuery, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a NIP-50 search subscription for text notes. + * Requires NIP-50 compatible relays. + * + * @param searchQuery Text to search for in notes + * @param limit Maximum results to return + */ +fun createSearchNotesSubscription( + relays: Set, + searchQuery: String, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (searchQuery.isBlank()) return null + + return SubscriptionConfig( + subId = generateSubId("search-notes-${searchQuery.take(8)}"), + filters = listOf(FilterBuilders.searchNotes(searchQuery, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription for zap receipts (kind 9735) for specific events. + * + * @param eventIds Event IDs to get zaps for + * @param limit Maximum zaps per event + */ +fun createZapsSubscription( + relays: Set, + eventIds: List, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (eventIds.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("zaps-${eventIds.first().take(8)}"), + filters = listOf(FilterBuilders.zapsForEvents(eventIds, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription for reactions (kind 7) for specific events. + * + * @param eventIds Event IDs to get reactions for + * @param limit Maximum reactions per event + */ +fun createReactionsSubscription( + relays: Set, + eventIds: List, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (eventIds.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("reactions-${eventIds.first().take(8)}"), + filters = listOf(FilterBuilders.reactionsForEvents(eventIds, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription for replies (kind 1) to specific events. + * + * @param eventIds Event IDs to get replies for + * @param limit Maximum replies per event + */ +fun createRepliesSubscription( + relays: Set, + eventIds: List, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (eventIds.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("replies-${eventIds.first().take(8)}"), + filters = listOf(FilterBuilders.repliesForEvents(eventIds, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription for reposts (kind 6) of specific events. + * + * @param eventIds Event IDs to get reposts for + * @param limit Maximum reposts per event + */ +fun createRepostsSubscription( + relays: Set, + eventIds: List, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (eventIds.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("reposts-${eventIds.first().take(8)}"), + filters = listOf(FilterBuilders.repostsForEvents(eventIds, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for global long-form content (kind 30023, NIP-23). + */ +fun createLongFormFeedSubscription( + relays: Set, + limit: Int = 30, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("longform-feed"), + filters = listOf(FilterBuilders.longFormGlobal(limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) + +/** + * Creates a subscription config for long-form content from followed users. + */ +fun createFollowingLongFormFeedSubscription( + relays: Set, + followedUsers: List, + limit: Int = 30, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (followedUsers.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("longform-following"), + filters = listOf(FilterBuilders.longFormFromAuthors(followedUsers, limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index cd0766e10..4baeb1fe5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -83,6 +83,18 @@ object FilterBuilders { limit = 1, ) + /** + * Creates a filter for user metadata (kind 0) from multiple authors. + * + * @param pubKeyHexList List of author public keys (hex-encoded, 64 chars each) + * @return Filter for user metadata + */ + fun userMetadataBatch(pubKeyHexList: List): Filter = + Filter( + kinds = listOf(0), // MetadataEvent.KIND + authors = pubKeyHexList, + ) + /** * Creates a filter for contact list (kind 3) from a specific author. * @@ -256,6 +268,155 @@ object FilterBuilders { since = since, until = until, ) + + /** + * Creates a NIP-50 search filter for user metadata (kind 0). + * Searches user profiles by name, displayName, about, nip05, etc. + * Requires a NIP-50 compatible relay (e.g., relay.nostr.band, nostr.wine). + * + * @param searchQuery The text to search for in user profiles + * @param limit Maximum number of results to return + * @return Filter for NIP-50 search + */ + fun searchPeople( + searchQuery: String, + limit: Int = 50, + ): Filter = + Filter( + kinds = listOf(0), // MetadataEvent.KIND + search = searchQuery, + limit = limit, + ) + + /** + * Creates a NIP-50 search filter for text notes (kind 1). + * Searches note content. + * Requires a NIP-50 compatible relay. + * + * @param searchQuery The text to search for in notes + * @param limit Maximum number of results to return + * @return Filter for NIP-50 search + */ + fun searchNotes( + searchQuery: String, + limit: Int = 50, + ): Filter = + Filter( + kinds = listOf(1), // TextNoteEvent.KIND + search = searchQuery, + limit = limit, + ) + + /** + * Creates a filter for zap receipts (kind 9735) for specific events. + * + * @param eventIds List of event IDs to get zaps for + * @param limit Maximum number of events to request + * @return Filter for zap receipts + */ + fun zapsForEvents( + eventIds: List, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(9735), // LnZapEvent.KIND + tags = mapOf("e" to eventIds), + limit = limit, + ) + + /** + * Creates a filter for reactions (kind 7) for specific events. + * + * @param eventIds List of event IDs to get reactions for + * @param limit Maximum number of events to request + * @return Filter for reactions + */ + fun reactionsForEvents( + eventIds: List, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(7), // ReactionEvent.KIND + tags = mapOf("e" to eventIds), + limit = limit, + ) + + /** + * Creates a filter for replies (kind 1) to specific events. + * + * @param eventIds List of event IDs to get replies for + * @param limit Maximum number of events to request + * @return Filter for replies + */ + fun repliesForEvents( + eventIds: List, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(1), // TextNoteEvent.KIND + tags = mapOf("e" to eventIds), + limit = limit, + ) + + /** + * Creates a filter for reposts (kind 6) of specific events. + * + * @param eventIds List of event IDs to get reposts for + * @param limit Maximum number of events to request + * @return Filter for reposts + */ + fun repostsForEvents( + eventIds: List, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(6), // RepostEvent.KIND + tags = mapOf("e" to eventIds), + limit = limit, + ) + + /** + * Creates a filter for long-form content (kind 30023, NIP-23). + * + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter for long-form content + */ + fun longFormGlobal( + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + kinds = listOf(30023), // LongTextNoteEvent.KIND + limit = limit, + since = since, + until = until, + ) + + /** + * Creates a filter for long-form content (kind 30023) from specific authors. + * + * @param authors List of author public keys (hex-encoded, 64 chars each) + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter for long-form content from specified authors + */ + fun longFormFromAuthors( + authors: List, + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + kinds = listOf(30023), // LongTextNoteEvent.KIND + authors = authors, + limit = limit, + since = since, + until = until, + ) } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt index be45a7911..f8524bf03 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt @@ -26,20 +26,50 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * Creates a subscription config for user metadata (kind 0). + * Returns null if the pubKeyHex is invalid (not 64 characters). */ fun createMetadataSubscription( relays: Set, pubKeyHex: String, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, -): SubscriptionConfig = - SubscriptionConfig( +): SubscriptionConfig? { + // Validate pubkey length + if (pubKeyHex.length != 64) { + return null + } + return SubscriptionConfig( subId = generateSubId("meta-${pubKeyHex.take(8)}"), filters = listOf(FilterBuilders.userMetadata(pubKeyHex)), relays = relays, onEvent = onEvent, onEose = onEose, ) +} + +/** + * Creates a subscription config for metadata of multiple users (kind 0). + * Useful for batch-fetching author profiles. + * Filters out any invalid pubkeys (not 64 characters). + */ +fun createBatchMetadataSubscription( + relays: Set, + pubKeyHexList: List, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + // Filter out invalid pubkeys + val validPubkeys = pubKeyHexList.filter { it.length == 64 } + if (validPubkeys.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("meta-batch-${validPubkeys.size}"), + filters = listOf(FilterBuilders.userMetadataBatch(validPubkeys)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} /** * Creates a subscription config for user posts (kind 1). diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt new file mode 100644 index 000000000..23c52fb12 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -0,0 +1,347 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.launch + +private enum class BookmarkTab { PUBLIC, PRIVATE } + +/** + * Screen displaying user's bookmarked notes (public and private). + */ +@Composable +fun BookmarksScreen( + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, +) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val scope = rememberCoroutineScope() + + // Tab state + var selectedTab by remember { mutableStateOf(BookmarkTab.PUBLIC) } + + // State for bookmark list + var bookmarkList by remember { mutableStateOf(null) } + var publicBookmarkIds by remember { mutableStateOf>(emptyList()) } + var privateBookmarkIds by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var hasReceivedEose by remember { mutableStateOf(false) } + + // State for fetched bookmark events + val publicEventState = + remember(account.pubKeyHex) { + EventCollectionState( + getId = { it.id }, + maxSize = 100, + scope = scope, + ) + } + val publicEvents by publicEventState.items.collectAsState() + + val privateEventState = + remember(account.pubKeyHex) { + EventCollectionState( + getId = { it.id }, + maxSize = 100, + scope = scope, + ) + } + val privateEvents by privateEventState.items.collectAsState() + + // Load metadata for bookmark authors via coordinator + LaunchedEffect(publicEvents, privateEvents, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null) { + val pubkeys = (publicEvents + privateEvents).map { it.pubKey }.distinct() + if (pubkeys.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys) + } + } + } + + // Subscribe to user's bookmark list (kind 30001) + rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + SubscriptionConfig( + subId = "bookmarks-list-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + // Extract public bookmarked event IDs + val pubIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + publicBookmarkIds = pubIds + } + }, + onEose = { _, _ -> + hasReceivedEose = true + isLoading = false + }, + ) + } else { + isLoading = false + null + } + } + + // Decrypt private bookmarks when bookmark list changes + LaunchedEffect(bookmarkList) { + bookmarkList?.let { list -> + scope.launch { + try { + val privateBookmarks = list.privateBookmarks(account.signer) + val privIds = + privateBookmarks + ?.filterIsInstance() + ?.map { it.eventId } + ?: emptyList() + privateBookmarkIds = privIds + } catch (e: Exception) { + println("Failed to decrypt private bookmarks: ${e.message}") + privateBookmarkIds = emptyList() + } + } + } + } + + // Subscribe to fetch the actual public bookmarked events + rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) { + publicEventState.clear() + SubscriptionConfig( + subId = "public-bookmarked-events-${System.currentTimeMillis()}", + filters = + listOf( + FilterBuilders.byIds(publicBookmarkIds), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + publicEventState.addItem(event) + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Subscribe to fetch the actual private bookmarked events + rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) { + privateEventState.clear() + SubscriptionConfig( + subId = "private-bookmarked-events-${System.currentTimeMillis()}", + filters = + listOf( + FilterBuilders.byIds(privateBookmarkIds), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + privateEventState.addItem(event) + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents + val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds + + Column(modifier = Modifier.fillMaxSize()) { + // Header with tabs + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Bookmarks", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.weight(1f)) + + // Tab selector + Row( + horizontalArrangement = + androidx.compose.foundation.layout.Arrangement + .spacedBy(8.dp), + ) { + FilterChip( + selected = selectedTab == BookmarkTab.PUBLIC, + onClick = { selectedTab = BookmarkTab.PUBLIC }, + label = { Text("Public (${publicBookmarkIds.size})") }, + ) + FilterChip( + selected = selectedTab == BookmarkTab.PRIVATE, + onClick = { selectedTab = BookmarkTab.PRIVATE }, + label = { Text("Private (${privateBookmarkIds.size})") }, + ) + } + } + + // Content + when { + isLoading && !hasReceivedEose -> { + LoadingState(message = "Loading bookmarks...") + } + currentBookmarkIds.isEmpty() && hasReceivedEose -> { + EmptyState( + title = if (selectedTab == BookmarkTab.PUBLIC) "No public bookmarks" else "No private bookmarks", + description = + if (selectedTab == BookmarkTab.PUBLIC) { + "Bookmark notes publicly to save them here" + } else { + "Private bookmarks are encrypted and only visible to you" + }, + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(currentEvents, key = { it.id }) { event -> + Column( + modifier = + Modifier.clickable { + onNavigateToThread(event.id) + }, + ) { + NoteCard( + note = event.toNoteDisplayData(localCache), + onAuthorClick = onNavigateToProfile, + ) + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onNavigateToThread(event.id) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + isBookmarked = true, + bookmarkList = bookmarkList, + onBookmarkChanged = { newList -> + bookmarkList = newList + // Update public bookmark IDs + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + publicBookmarkIds = pubIds + + // Decrypt and update private bookmark IDs + scope.launch { + try { + val privateBookmarks = newList.privateBookmarks(account.signer) + val privIds = + privateBookmarks + ?.filterIsInstance() + ?.map { it.eventId } + ?: emptyList() + privateBookmarkIds = privIds + } catch (e: Exception) { + // Keep existing private IDs if decryption fails + } + } + + // Remove unbookmarked event from appropriate list + if (!pubIds.contains(event.id)) { + publicEventState.removeItem(event.id) + } + if (!privateBookmarkIds.contains(event.id)) { + privateEventState.removeItem(event.id) + } + }, + ) + } + HorizontalDivider(thickness = 1.dp) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 954864647..38b01f339 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull @@ -28,7 +30,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub /** * Extension to convert Event to NoteDisplayData for the shared NoteCard. */ -fun Event.toNoteDisplayData(): NoteDisplayData { +fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData { val npub = try { pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." @@ -36,10 +38,13 @@ fun Event.toNoteDisplayData(): NoteDisplayData { pubKey.take(16) + "..." } + val pictureUrl = (cache?.getUserIfExists(pubKey) as? User)?.profilePicture() + return NoteDisplayData( id = id, pubKeyHex = pubKey, pubKeyDisplay = npub, + profilePictureUrl = pictureUrl, content = content, createdAt = createdAt, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 79a92ddbe..76261ef03 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,17 +54,33 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent /** * Note card with action buttons. @@ -72,11 +89,23 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent fun FeedNoteCard( event: Event, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, onReply: () -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + zapReceipts: List = emptyList(), + reactionCount: Int = 0, + replyCount: Int = 0, + repostCount: Int = 0, + bookmarkList: BookmarkListEvent? = null, + isBookmarked: Boolean = false, + onBookmarkChanged: (BookmarkListEvent) -> Unit = {}, ) { + val zapAmountSats = zapReceipts.sumOf { it.amountSats } + Column( modifier = Modifier.clickable { @@ -84,7 +113,7 @@ fun FeedNoteCard( }, ) { NoteCard( - note = event.toNoteDisplayData(), + note = event.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, ) @@ -93,9 +122,21 @@ fun FeedNoteCard( NoteActionsRow( event = event, relayManager = relayManager, + localCache = localCache, account = account, + nwcConnection = nwcConnection, onReplyClick = onReply, + onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = zapReceipts.size, + zapAmountSats = zapAmountSats, + zapReceipts = zapReceipts, + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + bookmarkList = bookmarkList, + isBookmarked = isBookmarked, + onBookmarkChanged = onBookmarkChanged, ) } } @@ -104,10 +145,14 @@ fun FeedNoteCard( @Composable fun FeedScreen( relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -123,8 +168,23 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } - var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } + var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } + var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } + // Track reaction event IDs per target event to deduplicate + var reactionIdsByEvent by remember { mutableStateOf>>(emptyMap()) } + val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } + // Track reply/repost event IDs per target event to deduplicate + var replyIdsByEvent by remember { mutableStateOf>>(emptyMap()) } + val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } + var repostIdsByEvent by remember { mutableStateOf>>(emptyMap()) } + val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // Track EOSE to know when initial load is complete + var eoseReceivedCount by remember { mutableStateOf(0) } + val initialLoadComplete = eoseReceivedCount > 0 // Load followed users for Following feed mode rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { @@ -144,9 +204,45 @@ fun FeedScreen( } } - // Clear events when feed mode changes + // Load user's bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = "bookmarks-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + // Extract public bookmarked event IDs + val pubIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Clear events and reset EOSE when feed mode changes remember(feedMode) { eventState.clear() + eoseReceivedCount = 0 } // Subscribe to feed based on mode @@ -161,8 +257,15 @@ fun FeedScreen( createGlobalFeedSubscription( relays = configuredRelays, onEvent = { event, _, _, _ -> + // Store metadata events in cache + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } eventState.addItem(event) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } FeedMode.FOLLOWING -> { @@ -171,8 +274,15 @@ fun FeedScreen( relays = configuredRelays, followedUsers = followedUsers.toList(), onEvent = { event, _, _, _ -> + // Store metadata events in cache + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } eventState.addItem(event) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } else { null @@ -181,6 +291,181 @@ fun FeedScreen( } } + // Subscribe to zaps for visible events + val eventIds = events.map { it.id } + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { + return@rememberSubscription null + } + + createZapsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent) { + val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription + val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription + zapsByEvent = + zapsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptyList() + if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { + this[targetEventId] = existing + receipt + } + } + } + }, + ) + } + + // Subscribe to metadata for zap senders (to show display names) + val zapSenderPubkeys = + zapsByEvent.values + .flatten() + .map { it.senderPubKey } + .distinct() + rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) { + return@rememberSubscription null + } + + // Only fetch metadata for users we don't have yet + val missingPubkeys = + zapSenderPubkeys.filter { pubkey -> + localCache.getUserIfExists(pubkey)?.info == null + } + if (missingPubkeys.isEmpty()) { + return@rememberSubscription null + } + + createBatchMetadataSubscription( + relays = configuredRelays, + pubKeyHexList = missingPubkeys, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + } + + // Subscribe to reactions for visible events + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { + return@rememberSubscription null + } + + createReactionsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is ReactionEvent) { + val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription + reactionIdsByEvent = + reactionIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to replies for visible events + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { + return@rememberSubscription null + } + + createRepliesSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + // Find the event this is replying to + val replyToId = + event.tags + .filter { it.size >= 2 && it[0] == "e" } + .lastOrNull() + ?.get(1) ?: return@createRepliesSubscription + if (replyToId in eventIds) { + replyIdsByEvent = + replyIdsByEvent.toMutableMap().apply { + val existing = this[replyToId] ?: emptySet() + this[replyToId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to reposts for visible events + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { + return@rememberSubscription null + } + + createRepostsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is RepostEvent) { + val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription + repostIdsByEvent = + repostIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to metadata for note authors (to enable zaps and populate search cache) + val authorPubkeys = events.map { it.pubKey }.distinct() + + // Use coordinator for rate-limited metadata loading (preferred) + LaunchedEffect(authorPubkeys, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys) + } + } + + // Fallback subscription if coordinator not available + rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { + // Skip if using coordinator + if (subscriptionsCoordinator != null) { + return@rememberSubscription null + } + + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) { + return@rememberSubscription null + } + + // Only fetch metadata for users we don't have yet + val missingPubkeys = + authorPubkeys.filter { pubkey -> + localCache.getUserIfExists(pubkey)?.info == null + } + if (missingPubkeys.isEmpty()) { + return@rememberSubscription null + } + + createBatchMetadataSubscription( + relays = configuredRelays, + pubKeyHexList = missingPubkeys, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + } + Column(modifier = Modifier.fillMaxSize()) { // Header with compose button Row( @@ -204,12 +489,18 @@ fun FeedScreen( Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { FilterChip( selected = feedMode == FeedMode.GLOBAL, - onClick = { feedMode = FeedMode.GLOBAL }, + onClick = { + feedMode = FeedMode.GLOBAL + DesktopPreferences.feedMode = FeedMode.GLOBAL + }, label = { Text("Global") }, ) FilterChip( selected = feedMode == FeedMode.FOLLOWING, - onClick = { feedMode = FeedMode.FOLLOWING }, + onClick = { + feedMode = FeedMode.FOLLOWING + DesktopPreferences.feedMode = FeedMode.FOLLOWING + }, label = { Text("Following") }, ) } @@ -262,13 +553,23 @@ fun FeedScreen( LoadingState("Connecting to relays...") } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { LoadingState("Loading followed users...") - } else if (events.isEmpty()) { - LoadingState( - if (feedMode == FeedMode.FOLLOWING) { - "No notes from followed users yet" - } else { - "Loading notes..." - }, + } else if (events.isEmpty() && !initialLoadComplete) { + LoadingState("Loading notes...") + } else if (events.isEmpty() && initialLoadComplete) { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users" + } else { + "No notes found" + }, + description = + if (feedMode == FeedMode.FOLLOWING) { + "Notes from people you follow will appear here" + } else { + "Notes from the network will appear here" + }, + onRefresh = { relayManager.connect() }, ) } else { LazyColumn( @@ -278,10 +579,29 @@ fun FeedScreen( FeedNoteCard( event = event, relayManager = relayManager, + localCache = localCache, account = account, + nwcConnection = nwcConnection, onReply = { replyToEvent = event }, + onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, + zapReceipts = zapsByEvent[event.id] ?: emptyList(), + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index bf32e84b5..8aeafd2df 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -37,14 +37,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.SharedRes +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop +import com.vitorpamplona.amethyst.commons.resources.login_title import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard -import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Composable fun LoginScreen( @@ -61,7 +63,7 @@ fun LoginScreen( verticalArrangement = Arrangement.Center, ) { Text( - stringResource(SharedRes.strings.login_title), + stringResource(Res.string.login_title), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onBackground, ) @@ -69,7 +71,7 @@ fun LoginScreen( Spacer(Modifier.height(8.dp)) Text( - stringResource(SharedRes.strings.login_subtitle_desktop), + stringResource(Res.string.login_subtitle_desktop), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 2a96f8399..ccf6ab061 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -20,18 +20,34 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.clickable 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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -39,30 +55,459 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.Bookmark +import com.vitorpamplona.amethyst.commons.icons.BookmarkFilled import com.vitorpamplona.amethyst.commons.icons.Reply import com.vitorpamplona.amethyst.commons.icons.Repost +import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction +import com.vitorpamplona.amethyst.commons.model.nip51Bookmarks.BookmarkAction +import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction +import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume + +private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L) /** - * Action buttons row for a note (react, reply, repost). + * Feedback from a zap operation for UI display. + */ +sealed class ZapFeedback { + data class Success( + val amountSats: Long, + ) : ZapFeedback() + + data class ExternalWallet( + val amountSats: Long, + ) : ZapFeedback() + + data class Error( + val message: String, + ) : ZapFeedback() + + data object Timeout : ZapFeedback() + + data class NoLightningAddress( + val pubKey: String, + ) : ZapFeedback() +} + +/** + * Data class representing a zap receipt for display. + */ +data class ZapReceipt( + val senderPubKey: String, + val amountSats: Long, + val message: String?, + val createdAt: Long, +) + +/** + * Converts an LnZapEvent to a ZapReceipt for display. + */ +fun LnZapEvent.toZapReceipt(localCache: DesktopLocalCache): ZapReceipt? { + val senderPubKey = zappedRequestAuthor() ?: return null + val amountSats = amount?.toLong() ?: return null + + return ZapReceipt( + senderPubKey = senderPubKey, + amountSats = amountSats, + message = zapRequest?.content?.ifBlank { null }, + createdAt = createdAt, + ) +} + +/** + * Gets display name for a pubkey, looking up from cache. + * Falls back to shortened npub if not found. + */ +fun getDisplayName( + pubKey: String, + localCache: DesktopLocalCache, +): String { + val user = localCache.getUserIfExists(pubKey) + return user?.info?.bestName() + ?: pubKey.hexToByteArrayOrNull()?.toNpub()?.let { npub -> + npub.take(12) + "..." + npub.takeLast(6) + } + ?: pubKey.take(12) + "..." +} + +/** + * Dialog for selecting zap amount and optional message. + */ +@Composable +fun ZapAmountDialog( + onDismiss: () -> Unit, + onZap: (Long, String) -> Unit, +) { + var selectedAmount by remember { mutableStateOf(21L) } + var message by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Zap") }, + text = { + Column { + Text( + "Select amount in sats", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ZAP_AMOUNTS.take(3).forEach { amount -> + FilterChip( + selected = selectedAmount == amount, + onClick = { selectedAmount = amount }, + label = { Text("$amount") }, + ) + } + } + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ZAP_AMOUNTS.drop(3).forEach { amount -> + FilterChip( + selected = selectedAmount == amount, + onClick = { selectedAmount = amount }, + label = { Text(formatSats(amount)) }, + ) + } + } + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.OutlinedTextField( + value = message, + onValueChange = { message = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Message (optional)") }, + placeholder = { Text("Add a comment...") }, + singleLine = false, + maxLines = 3, + ) + } + }, + confirmButton = { + Button(onClick = { onZap(selectedAmount, message) }) { + Text("Zap ${formatSats(selectedAmount)} sats") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +private fun formatSats(amount: Long): String = if (amount >= 1000) "${amount / 1000}k" else "$amount" + +/** + * Dialog for choosing bookmark visibility (public or private). + */ +@Composable +fun BookmarkDialog( + onDismiss: () -> Unit, + onBookmark: (isPrivate: Boolean) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Bookmark") }, + text = { + Column { + Text( + "Choose bookmark visibility", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = true, + onClick = { onBookmark(false) }, + label = { Text("Public") }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = false, + onClick = { onBookmark(true) }, + label = { Text("Private") }, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + "Private bookmarks are encrypted and only visible to you.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +/** + * Dialog for displaying zap receipts. + * Automatically loads missing user metadata when opened. + */ +@Composable +fun ZapReceiptsDialog( + receipts: List, + totalAmount: Long, + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + onDismiss: () -> Unit, +) { + var isLoading by remember { mutableStateOf(false) } + // Trigger recomposition when metadata loads + var metadataVersion by remember { mutableIntStateOf(0) } + + // Find users without metadata and load them + LaunchedEffect(receipts) { + val pubKeysNeedingMetadata = + receipts + .map { it.senderPubKey } + .distinct() + .filter { pubKey -> + val user = localCache.getUserIfExists(pubKey) + user?.info == null + } + + if (pubKeysNeedingMetadata.isNotEmpty()) { + isLoading = true + fetchMetadataForUsers(pubKeysNeedingMetadata, relayManager, localCache) { + metadataVersion++ + } + isLoading = false + } + } + + // Force read metadataVersion to trigger recomposition + @Suppress("UNUSED_EXPRESSION") + metadataVersion + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Zap, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text("${formatSats(totalAmount)} sats") + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + } + }, + text = { + if (receipts.isEmpty()) { + Text( + "No zaps yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + receipts.sortedByDescending { it.amountSats }.take(10).forEach { receipt -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = getDisplayName(receipt.senderPubKey, localCache), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!receipt.message.isNullOrBlank()) { + Text( + text = receipt.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + Text( + text = "${formatSats(receipt.amountSats)} sats", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + if (receipts.size > 10) { + Text( + text = "and ${receipts.size - 10} more...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + }, + ) +} + +/** + * Fetches metadata for multiple users in a single subscription. + */ +private suspend fun fetchMetadataForUsers( + pubKeys: List, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + onMetadataLoaded: () -> Unit, +) = withContext(Dispatchers.IO) { + if (pubKeys.isEmpty()) return@withContext + + val subId = "metadata-zaps-${pubKeys.hashCode()}" + val relays = relayManager.connectedRelays.value + val remaining = pubKeys.toMutableSet() + + val filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = pubKeys, + ), + ) + + suspendCancellableCoroutine { continuation -> + val timeoutJob = + kotlinx.coroutines.GlobalScope.launch { + kotlinx.coroutines.delay(5000) // 5 second timeout + if (continuation.isActive) { + relayManager.unsubscribe(subId) + continuation.resume(Unit) + } + } + + relayManager.subscribe( + subId = subId, + filters = filters, + relays = relays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + remaining.remove(event.pubKey) + onMetadataLoaded() + + // All metadata loaded + if (remaining.isEmpty() && continuation.isActive) { + timeoutJob.cancel() + relayManager.unsubscribe(subId) + continuation.resume(Unit) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Wait for all relays or timeout + } + }, + ) + + continuation.invokeOnCancellation { + timeoutJob.cancel() + relayManager.unsubscribe(subId) + } + } +} + +/** + * Action buttons row for a note (react, reply, repost, zap, bookmark). */ @Composable fun NoteActionsRow( event: Event, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn, onReplyClick: () -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, modifier: Modifier = Modifier, + zapCount: Int = 0, + zapAmountSats: Long = 0, + zapReceipts: List = emptyList(), + reactionCount: Int = 0, + replyCount: Int = 0, + repostCount: Int = 0, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, + isBookmarked: Boolean = false, + bookmarkList: BookmarkListEvent? = null, + onBookmarkChanged: (BookmarkListEvent) -> Unit = {}, ) { var isLiked by remember { mutableStateOf(false) } var isReposted by remember { mutableStateOf(false) } + var localReactionCount by remember(reactionCount) { mutableStateOf(reactionCount) } + var localRepostCount by remember(repostCount) { mutableStateOf(repostCount) } + var isZapping by remember { mutableStateOf(false) } + var showZapDialog by remember { mutableStateOf(false) } + var showZapReceiptsDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() Row( @@ -70,70 +515,184 @@ fun NoteActionsRow( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Reply button - IconButton( - onClick = onReplyClick, - modifier = Modifier.size(32.dp), - ) { - Icon( - Reply, - contentDescription = "Reply", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) + // Reply button with count + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = onReplyClick, + modifier = Modifier.size(32.dp), + ) { + Icon( + Reply, + contentDescription = "Reply", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + if (replyCount > 0) { + Text( + text = "$replyCount", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - // Like button - IconButton( - onClick = { - if (!isLiked) { - scope.launch { - reactToNote( - event = event, - reaction = "+", - account = account, - relayManager = relayManager, + // Like button with count + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { + if (!isLiked) { + scope.launch { + reactToNote( + event = event, + reaction = "+", + account = account, + relayManager = relayManager, + ) + isLiked = true + localReactionCount++ + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = if (isLiked) "Unlike" else "Like", + tint = + if (isLiked) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + if (localReactionCount > 0) { + Text( + text = "$localReactionCount", + style = MaterialTheme.typography.labelSmall, + color = if (isLiked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Repost button with count + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { + if (!isReposted) { + scope.launch { + repostNote( + event = event, + account = account, + relayManager = relayManager, + ) + isReposted = true + localRepostCount++ + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Repost, + contentDescription = "Repost", + tint = + if (isReposted) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + if (localRepostCount > 0) { + Text( + text = "$localRepostCount", + style = MaterialTheme.typography.labelSmall, + color = if (isReposted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Zap button with amount (clickable to show receipts) + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { + if (isZapping) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + IconButton( + onClick = { showZapDialog = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Zap, + contentDescription = "Zap", + tint = + if (zapAmountSats > 0) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), ) - isLiked = true } } - }, - modifier = Modifier.size(32.dp), - ) { - Icon( - if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, - contentDescription = if (isLiked) "Unlike" else "Like", - tint = - if (isLiked) { - MaterialTheme.colorScheme.error + } + if (zapAmountSats > 0) { + Text( + text = formatSats(zapAmountSats), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { showZapReceiptsDialog = true }, + ) + } + } + + // Bookmark button + var isBookmarking by remember { mutableStateOf(false) } + var localIsBookmarked by remember(isBookmarked) { mutableStateOf(isBookmarked) } + var showBookmarkDialog by remember { mutableStateOf(false) } + + IconButton( + onClick = { + if (!isBookmarking) { + if (localIsBookmarked) { + // Remove bookmark immediately + scope.launch { + isBookmarking = true + val newBookmarkList = + removeBookmark( + event = event, + bookmarkList = bookmarkList, + account = account, + relayManager = relayManager, + ) + if (newBookmarkList != null) { + localIsBookmarked = false + onBookmarkChanged(newBookmarkList) + } + isBookmarking = false + } } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) - } - - // Repost button - IconButton( - onClick = { - if (!isReposted) { - scope.launch { - repostNote( - event = event, - account = account, - relayManager = relayManager, - ) - isReposted = true + // Show dialog to choose public/private + showBookmarkDialog = true } } }, modifier = Modifier.size(32.dp), + enabled = !isBookmarking, ) { Icon( - Repost, - contentDescription = "Repost", + if (localIsBookmarked) BookmarkFilled else Bookmark, + contentDescription = if (localIsBookmarked) "Remove bookmark" else "Bookmark", tint = - if (isReposted) { + if (localIsBookmarked) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -142,11 +701,111 @@ fun NoteActionsRow( ) } - // Placeholder for action count - Text( - "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Bookmark dialog + if (showBookmarkDialog) { + BookmarkDialog( + onDismiss = { showBookmarkDialog = false }, + onBookmark = { isPrivate -> + showBookmarkDialog = false + scope.launch { + isBookmarking = true + val newBookmarkList = + addBookmark( + event = event, + bookmarkList = bookmarkList, + isPrivate = isPrivate, + account = account, + relayManager = relayManager, + ) + if (newBookmarkList != null) { + localIsBookmarked = true + onBookmarkChanged(newBookmarkList) + } + isBookmarking = false + } + }, + ) + } + + // Overflow menu (three dots) + var showOverflowMenu by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { showOverflowMenu = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "More options", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Copy Note Link") }, + onClick = { + val noteLink = "nostr:${NNote.create(event.id)}" + copyToClipboard(noteLink) + showOverflowMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Copy Event Link") }, + onClick = { + val relays = relayManager.connectedRelays.value.take(3) + val neventLink = "nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}" + copyToClipboard(neventLink) + showOverflowMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Copy Event ID") }, + onClick = { + copyToClipboard(event.id) + showOverflowMenu = false + }, + ) + } + } + } + + // Zap amount selection dialog + if (showZapDialog) { + ZapAmountDialog( + onDismiss = { showZapDialog = false }, + onZap = { amountSats, message -> + showZapDialog = false + scope.launch { + isZapping = true + val feedback = + zapNote( + event = event, + account = account, + relayManager = relayManager, + localCache = localCache, + amountSats = amountSats, + message = message, + nwcConnection = nwcConnection, + ) + isZapping = false + onZapFeedback(feedback) + } + }, + ) + } + + // Zap receipts dialog + if (showZapReceiptsDialog) { + ZapReceiptsDialog( + receipts = zapReceipts, + totalAmount = zapAmountSats, + localCache = localCache, + relayManager = relayManager, + onDismiss = { showZapReceiptsDialog = false }, ) } } @@ -161,14 +820,81 @@ private suspend fun reactToNote( relayManager: DesktopRelayConnectionManager, ) { withContext(Dispatchers.IO) { - // Use shared ReactionAction from commons val signedEvent = ReactionAction.reactTo(event, reaction, account.signer) - - // Broadcast to all relays relayManager.broadcastToAll(signedEvent) } } +/** + * Adds an event to bookmarks (public or private). + * Returns the new bookmark list event, or null if operation failed. + */ +private suspend fun addBookmark( + event: Event, + bookmarkList: BookmarkListEvent?, + isPrivate: Boolean, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +): BookmarkListEvent? = + withContext(Dispatchers.IO) { + try { + val newBookmarkList = + if (bookmarkList != null) { + BookmarkAction.addBookmark( + existingList = bookmarkList, + eventId = event.id, + isPrivate = isPrivate, + signer = account.signer, + ) + } else { + BookmarkAction.createWithBookmark( + eventId = event.id, + isPrivate = isPrivate, + signer = account.signer, + ) + } + + // Broadcast to all relays + relayManager.broadcastToAll(newBookmarkList) + + newBookmarkList + } catch (e: Exception) { + println("Failed to add bookmark: ${e.message}") + null + } + } + +/** + * Removes an event from bookmarks (checks both public and private). + * Returns the new bookmark list event, or null if operation failed. + */ +private suspend fun removeBookmark( + event: Event, + bookmarkList: BookmarkListEvent?, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +): BookmarkListEvent? = + withContext(Dispatchers.IO) { + try { + if (bookmarkList == null) return@withContext null + + val newBookmarkList = + BookmarkAction.removeBookmark( + existingList = bookmarkList, + eventId = event.id, + signer = account.signer, + ) + + // Broadcast to all relays + relayManager.broadcastToAll(newBookmarkList) + + newBookmarkList + } catch (e: Exception) { + println("Failed to remove bookmark: ${e.message}") + null + } + } + /** * Creates a repost event and broadcasts to relays. */ @@ -178,10 +904,199 @@ private suspend fun repostNote( relayManager: DesktopRelayConnectionManager, ) { withContext(Dispatchers.IO) { - // Use shared RepostAction from commons val signedEvent = RepostAction.repost(event, account.signer) - - // Broadcast to all relays relayManager.broadcastToAll(signedEvent) } } + +/** + * Creates a zap request and pays via NWC or opens external wallet. + * Returns feedback for UI display. + */ +private suspend fun zapNote( + event: Event, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + amountSats: Long, + message: String = "", + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, +): ZapFeedback = + withContext(Dispatchers.IO) { + // Get author's lightning address from cache + var user = localCache.getUserIfExists(event.pubKey) + var lnAddress = user?.info?.lud16 ?: user?.info?.lud06 + + // TODO: Use UserFinderFilterAssemblerSubscription pattern from Amethyst + // to proactively load metadata when zap button is displayed. + // For now, fetch on-demand if missing. + if (lnAddress == null) { + lnAddress = fetchUserLightningAddress(event.pubKey, relayManager, localCache) + } + + if (lnAddress == null) { + return@withContext ZapFeedback.NoLightningAddress(event.pubKey.take(8)) + } + + // Create HTTP client and resolver + val httpClient = + OkHttpClient + .Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + val resolver = LightningAddressResolver(httpClient) + + // Get relay URLs for zap request + val relays = relayManager.connectedRelays.value + + // Fetch invoice + val result = + ZapAction.fetchZapInvoice( + targetEvent = event, + lnAddress = lnAddress, + amountSats = amountSats, + message = message, + relays = relays, + signer = account.signer, + resolver = resolver, + ) + + when (result) { + is ZapAction.ZapResult.Invoice -> { + // Pay via NWC if configured, otherwise open external wallet + if (nwcConnection != null) { + // Get/create Note for tracking the payment + val zappedNote = localCache.getOrCreateNote(event.id) + if (zappedNote.event == null) { + zappedNote.loadEvent(event, localCache.getOrCreateUser(event.pubKey), emptyList()) + } + + val paymentHandler = NwcPaymentHandler(relayManager, localCache) + when (val paymentResult = paymentHandler.payInvoice(result.bolt11, nwcConnection, zappedNote)) { + is NwcPaymentHandler.PaymentResult.Success -> { + ZapFeedback.Success(amountSats) + } + is NwcPaymentHandler.PaymentResult.Error -> { + ZapFeedback.Error(paymentResult.message) + } + is NwcPaymentHandler.PaymentResult.Timeout -> { + ZapFeedback.Timeout + } + } + } else { + // Fallback: open lightning: URI in external wallet + openLightningUri(result.bolt11) + ZapFeedback.ExternalWallet(amountSats) + } + } + is ZapAction.ZapResult.Error -> { + ZapFeedback.Error(result.message) + } + } + } + +private fun openLightningUri(bolt11: String) { + val uri = "lightning:$bolt11" + try { + val os = System.getProperty("os.name").lowercase() + val command = + when { + os.contains("mac") -> arrayOf("open", uri) + os.contains("win") -> arrayOf("cmd", "/c", "start", uri) + else -> arrayOf("xdg-open", uri) // Linux + } + Runtime.getRuntime().exec(command) + } catch (e: Exception) { + println("Failed to open lightning URI: ${e.message}") + println("Invoice: $bolt11") + } +} + +/** + * Fetches user metadata on-demand to get lightning address. + * Returns the lightning address if found, null otherwise. + */ +private suspend fun fetchUserLightningAddress( + pubKey: String, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, +): String? = + suspendCancellableCoroutine { continuation -> + val relays = relayManager.connectedRelays.value + if (relays.isEmpty()) { + continuation.resume(null) + return@suspendCancellableCoroutine + } + + val subId = "meta-zap-${pubKey.take(8)}" + var resumed = false + + // Set timeout + val timeoutJob = + kotlinx.coroutines.GlobalScope.launch { + kotlinx.coroutines.delay(5000) // 5 second timeout + if (!resumed) { + resumed = true + relayManager.unsubscribe(subId) + continuation.resume(null) + } + } + + val filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(pubKey), + limit = 1, + ), + ) + + // Subscribe to fetch metadata + relayManager.subscribe( + subId = subId, + filters = filters, + relays = relays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is MetadataEvent && !resumed) { + localCache.consumeMetadata(event) + val user = localCache.getUserIfExists(pubKey) + val lnAddress = user?.info?.lud16 ?: user?.info?.lud06 + if (lnAddress != null && !resumed) { + resumed = true + timeoutJob.cancel() + relayManager.unsubscribe(subId) + continuation.resume(lnAddress) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // If we get EOSE without finding address, wait for timeout or other relays + } + }, + ) + + continuation.invokeOnCancellation { + timeoutJob.cancel() + relayManager.unsubscribe(subId) + } + } + +/** + * Copies text to the system clipboard. + */ +private fun copyToClipboard(text: String) { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 82b75329a..59ac06f2c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -39,10 +39,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -50,11 +53,13 @@ import com.vitorpamplona.amethyst.commons.icons.Reply import com.vitorpamplona.amethyst.commons.icons.Repost import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip01Core.core.Event @@ -105,6 +110,7 @@ sealed class NotificationItem( fun NotificationsScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -120,6 +126,18 @@ fun NotificationsScreen( } val notifications by notificationState.items.collectAsState() + // Load metadata for notification authors via coordinator + LaunchedEffect(notifications, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && notifications.isNotEmpty()) { + val pubkeys = notifications.map { it.event.pubKey }.distinct() + subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys) + } + } + + // Track EOSE to know when initial load is complete + var eoseReceivedCount by remember { mutableStateOf(0) } + val initialLoadComplete = eoseReceivedCount > 0 + // Subscribe to notifications rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -168,6 +186,9 @@ fun NotificationsScreen( notificationState.addItem(notification) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } else { null @@ -185,8 +206,14 @@ fun NotificationsScreen( if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") - } else if (notifications.isEmpty()) { + } else if (notifications.isEmpty() && !initialLoadComplete) { LoadingState("Loading notifications...") + } else if (notifications.isEmpty() && initialLoadComplete) { + EmptyState( + title = "No notifications yet", + description = "When someone interacts with your posts, you'll see it here", + onRefresh = { relayManager.connect() }, + ) } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt new file mode 100644 index 000000000..81b415e1f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -0,0 +1,364 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingLongFormFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + +private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000)) + +/** + * Card displaying long-form content (NIP-23) with title, summary, and image. + */ +@Composable +fun LongFormCard( + event: LongTextNoteEvent, + localCache: DesktopLocalCache, + onAuthorClick: (String) -> Unit = {}, + onClick: () -> Unit = {}, +) { + val author = localCache.getUserIfExists(event.pubKey) + val authorName = author?.info?.bestName() ?: event.pubKey.take(8) + val publishedAt = event.publishedAt() ?: event.createdAt + + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Title + event.title()?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + } + + // Summary + event.summary()?.let { summary -> + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(12.dp)) + } + + // Footer with author and date + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = authorName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { onAuthorClick(event.pubKey) }, + ) + + Text( + text = formatDate(publishedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Topics/hashtags + val topics = event.topics() + if (topics.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + topics.take(3).forEach { topic -> + Text( + text = "#$topic", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + } + } +} + +@Composable +fun ReadsScreen( + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn? = null, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToArticle: (String) -> Unit = {}, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val scope = rememberCoroutineScope() + + val eventState = + remember { + EventCollectionState( + getId = { it.id }, + sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt }, + maxSize = 100, + scope = scope, + ) + } + val events by eventState.items.collectAsState() + + var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } + var followedUsers by remember { mutableStateOf>(emptySet()) } + var eoseReceivedCount by remember { mutableStateOf(0) } + val initialLoadComplete = eoseReceivedCount > 0 + + // Load followed users for Following feed mode + rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { + createContactListSubscription( + relays = configuredRelays, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, _, _ -> + if (event is ContactListEvent) { + followedUsers = event.verifiedFollowKeySet() + } + }, + ) + } else { + null + } + } + + // Clear events when feed mode changes + remember(feedMode) { + eventState.clear() + eoseReceivedCount = 0 + } + + // Subscribe to long-form content feed + rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty()) { + return@rememberSubscription null + } + + when (feedMode) { + FeedMode.GLOBAL -> { + createLongFormFeedSubscription( + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent) { + eventState.addItem(event) + } + }, + onEose = { _, _ -> + eoseReceivedCount++ + }, + ) + } + FeedMode.FOLLOWING -> { + if (followedUsers.isNotEmpty()) { + createFollowingLongFormFeedSubscription( + relays = configuredRelays, + followedUsers = followedUsers.toList(), + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent) { + eventState.addItem(event) + } + }, + onEose = { _, _ -> + eoseReceivedCount++ + }, + ) + } else { + null + } + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Reads", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + // Feed mode selector + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { feedMode = FeedMode.GLOBAL }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { feedMode = FeedMode.FOLLOWING }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } + } + + Spacer(Modifier.height(8.dp)) + + when { + connectedRelays.isEmpty() -> { + LoadingState("Connecting to relays...") + } + feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty() -> { + LoadingState("Loading followed users...") + } + events.isEmpty() && !initialLoadComplete -> { + LoadingState("Loading articles...") + } + events.isEmpty() && initialLoadComplete -> { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No articles from followed users" + } else { + "No articles found" + }, + description = + if (feedMode == FeedMode.FOLLOWING) { + "Long-form articles from people you follow will appear here" + } else { + "Long-form articles from the network will appear here" + }, + onRefresh = { relayManager.connect() }, + ) + } + else -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(events, key = { it.id }) { event -> + LongFormCard( + event = event, + localCache = localCache, + onAuthorClick = onNavigateToProfile, + onClick = { onNavigateToArticle(event.id) }, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt new file mode 100644 index 000000000..4f3bd7164 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -0,0 +1,453 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.search.SearchResult +import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +@Composable +fun SearchScreen( + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + onNavigateToHashtag: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val searchState = remember { SearchBarState(localCache, scope) } + val focusRequester = remember { FocusRequester() } + val relayStatuses by relayManager.relayStatuses.collectAsState() + + // Collect state from SearchBarState + val searchText by searchState.searchText.collectAsState() + val bech32Results by searchState.bech32Results.collectAsState() + val cachedUserResults by searchState.cachedUserResults.collectAsState() + val relaySearchResults by searchState.relaySearchResults.collectAsState() + val isSearchingRelays by searchState.isSearchingRelays.collectAsState() + + // NIP-50 relay search when local cache has few/no results + rememberSubscription(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty()) return@rememberSubscription null + + // Only search relays if we have a real query and limited local results + if (searchState.shouldSearchRelays) { + searchState.startRelaySearch() + createSearchPeopleSubscription( + relays = configuredRelays, + searchQuery = searchText, + limit = 20, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + val user = localCache.getUserIfExists(event.pubKey) + if (user != null) { + searchState.addRelaySearchResult(user) + } + } + }, + onEose = { _, _ -> + searchState.endRelaySearch() + }, + ) + } else { + null + } + } + + // Subscribe to metadata for searched users (to populate cache) + rememberSubscription(relayStatuses, searchText, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || searchText.length < 2) { + return@rememberSubscription null + } + + // If it's a specific pubkey search, fetch that user's metadata + val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) + if (pubkeyHex != null) { + createMetadataSubscription( + relays = configuredRelays, + pubKeyHex = pubkeyHex, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + } else { + null + } + } + + // Auto-focus the search field + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = modifier.fillMaxSize(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Search", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + "${localCache.userCount()} users cached", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Search input field + OutlinedTextField( + value = searchText, + onValueChange = { searchState.updateSearchText(it) }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text("Search by name, npub, nevent, or #hashtag") }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (searchText.isNotEmpty()) { + IconButton(onClick = { searchState.clearSearch() }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + Spacer(Modifier.height(16.dp)) + + // Results + val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty() + + if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) { + Text( + "No matches found. Try a name, npub, nevent, or #hashtag.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } else if (isSearchingRelays && !hasResults) { + Text( + "Searching relays...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } else if (hasResults) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Bech32/hex results first + if (bech32Results.isNotEmpty()) { + item { + Text( + "Direct lookup", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + items(bech32Results) { result -> + SearchResultCard( + result = result, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onNavigateToHashtag = onNavigateToHashtag, + ) + } + } + + // Cached user results + if (cachedUserResults.isNotEmpty()) { + if (bech32Results.isNotEmpty()) { + item { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + } + } + item { + Text( + "Cached users (${cachedUserResults.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user -> + UserSearchCard( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } + } + + // Relay search results (NIP-50) + if (relaySearchResults.isNotEmpty()) { + if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) { + item { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + } + } + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "From relays (${relaySearchResults.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + if (isSearchingRelays) { + Text( + "searching...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user -> + UserSearchCard( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } + } else if (isSearchingRelays && cachedUserResults.isEmpty()) { + item { + Text( + "Searching relays...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } + } + } else { + // Empty state + Column( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Search for users or notes", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Enter a name or Nostr identifier:", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + SearchHint("vitor", "Search by name") + SearchHint("npub1...", "User profile") + SearchHint("note1...", "Single note") + SearchHint("nevent1...", "Note with metadata") + SearchHint("#hashtag", "Hashtag search") + } + } + } + } +} + +@Composable +private fun SearchHint( + identifier: String, + description: String, +) { + Row( + modifier = Modifier.fillMaxWidth(0.6f), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + identifier, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(16.dp)) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SearchResultCard( + result: SearchResult, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + onNavigateToHashtag: (String) -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable { + when (result) { + is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex) + is SearchResult.CachedUserResult -> onNavigateToProfile(result.user.pubkeyHex) + is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex) + is SearchResult.AddressResult -> { + onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}") + } + is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag) + } + }, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = + when (result) { + is SearchResult.UserResult -> Icons.Default.Person + is SearchResult.CachedUserResult -> Icons.Default.Person + is SearchResult.NoteResult -> Icons.Default.Description + is SearchResult.AddressResult -> Icons.Default.Description + is SearchResult.HashtagResult -> Icons.Default.Tag + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + when (result) { + is SearchResult.UserResult -> "User Profile" + is SearchResult.CachedUserResult -> result.user.toBestDisplayName() + is SearchResult.NoteResult -> "Note" + is SearchResult.AddressResult -> "Event (kind ${result.kind})" + is SearchResult.HashtagResult -> "#${result.hashtag}" + }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + when (result) { + is SearchResult.UserResult -> result.displayId + is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex() + is SearchResult.NoteResult -> result.displayId + is SearchResult.AddressResult -> result.displayId + is SearchResult.HashtagResult -> "Search posts with this hashtag" + }, + style = MaterialTheme.typography.bodySmall, + fontFamily = if (result is SearchResult.HashtagResult) null else FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Navigate", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 4b6390f42..d30d19960 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,15 +52,29 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent /** * Desktop Thread Screen - displays a note and all its replies in a thread view. @@ -70,10 +85,15 @@ import com.vitorpamplona.quartz.nip01Core.core.Event fun ThreadScreen( noteId: String, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, + onReply: (Event) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -97,6 +117,71 @@ fun ThreadScreen( // Cache for calculating reply levels val levelCache = remember(noteId) { mutableMapOf() } + // Track EOSE to know when initial load is complete + var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } + var repliesEoseReceived by remember(noteId) { mutableStateOf(false) } + + // Track zaps per event + var zapsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } + // Track reaction event IDs per target event to deduplicate + var reactionIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } + val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } + // Track reply/repost event IDs per target event to deduplicate + var replyIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } + val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } + var repostIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } + val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } + + // Bookmark state + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // Load metadata for thread authors via coordinator + LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null) { + val pubkeys = mutableListOf() + rootNote?.let { pubkeys.add(it.pubKey) } + pubkeys.addAll(replyEvents.map { it.pubKey }) + if (pubkeys.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct()) + } + } + } + + // Subscribe to user's bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = "thread-bookmarks-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + val pubIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Subscribe to the root note rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -110,6 +195,9 @@ fun ThreadScreen( levelCache[event.id] = 0 } }, + onEose = { _, _ -> + rootNoteEoseReceived = true + }, ) } else { null @@ -126,12 +214,115 @@ fun ThreadScreen( onEvent = { event, _, _, _ -> replyEventState.addItem(event) }, + onEose = { _, _ -> + repliesEoseReceived = true + }, ) } else { null } } + // Subscribe to zaps for thread events + val allEventIds = listOf(noteId) + replyEvents.map { it.id } + rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + return@rememberSubscription null + } + + createZapsSubscription( + relays = configuredRelays, + eventIds = allEventIds, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent) { + val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription + val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription + zapsByEvent = + zapsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptyList() + if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { + this[targetEventId] = existing + receipt + } + } + } + }, + ) + } + + // Subscribe to reactions for thread events + rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + return@rememberSubscription null + } + + createReactionsSubscription( + relays = configuredRelays, + eventIds = allEventIds, + onEvent = { event, _, _, _ -> + if (event is ReactionEvent) { + val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription + reactionIdsByEvent = + reactionIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to replies for thread events (for counts) + rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + return@rememberSubscription null + } + + createRepliesSubscription( + relays = configuredRelays, + eventIds = allEventIds, + onEvent = { event, _, _, _ -> + val replyToId = + event.tags + .filter { it.size >= 2 && it[0] == "e" } + .lastOrNull() + ?.get(1) ?: return@createRepliesSubscription + if (replyToId in allEventIds) { + replyIdsByEvent = + replyIdsByEvent.toMutableMap().apply { + val existing = this[replyToId] ?: emptySet() + this[replyToId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to reposts for thread events + rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + return@rememberSubscription null + } + + createRepostsSubscription( + relays = configuredRelays, + eventIds = allEventIds, + onEvent = { event, _, _, _ -> + if (event is RepostEvent) { + val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription + repostIdsByEvent = + repostIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id + } + } + }, + ) + } + // Calculate reply level for an event based on e-tags fun calculateLevel(event: Event): Int { levelCache[event.id]?.let { return it } @@ -171,8 +362,15 @@ fun ThreadScreen( if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") - } else if (rootNote == null) { + } else if (rootNote == null && !rootNoteEoseReceived) { LoadingState("Loading thread...") + } else if (rootNote == null && rootNoteEoseReceived) { + EmptyState( + title = "Note not found", + description = "This note may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(0.dp), @@ -186,16 +384,38 @@ fun ThreadScreen( }, ) { NoteCard( - note = rootNote!!.toNoteDisplayData(), + note = rootNote!!.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, ) if (account != null) { + val rootZaps = zapsByEvent[noteId] ?: emptyList() NoteActionsRow( event = rootNote!!, relayManager = relayManager, + localCache = localCache, account = account, - onReplyClick = { /* TODO: Open reply dialog */ }, + nwcConnection = nwcConnection, + onReplyClick = { onReply(rootNote!!) }, + onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = rootZaps.size, + zapAmountSats = rootZaps.sumOf { it.amountSats }, + zapReceipts = rootZaps, + reactionCount = reactionsByEvent[noteId] ?: 0, + replyCount = repliesByEvent[noteId] ?: 0, + repostCount = repostsByEvent[noteId] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(noteId), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } @@ -223,16 +443,38 @@ fun ThreadScreen( }, ) { NoteCard( - note = event.toNoteDisplayData(), + note = event.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, ) if (account != null) { + val eventZaps = zapsByEvent[event.id] ?: emptyList() NoteActionsRow( event = event, relayManager = relayManager, + localCache = localCache, account = account, - onReplyClick = { /* TODO: Open reply dialog */ }, + nwcConnection = nwcConnection, + onReplyClick = { onReply(event) }, + onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = eventZaps.size, + zapAmountSats = eventZaps.sumOf { it.amountSats }, + zapReceipts = eventZaps, + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } @@ -240,7 +482,7 @@ fun ThreadScreen( } // Empty state for no replies - if (replyEvents.isEmpty()) { + if (replyEvents.isEmpty() && repliesEoseReceived) { item { Spacer(Modifier.height(32.dp)) Text( @@ -250,6 +492,16 @@ fun ThreadScreen( modifier = Modifier.padding(16.dp), ) } + } else if (replyEvents.isEmpty() && !repliesEoseReceived) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "Loading replies...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index d08286dee..9f96f4409 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -35,6 +35,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material3.Button @@ -46,6 +48,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,7 +65,11 @@ import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription @@ -72,8 +79,11 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection /** * User profile screen showing user info, follow button, and their posts. @@ -82,10 +92,14 @@ import kotlinx.coroutines.withContext fun UserProfileScreen( pubKeyHex: String, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -188,6 +202,61 @@ fun UserProfileScreen( } } + // Subscribe to profile user's contact list (for following count) + rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + createContactListSubscription( + relays = configuredRelays, + pubKeyHex = pubKeyHex, + onEvent = { event, _, _, _ -> + if (event is ContactListEvent) { + // Count the number of people this user follows + followingCount = event.verifiedFollowKeySet().size + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Track unique followers (authors of contact lists that tag this pubkey) + val followerAuthors = remember(pubKeyHex) { mutableSetOf() } + + // Subscribe to followers (contact lists that tag this user) + rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + // Clear previous followers when subscription restarts + followerAuthors.clear() + followersCount = 0 + + SubscriptionConfig( + subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}", + filters = + listOf( + FilterBuilders.byPTags( + pubKeys = listOf(pubKeyHex), + kinds = listOf(3), // ContactListEvent + limit = 500, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + // Count unique authors who follow this user + if (followerAuthors.add(event.pubKey)) { + followersCount = followerAuthors.size + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Subscribe to user posts rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -339,11 +408,49 @@ fun UserProfileScreen( fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(4.dp)) - Text( - (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(32) ?: pubKeyHex.take(32)) + "...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() + var copied by remember { mutableStateOf(false) } + + // Reset copied state after delay + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (npub != null) { + IconButton( + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(npub), null) + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + if (copied) Icons.Default.Check else Icons.Default.ContentCopy, + contentDescription = if (copied) "Copied" else "Copy npub", + modifier = Modifier.size(14.dp), + tint = + if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } } } @@ -461,8 +568,11 @@ fun UserProfileScreen( FeedNoteCard( event = event, relayManager = relayManager, + localCache = localCache, account = account, + nwcConnection = nwcConnection, onReply = onCompose, + onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt index b71a3077d..c5c44f333 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt @@ -42,8 +42,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.SharedRes -import dev.icerock.moko.resources.compose.stringResource +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.login_hide_key +import com.vitorpamplona.amethyst.commons.resources.login_key_label +import com.vitorpamplona.amethyst.commons.resources.login_key_placeholder +import com.vitorpamplona.amethyst.commons.resources.login_show_key +import org.jetbrains.compose.resources.stringResource /** * Text field for entering Nostr keys (nsec or npub) with visibility toggle. @@ -53,8 +57,8 @@ fun KeyInputField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, - label: String = stringResource(SharedRes.strings.login_key_label), - placeholder: String = stringResource(SharedRes.strings.login_key_placeholder), + label: String = stringResource(Res.string.login_key_label), + placeholder: String = stringResource(Res.string.login_key_placeholder), errorMessage: String? = null, ) { var showKey by remember { mutableStateOf(false) } @@ -76,7 +80,7 @@ fun KeyInputField( IconButton(onClick = { showKey = !showKey }) { Icon( if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showKey) stringResource(SharedRes.strings.login_hide_key) else stringResource(SharedRes.strings.login_show_key), + contentDescription = if (showKey) stringResource(Res.string.login_hide_key) else stringResource(Res.string.login_show_key), ) } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index 4271baaf7..50c3f13b5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -44,8 +44,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.SharedRes -import dev.icerock.moko.resources.compose.stringResource +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.login_button +import com.vitorpamplona.amethyst.commons.resources.login_card_subtitle +import com.vitorpamplona.amethyst.commons.resources.login_card_title +import com.vitorpamplona.amethyst.commons.resources.login_generate_button +import org.jetbrains.compose.resources.stringResource /** * Login card with Nostr key input field and action buttons. @@ -63,8 +67,8 @@ fun LoginCard( onGenerateNew: () -> Unit, modifier: Modifier = Modifier, cardWidth: Dp = 400.dp, - title: String = stringResource(SharedRes.strings.login_card_title), - subtitle: String = stringResource(SharedRes.strings.login_card_subtitle), + title: String = stringResource(Res.string.login_card_title), + subtitle: String = stringResource(Res.string.login_card_subtitle), ) { var keyInput by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } @@ -121,14 +125,14 @@ fun LoginCard( modifier = Modifier.weight(1f), enabled = keyInput.isNotBlank(), ) { - Text(stringResource(SharedRes.strings.login_button)) + Text(stringResource(Res.string.login_button)) } OutlinedButton( onClick = onGenerateNew, modifier = Modifier.weight(1f), ) { - Text(stringResource(SharedRes.strings.login_generate_button)) + Text(stringResource(Res.string.login_generate_button)) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt index b473580a4..6cf01a4c0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt @@ -37,8 +37,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.SharedRes -import dev.icerock.moko.resources.compose.stringResource +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.new_key_continue_button +import com.vitorpamplona.amethyst.commons.resources.new_key_public_label +import com.vitorpamplona.amethyst.commons.resources.new_key_secret_label +import com.vitorpamplona.amethyst.commons.resources.new_key_warning_message +import com.vitorpamplona.amethyst.commons.resources.new_key_warning_title +import org.jetbrains.compose.resources.stringResource /** * Warning card displayed after generating a new Nostr key pair. @@ -69,7 +74,7 @@ fun NewKeyWarningCard( modifier = Modifier.padding(24.dp), ) { Text( - stringResource(SharedRes.strings.new_key_warning_title), + stringResource(Res.string.new_key_warning_title), style = MaterialTheme.typography.titleMedium, color = Color.Red, ) @@ -77,7 +82,7 @@ fun NewKeyWarningCard( Spacer(Modifier.height(16.dp)) Text( - stringResource(SharedRes.strings.new_key_warning_message), + stringResource(Res.string.new_key_warning_message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) @@ -85,7 +90,7 @@ fun NewKeyWarningCard( Spacer(Modifier.height(16.dp)) Text( - stringResource(SharedRes.strings.new_key_public_label), + stringResource(Res.string.new_key_public_label), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -95,7 +100,7 @@ fun NewKeyWarningCard( nsec?.let { secretKey -> Text( - stringResource(SharedRes.strings.new_key_secret_label), + stringResource(Res.string.new_key_secret_label), style = MaterialTheme.typography.labelMedium, color = Color.Red, ) @@ -108,7 +113,7 @@ fun NewKeyWarningCard( onClick = onContinue, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(SharedRes.strings.new_key_continue_button)) + Text(stringResource(Res.string.new_key_continue_button)) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af0f81b35..3b9c94f9c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,6 @@ lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" media3 = "1.9.0" mockk = "1.14.7" -mokoResources = "0.25.2" kotlinx-coroutines-test = "1.10.2" navigationCompose = "2.9.6" okhttp = "5.3.2" @@ -139,8 +138,6 @@ markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "ric markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } -moko-resources = { group = "dev.icerock.moko", name = "resources", version.ref = "mokoResources" } -moko-resources-compose = { group = "dev.icerock.moko", name = "resources-compose", version.ref = "mokoResources" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } @@ -180,4 +177,3 @@ androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.lib vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.6" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } -mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt index 204e57f87..302c9e004 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.nipA3 +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -36,6 +37,8 @@ class PaymentTargetsEvent( companion object { const val KIND = 10133 + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + suspend fun updatePaymentTargets( earlierVersion: PaymentTargetsEvent, targets: List, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index b8ac14da8..018385c6b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -49,7 +49,6 @@ import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.serialization.json.JsonNull.content @Immutable class TextNoteEvent(