From ec629ac3fea7ca01a34133bce44ddf92855b20e8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 12 Jan 2026 06:34:14 +0200 Subject: [PATCH 01/38] feat: Extract Card interface and CardFeedState to commons (B1) - Create commons/ui/notifications/CardFeedState.kt with: - Card interface (createdAt, id) - CardFeedState sealed class (Loading, Loaded, Empty, FeedError) - DefaultCardComparator for sorting - Update Android imports to use commons Card/CardFeedState: - DefaultFeedOrder.kt - AccountViewModel.kt - CardFeedContentState.kt - CardFeedState.kt (now only contains concrete card types) - CardFeedView.kt Part of Phase B (Notifications Feed Extraction) for #1648 Co-Authored-By: Claude Opus 4.5 --- .../amethyst/ui/dal/DefaultFeedOrder.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../notifications/CardFeedContentState.kt | 2 + .../loggedIn/notifications/CardFeedState.kt | 27 +------ .../loggedIn/notifications/CardFeedView.kt | 2 + .../commons/ui/notifications/CardFeedState.kt | 78 +++++++++++++++++++ 6 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt 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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3072e0d18..0f7322162 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 @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers 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 @@ -82,7 +83,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 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/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() } From e1412c1f97c80db8e4d1b29c61ca842f2b35ae41 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 12 Jan 2026 06:52:33 +0200 Subject: [PATCH 02/38] fix: Add proper empty states with EOSE tracking - NotificationsScreen: Show "No notifications yet" after EOSE instead of infinite loading - FeedScreen: Show "No notes found" after EOSE for both Global and Following feeds - ThreadScreen: Show "Note not found" if root note missing after EOSE, "No replies yet" for empty threads Uses existing EmptyState component from commons. Tracks EOSE (End of Stored Events) from relays to distinguish between "still loading" and "no data". Co-Authored-By: Claude Opus 4.5 --- .../amethyst/desktop/ui/FeedScreen.kt | 38 +++++++++++++++---- .../desktop/ui/NotificationsScreen.kt | 18 ++++++++- .../amethyst/desktop/ui/ThreadScreen.kt | 32 +++++++++++++++- 3 files changed, 77 insertions(+), 11 deletions(-) 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 00cef6714..5cd4d73a7 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 @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscri import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData @@ -127,6 +128,10 @@ fun FeedScreen( var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } var followedUsers 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) { val configuredRelays = relayStatuses.keys @@ -145,9 +150,10 @@ fun FeedScreen( } } - // Clear events when feed mode changes + // Clear events and reset EOSE when feed mode changes remember(feedMode) { eventState.clear() + eoseReceivedCount = 0 } // Subscribe to feed based on mode @@ -164,6 +170,9 @@ fun FeedScreen( onEvent = { event, _, _, _ -> eventState.addItem(event) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } FeedMode.FOLLOWING -> { @@ -174,6 +183,9 @@ fun FeedScreen( onEvent = { event, _, _, _ -> eventState.addItem(event) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } else { null @@ -263,13 +275,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( 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 772f05219..77edace1c 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 @@ -41,8 +41,10 @@ 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.unit.dp @@ -53,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.subscriptions.createNotificationsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +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 @@ -120,6 +123,10 @@ fun NotificationsScreen( } val notifications by notificationState.items.collectAsState() + // 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 +175,9 @@ fun NotificationsScreen( notificationState.addItem(notification) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } else { null @@ -185,8 +195,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/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 0c469f085..30c436909 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 @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel @@ -98,6 +99,10 @@ 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) } + // Subscribe to the root note rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -111,6 +116,9 @@ fun ThreadScreen( levelCache[event.id] = 0 } }, + onEose = { _, _ -> + rootNoteEoseReceived = true + }, ) } else { null @@ -127,6 +135,9 @@ fun ThreadScreen( onEvent = { event, _, _, _ -> replyEventState.addItem(event) }, + onEose = { _, _ -> + repliesEoseReceived = true + }, ) } else { null @@ -172,8 +183,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), @@ -241,7 +259,7 @@ fun ThreadScreen( } // Empty state for no replies - if (replyEvents.isEmpty()) { + if (replyEvents.isEmpty() && repliesEoseReceived) { item { Spacer(Modifier.height(32.dp)) Text( @@ -251,6 +269,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), + ) + } } } } From 5ce4391db4505a1e224bee3934469e1727e3c194 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 10:29:06 +0100 Subject: [PATCH 03/38] guard encoder / decoder .stop --- .../amethyst/ui/actions/uploads/VoiceAnonymizer.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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..c8742fdb8 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 @@ -196,7 +196,11 @@ class VoiceAnonymizer { return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration) } finally { - decoder?.stop() + try { + decoder?.stop() + } catch (_: IllegalStateException) { + // Decoder was never started + } decoder?.release() extractor.release() } @@ -400,7 +404,11 @@ class VoiceAnonymizer { } } } finally { - encoder.stop() + try { + encoder.stop() + } catch (_: IllegalStateException) { + // Encoder was never started + } encoder.release() if (muxerStarted) { muxer.stop() From c6d437b98e367ae5f65c4744a6b41b255415a45e Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 10:47:03 +0100 Subject: [PATCH 04/38] confirmed: <1 results in Higher pitch. Adjusted NEUTRAL to slightly lower pitch --- .../amethyst/ui/actions/uploads/VoiceAnonymizationController.kt | 1 + .../vitorpamplona/amethyst/ui/actions/uploads/VoicePreset.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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..33adff8b6 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) { 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), } From 708c6da9fe85afabe1c2aac6822d38aa3646f0ab Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 10:52:58 +0100 Subject: [PATCH 05/38] add docs --- .../ui/actions/uploads/VoiceAnonymizer.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 c8742fdb8..7240c87ea 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, From 001dce8fef74644d8fbacbb46cb024743565817b Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 10:56:28 +0100 Subject: [PATCH 06/38] guard for null inputFile.parentFile --- .../amethyst/ui/actions/uploads/VoiceAnonymizer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 7240c87ea..db831a2ae 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 @@ -126,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( From 0b233f3a3f4a347dcade1183337d374e9b11496d Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 11:00:42 +0100 Subject: [PATCH 07/38] use colorScheme.onSurfaceVariant instead of Gray --- .../amethyst/ui/actions/uploads/VoiceAnonymizationSection.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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, ) From 50b0e8bd17e90c21056fadc8859496bf220c8a17 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 11:33:30 +0100 Subject: [PATCH 08/38] Moved processingPreset = preset to execute synchronously before launching the coroutine --- .../amethyst/ui/actions/uploads/VoiceAnonymizationController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 33adff8b6..fdb54523e 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 @@ -77,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) From db75a7c910c030fc15bd749ac11c9d8bcc3eeb98 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 11:40:38 +0100 Subject: [PATCH 09/38] preallocate list capacity --- .../amethyst/ui/actions/uploads/VoiceAnonymizer.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 db831a2ae..71838a176 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 @@ -172,7 +172,8 @@ class VoiceAnonymizer { decoder.configure(format, null, null, 0) decoder.start() - val pcmSamples = mutableListOf() + val estimatedSamples = (sampleRate.toLong() * durationUs / 1_000_000).toInt() + val pcmSamples = ArrayList(estimatedSamples) val bufferInfo = MediaCodec.BufferInfo() var inputDone = false var outputDone = false @@ -251,8 +252,8 @@ class VoiceAnonymizer { } else -> baseFactor } - val processedSamples = mutableListOf() val totalSamples = pcmData.size + val processedSamples = ArrayList(totalSamples) val wsola = WaveformSimilarityBasedOverlapAdd( From 5be16338438cfdd61a2bda46192006d3d012a3fd Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 12:59:25 +0100 Subject: [PATCH 10/38] limit voice recording to 180s --- .../amethyst/ui/actions/uploads/RecordAudio.kt | 7 +++++++ .../amethyst/ui/actions/uploads/RecordVoiceButton.kt | 6 +++++- .../com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 2 ++ .../ui/screen/loggedIn/home/ShortNotePostScreen.kt | 2 ++ .../amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt | 2 ++ 5 files changed, 18 insertions(+), 1 deletion(-) 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..c7fe883aa 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 = 180 + @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/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a2814a464..ffcb0a2d4 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 @@ -635,6 +636,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/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 4318f13c8..e61651c12 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 @@ -497,6 +498,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/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index a2573663b..7eeac6f77 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 @@ -51,6 +51,7 @@ 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.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection @@ -226,6 +227,7 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { onRecordTaken = { recording -> viewModel.selectRecording(recording) }, + maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, ) { isRecording, elapsedSeconds -> val contentColor = if (isRecording) { From 76a05f7d71e3efe42f4861bdc5113a8bc5caddf7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 13:56:49 +0100 Subject: [PATCH 11/38] moved re-record button inline and added to VoiceMessagePreview.kt --- .../ui/actions/uploads/VoiceMessagePreview.kt | 202 +++++++++++++----- .../loggedIn/home/ShortNotePostScreen.kt | 2 + .../screen/loggedIn/home/VoiceReplyScreen.kt | 98 +-------- 3 files changed, 148 insertions(+), 154 deletions(-) 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/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index e61651c12..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 @@ -375,6 +375,8 @@ private fun NewPostScreenBody( VoiceMessagePreview( voiceMetadata = displayMetadata, localFile = postViewModel.activeFile, + onReRecord = { recording -> postViewModel.selectVoiceRecording(recording) }, + isUploading = postViewModel.isUploadingVoice, onRemove = { postViewModel.removeVoiceMessage() }, ) 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 7eeac6f77..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,47 +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.MAX_VOICE_RECORD_SECONDS -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 @@ -104,9 +89,6 @@ fun VoiceReplyScreen( }, ) }, - bottomBar = { - ReRecordButton(viewModel) - }, ) { pad -> Surface( modifier = @@ -162,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() @@ -193,81 +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) - }, - maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, - ) { 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, - ) - } - } - } -} From 835fbb90b7da16175aa91803c3aeccd373e42097 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 14:00:13 +0100 Subject: [PATCH 12/38] Do something with the "Boolean" value returned by "delete". --- .../ui/actions/uploads/VoiceAnonymizationController.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 fdb54523e..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 @@ -109,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) From 40d694343909b760b77a9a562878c50631525e55 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 14:04:59 +0100 Subject: [PATCH 13/38] sonar fixes --- .../ui/actions/uploads/VoiceAnonymizer.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 71838a176..8c4210bc4 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 @@ -158,9 +158,7 @@ class VoiceAnonymizer { } } - if (audioTrackIndex == -1 || format == null) { - throw IllegalStateException("No audio track found in file") - } + check(audioTrackIndex != -1 && format != null) { "No audio track found in file" } extractor.selectTrack(audioTrackIndex) val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm" @@ -286,7 +284,9 @@ class VoiceAnonymizer { return true } - override fun processingFinished() {} + override fun processingFinished() { + // No-op: no cleanup needed + } } val dispatcher = @@ -310,7 +310,9 @@ class VoiceAnonymizer { return true } - override fun processingFinished() {} + override fun processingFinished() { + // No-op: no cleanup needed + } } dispatcher.addAudioProcessor(progressProcessor) @@ -484,5 +486,7 @@ private class FloatArrayAudioInputStream( return actualSkip.toLong() * 2 } - override fun close() {} + override fun close() { + // No-op: no cleanup needed + } } From 505a6583ee32dec83a146f6f79c42bedda325952 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 15 Jan 2026 14:20:05 +0100 Subject: [PATCH 14/38] remove unused imports --- .../main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt | 2 -- .../amethyst/commons/model/privateChats/Chatroom.kt | 1 - .../com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt | 1 - .../vitorpamplona/amethyst/commons/richtext/RichTextParser.kt | 1 - .../amethyst/desktop/network/DesktopRelayConnectionManager.kt | 1 - .../kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt | 1 - 6 files changed, 7 deletions(-) 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/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/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/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/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( From f2e484b18ef78ddf579e5ea27ba852f3a394c463 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 18 Jan 2026 20:04:27 +0100 Subject: [PATCH 15/38] URL videos: Downloads video to temp file using streaming (avoids OOM), shows progress indicator, shares via Intent, cleans up after 60 seconds Local videos: Shares directly without downloading Video format detection: Supports MP4, WebM, MKV, AVI, MOV based on file magic numbers --- .../amethyst/ui/components/ShareHelper.kt | 114 ++++++++++- .../ui/components/ZoomableContentView.kt | 189 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 3 + 3 files changed, 290 insertions(+), 16 deletions(-) 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..39d7c28ba 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,31 @@ 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 + // 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,18 +79,26 @@ 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) + + 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 { + // Image formats // JPEG: Check first 2 bytes matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" @@ -90,13 +113,45 @@ object ShareHelper { bytesRead >= 12 && matchesMagicNumbers(header, 8, WEBP_HEADER_END) -> "webp" - else -> DEFAULT_EXTENSION + // 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" + + // 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" + + // MP4/MOV: Check for ftyp box (bytes 4-7 should be "ftyp") + bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header) + + // 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 -> 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 +187,43 @@ 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, + ): Pair { + val extension = getVideoExtension(tempFile) + val timestamp = System.currentTimeMillis() + val sharableFile = File(context.cacheDir, "${SHARED_FILE_PREFIX}_$timestamp.$extension") + + try { + tempFile.renameTo(sharableFile) + } catch (e: Exception) { + Log.e(TAG, "Failed to rename temp video file for sharing", e) + throw e + } + + return Pair( + FileProvider.getUriForFile(context, "${context.packageName}.provider", sharableFile), + extension, + ) + } } 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..0127af4e3 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 @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent +import android.os.Handler +import android.os.Looper import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -32,6 +34,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,10 +111,16 @@ 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.Dispatchers 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 kotlin.time.Duration.Companion.seconds @Composable @@ -765,19 +774,75 @@ 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()) { + // State is read via Compose recomposition in text{} and enabled parameter + val isDownloading = remember { mutableStateOf(false) } + + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringRes(R.string.share_video)) + if (isDownloading.value) { + Spacer(modifier = Modifier.width(8.dp)) + LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp) + } + } + }, + enabled = !isDownloading.value, + onClick = { + isDownloading.value = true + scope.launch { + shareVideoFile( + context = context, + videoUrl = videoUri, + mimeType = mimeType, + okHttpClient = { url -> + accountViewModel.httpClientBuilder.okHttpClientForVideo(url) + }, + onComplete = { + isDownloading.value = false + onDismiss() + }, + onError = { + isDownloading.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 +875,118 @@ private suspend fun shareImageFile( } } +private suspend fun shareVideoFile( + context: Context, + videoUrl: String, + mimeType: String?, + okHttpClient: (String) -> OkHttpClient, + onComplete: () -> Unit, + onError: () -> Unit, +) { + val tempFile = ShareHelper.createTempVideoFile(context) + 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}" } + + // Stream the response to the temp file + tempFile.outputStream().use { outputStream -> + response.body.source().readAll(outputStream.sink()) + } + } + + // Prepare the temp file for sharing (determines extension and creates sharable URI) + val (uri, extension) = ShareHelper.prepareTempVideoForSharing(context, tempFile) + + // 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 after 60 seconds to allow the receiving app time to copy the file + Handler(Looper.getMainLooper()).postDelayed({ + tempFile.delete() + }, 60_000) + } + + 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() + + 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/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 From 1a04f16d1a923df780a29ed73189eb248ac169ad Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 18 Jan 2026 22:17:44 +0100 Subject: [PATCH 16/38] Safer temp video handling Cleanup deletes the actual shared file (and on error) instead of the old temp path in Guard against empty response.body Gate media-type detection so image sharing only checks image headers Added unit test for rename-failure fallback --- .../amethyst/ui/components/ShareHelper.kt | 97 ++++++++++++------- .../ui/components/ZoomableContentView.kt | 14 ++- .../amethyst/ui/components/ShareHelperTest.kt | 52 ++++++++++ 3 files changed, 125 insertions(+), 38 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt 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 39d7c28ba..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 @@ -37,6 +37,12 @@ object ShareHelper { private const val DEFAULT_VIDEO_EXTENSION = "mp4" private const val SHARED_FILE_PREFIX = "shared_media" + 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()) @@ -81,7 +87,7 @@ object ShareHelper { private fun getImageExtension(file: File): String = getMediaExtension(file, isVideo = false) - fun getVideoExtension(file: File): String = getMediaExtension(file, isVideo = true) + private fun getVideoExtension(file: File): String = getMediaExtension(file, isVideo = true) private fun getMediaExtension( file: File, @@ -97,43 +103,49 @@ object ShareHelper { return defaultExtension } - when { - // Image formats - // 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" - // 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" + else -> defaultExtension + } + } else { + when { + // Image formats + // JPEG: Check first 2 bytes + matchesMagicNumbers(header, 0, JPEG_MAGIC) -> "jpg" - // 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" + // PNG: Check first 4 bytes + matchesMagicNumbers(header, 0, PNG_MAGIC) -> "png" - // MP4/MOV: Check for ftyp box (bytes 4-7 should be "ftyp") - bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header) + // GIF: Check first 4 bytes for "GIF8" + matchesMagicNumbers(header, 0, GIF_MAGIC) -> "gif" - // 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" + // 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 + else -> defaultExtension + } } } } catch (e: IOException) { @@ -209,21 +221,36 @@ object ShareHelper { fun prepareTempVideoForSharing( context: Context, tempFile: File, - ): Pair { + ): SharableFile { val extension = getVideoExtension(tempFile) val timestamp = System.currentTimeMillis() val sharableFile = File(context.cacheDir, "${SHARED_FILE_PREFIX}_$timestamp.$extension") try { - tempFile.renameTo(sharableFile) + moveTempFileForSharing(tempFile, sharableFile) } catch (e: Exception) { Log.e(TAG, "Failed to rename temp video file for sharing", e) throw e } - return Pair( - FileProvider.getUriForFile(context, "${context.packageName}.provider", sharableFile), - extension, + 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/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 0127af4e3..48e0a2cfe 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 @@ -121,6 +121,7 @@ import okhttp3.Request import okhttp3.coroutines.executeAsync import okio.sink import java.io.File +import java.io.IOException import kotlin.time.Duration.Companion.seconds @Composable @@ -884,6 +885,7 @@ private suspend fun shareVideoFile( onError: () -> Unit, ) { val tempFile = ShareHelper.createTempVideoFile(context) + var sharedFile: File? = null try { withContext(Dispatchers.IO) { // Download video using streaming @@ -897,15 +899,20 @@ private suspend fun shareVideoFile( 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 -> - response.body.source().readAll(outputStream.sink()) + 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) = ShareHelper.prepareTempVideoForSharing(context, tempFile) + val (uri, extension, sharableFile) = ShareHelper.prepareTempVideoForSharing(context, tempFile) + sharedFile = sharableFile // Determine mime type val determinedMimeType = mimeType ?: "video/$extension" @@ -924,7 +931,7 @@ private suspend fun shareVideoFile( // Schedule cleanup after 60 seconds to allow the receiving app time to copy the file Handler(Looper.getMainLooper()).postDelayed({ - tempFile.delete() + sharableFile.delete() }, 60_000) } @@ -937,6 +944,7 @@ private suspend fun shareVideoFile( // Clean up temp file on error tempFile.delete() + sharedFile?.delete() withContext(Dispatchers.Main) { Toast 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() + } +} From 5450c7670ff51e88d05b3da896e8be4354c7da09 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 18 Jan 2026 22:31:31 +0100 Subject: [PATCH 17/38] update translations --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 3 +++ amethyst/src/main/res/values-de-rDE/strings.xml | 3 +++ amethyst/src/main/res/values-pt-rBR/strings.xml | 3 +++ amethyst/src/main/res/values-sv-rSE/strings.xml | 3 +++ 4 files changed, 12 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index e97ff5381..6f1e6eaa8 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1234,4 +1234,7 @@ Smazat seznam Smazat sadu doporučení Typy + Sdílet video… + Video nelze sdílet, zkuste to prosím později… + Stahování videa… diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index e2cfb90cb..a7fdc6cc6 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1238,4 +1238,7 @@ anz der Bedingungen ist erforderlich Liste löschen Empfehlungspaket löschen Typen + Video teilen… + Video kann nicht geteilt werden, bitte versuchen Sie es später erneut… + Video wird heruntergeladen… diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index a90421341..c9a50c779 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1234,4 +1234,7 @@ Excluir lista Excluir pacote de recomendações Tipos + Compartilhar vídeo… + Não foi possível compartilhar o vídeo, tente novamente mais tarde… + Baixando vídeo… diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 44571cb2a..f7eefcb4c 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1233,4 +1233,7 @@ Ta bort lista Ta bort rekommendationspaket Typer + Dela video… + Det gick inte att dela videon, försök igen senare… + Laddar ner videon… From 4897aa4d090144425eb38d18e4bce9777e1bdfb1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 18 Jan 2026 22:49:50 +0100 Subject: [PATCH 18/38] Use coroutine instead of Handler for temp file cleanup --- .../amethyst/ui/components/ZoomableContentView.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 48e0a2cfe..1b4d60cd6 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 @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Context import android.content.Intent -import android.os.Handler -import android.os.Looper import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -112,7 +110,9 @@ 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 @@ -876,6 +876,7 @@ private suspend fun shareImageFile( } } +@OptIn(DelicateCoroutinesApi::class) private suspend fun shareVideoFile( context: Context, videoUrl: String, @@ -928,11 +929,13 @@ private suspend fun shareVideoFile( withContext(Dispatchers.Main) { context.startActivity(Intent.createChooser(shareIntent, null)) } + } - // Schedule cleanup after 60 seconds to allow the receiving app time to copy the file - Handler(Looper.getMainLooper()).postDelayed({ - sharableFile.delete() - }, 60_000) + // Schedule cleanup after 60 seconds to allow the receiving app time to copy the file + // Launch in separate coroutine so we don't block the completion callback + GlobalScope.launch(Dispatchers.IO) { + delay(60_000) + sharedFile?.delete() } withContext(Dispatchers.Main) { From 8f6de1d304a88523b2dbae9a983babc64b907667 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 19 Jan 2026 16:32:01 +0200 Subject: [PATCH 19/38] zap updates --- .../amethyst/model/LocalCache.kt | 12 +- .../amethyst/commons/icons/Bookmark.kt | 121 ++ .../commons/model/cache/ICacheProvider.kt | 22 + .../amethyst/commons/search/SearchParser.kt | 127 ++ .../amethyst/commons/search/SearchResult.kt | 69 ++ .../commons/state/EventCollectionState.kt | 5 +- .../commons/ui/components/UserAvatar.kt | 7 +- .../commons/ui/components/UserSearchCard.kt | 105 ++ .../commons/viewmodels/SearchBarState.kt | 130 ++ .../commons/account/AccountManager.kt | 45 + .../model/nip51Bookmarks/BookmarkAction.kt | 149 +++ .../commons/model/nip57Zaps/ZapAction.kt | 163 +++ .../commons/network/RelayConnectionManager.kt | 55 + .../lnurl/LightningAddressResolver.kt | 224 ++++ .../commons/services/nwc/NwcPaymentTracker.kt | 97 ++ .../commons/subscriptions/FeedSubscription.kt | 184 +++ .../commons/subscriptions/FilterBuilders.kt | 161 +++ .../subscriptions/ProfileSubscription.kt | 34 +- .../amethyst/commons/util/EventExtensions.kt | 7 +- .../amethyst/desktop/DesktopPreferences.kt | 57 + .../vitorpamplona/amethyst/desktop/Main.kt | 456 ++++++-- .../desktop/cache/DesktopLocalCache.kt | 262 +++++ .../amethyst/desktop/nwc/NwcPaymentHandler.kt | 181 +++ .../amethyst/desktop/ui/BookmarksScreen.kt | 336 ++++++ .../amethyst/desktop/ui/FeedScreen.kt | 280 ++++- .../amethyst/desktop/ui/NoteActions.kt | 1041 ++++++++++++++++- .../amethyst/desktop/ui/ReadsScreen.kt | 364 ++++++ .../amethyst/desktop/ui/SearchScreen.kt | 451 +++++++ .../amethyst/desktop/ui/ThreadScreen.kt | 209 +++- .../amethyst/desktop/ui/UserProfileScreen.kt | 118 +- 30 files changed, 5267 insertions(+), 205 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt 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 80441bbb1..3e220e0ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.IChannel +import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel @@ -225,7 +226,7 @@ object LocalCache : ILocalCache, ICacheProvider { val liveChatChannels = LargeCache() val ephemeralChannels = LargeCache() - val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + val paymentTracker = NwcPaymentTracker() val relayHints = HintIndexer() @@ -308,7 +309,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) { @@ -1976,7 +1977,7 @@ object LocalCache : ILocalCache, ICacheProvider { zappedNote?.addZapPayment(note, null) - awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) + paymentTracker.registerRequest(event.id, zappedNote, onResponse) refreshNewNoteObservers(note) @@ -1992,9 +1993,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) } 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 7d1f32f98..d73085e9f 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 @@ -96,6 +96,28 @@ interface ICacheProvider { * @return true if the event has been deleted, false otherwise */ 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? } /** 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/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/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/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt index a2cffbd11..f7df8af80 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/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 @@ -68,6 +69,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. @@ -205,6 +209,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/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/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt index 3e0f7159d..8669b66e8 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -107,6 +107,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/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/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt index 60852ec49..21f95caa5 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/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/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt index 011d910fa..51eaf8d95 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/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/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt index 2355c0fac..dbed8ef4d 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/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/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt index 0c392b8f8..aa45c97fe 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.util +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.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/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt new file mode 100644 index 000000000..6e11bf154 --- /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.commons.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 86b01d92d..e5d06bd4d 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 @@ -78,14 +83,19 @@ import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder -import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +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.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -99,8 +109,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 +141,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 +170,7 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { /* TODO: Open settings */ }, + onClick = { currentScreen = DesktopScreen.Settings }, ) Separator() Item( @@ -202,21 +218,35 @@ 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) } @@ -246,19 +276,28 @@ 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, onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, ) // Compose dialog @@ -267,6 +306,7 @@ fun App( onDismiss = onDismissComposeDialog, relayManager = relayManager, account = account, + replyTo = replyToNote, ) } } @@ -280,127 +320,218 @@ fun MainContent( currentScreen: DesktopScreen, onScreenChange: (DesktopScreen) -> Unit, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + nwcConnection: Nip47WalletConnect.Nip47URINorm?, 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, + 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, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + ) + DesktopScreen.Bookmarks -> + BookmarksScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + onZapFeedback = onZapFeedback, + ) + DesktopScreen.Messages -> MessagesPlaceholder() + DesktopScreen.Notifications -> NotificationsScreen(relayManager, account) + DesktopScreen.MyProfile -> + UserProfileScreen( + pubKeyHex = account.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + 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, + 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, + 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 +574,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 +597,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/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt new file mode 100644 index 000000000..7b0144e93 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -0,0 +1,262 @@ +/** + * 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.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.model.cache.IChannel +import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker +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 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, this) + } + + override fun countUsers(predicate: (String, Any) -> 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, this) + } + + // ----- Channel operations ----- + + override fun getAnyChannel(note: Any?): IChannel? { + // 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 com.vitorpamplona.quartz.nip01Core.core.Event -> deletedEvents.contains(event.id) + else -> false + } + + fun markAsDeleted(eventId: HexKey) { + deletedEvents.add(eventId) + } + + // ----- 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() + 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/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/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt new file mode 100644 index 000000000..fbc40ff75 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -0,0 +1,336 @@ +/** + * 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.account.AccountState +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.note.NoteCard +import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +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, + 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() + + // 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/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 5cd4d73a7..f6676ae14 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 @@ -55,17 +55,30 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode +import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.commons.subscriptions.createBatchMetadataSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createRepostsSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager 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. @@ -74,11 +87,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 { @@ -86,7 +111,7 @@ fun FeedNoteCard( }, ) { NoteCard( - note = event.toNoteDisplayData(), + note = event.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, ) @@ -95,9 +120,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, ) } } @@ -106,10 +143,13 @@ fun FeedNoteCard( @Composable fun FeedScreen( relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -125,8 +165,14 @@ 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()) } + var reactionsByEvent by remember { mutableStateOf>(emptyMap()) } + var repliesByEvent by remember { mutableStateOf>(emptyMap()) } + var repostsByEvent by remember { mutableStateOf>(emptyMap()) } + 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) } @@ -150,6 +196,41 @@ fun FeedScreen( } } + // Load user's bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + com.vitorpamplona.amethyst.commons.subscriptions.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() @@ -168,6 +249,10 @@ fun FeedScreen( createGlobalFeedSubscription( relays = configuredRelays, onEvent = { event, _, _, _ -> + // Store metadata events in cache + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } eventState.addItem(event) }, onEose = { _, _ -> @@ -181,6 +266,10 @@ 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 = { _, _ -> @@ -194,6 +283,164 @@ 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 + reactionsByEvent = + reactionsByEvent.toMutableMap().apply { + this[targetEventId] = (this[targetEventId] ?: 0) + 1 + } + } + }, + ) + } + + // 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) { + repliesByEvent = + repliesByEvent.toMutableMap().apply { + this[replyToId] = (this[replyToId] ?: 0) + 1 + } + } + }, + ) + } + + // 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 + repostsByEvent = + repostsByEvent.toMutableMap().apply { + this[targetEventId] = (this[targetEventId] ?: 0) + 1 + } + } + }, + ) + } + + // Subscribe to metadata for note authors (to enable zaps and populate search cache) + val authorPubkeys = events.map { it.pubKey }.distinct() + rememberSubscription(relayStatuses, authorPubkeys, relayManager = relayManager) { + 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( @@ -217,12 +464,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") }, ) } @@ -301,10 +554,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/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index fcc12c918..994ea5271 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 @@ -40,29 +56,458 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.account.AccountState +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.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/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt new file mode 100644 index 000000000..044045d23 --- /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.account.AccountState +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode +import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingLongFormFeedSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createLongFormFeedSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +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..0257bf852 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -0,0 +1,451 @@ +/** + * 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.subscriptions.createMetadataSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createSearchPeopleSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +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.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +@Composable +fun SearchScreen( + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + 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 30c436909..b73def3f0 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 @@ -52,16 +52,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createRepostsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager 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. @@ -72,10 +84,14 @@ 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, 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() @@ -103,6 +119,50 @@ fun ThreadScreen( 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()) } + var reactionsByEvent by remember(noteId) { mutableStateOf>(emptyMap()) } + var repliesByEvent by remember(noteId) { mutableStateOf>(emptyMap()) } + var repostsByEvent by remember(noteId) { mutableStateOf>(emptyMap()) } + + // Bookmark state + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // 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 @@ -144,6 +204,103 @@ fun ThreadScreen( } } + // 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 + reactionsByEvent = + reactionsByEvent.toMutableMap().apply { + this[targetEventId] = (this[targetEventId] ?: 0) + 1 + } + } + }, + ) + } + + // 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) { + repliesByEvent = + repliesByEvent.toMutableMap().apply { + this[replyToId] = (this[replyToId] ?: 0) + 1 + } + } + }, + ) + } + + // 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 + repostsByEvent = + repostsByEvent.toMutableMap().apply { + this[targetEventId] = (this[targetEventId] ?: 0) + 1 + } + } + }, + ) + } + // Calculate reply level for an event based on e-tags fun calculateLevel(event: Event): Int { levelCache[event.id]?.let { return it } @@ -205,16 +362,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 + }, ) } } @@ -242,16 +421,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 + }, ) } } 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 475fe55b1..7b745ee98 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 @@ -60,20 +63,26 @@ import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.FollowState +import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.commons.subscriptions.createUserPostsSubscription import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event 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 +91,13 @@ import kotlinx.coroutines.withContext fun UserProfileScreen( pubKeyHex: String, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = 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 +200,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 +406,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 +566,11 @@ fun UserProfileScreen( FeedNoteCard( event = event, relayManager = relayManager, + localCache = localCache, account = account, + nwcConnection = nwcConnection, onReply = onCompose, + onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, ) } From 8155cfee9d4a1ba7617089a448ffb3694ba53c65 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 20 Jan 2026 15:18:11 +0200 Subject: [PATCH 20/38] update metadata loading --- .../eoseManagers/PerUniqueIdEoseManager.kt | 1 + .../PerUserAndFollowListEoseManager.kt | 1 + .../eoseManagers/PerUserEoseManager.kt | 1 + .../SingleSubNoEoseCacheEoseManager.kt | 1 + .../RelaySubscriptionsCoordinator.kt | 2 +- .../account/AccountFilterAssembler.kt | 2 +- .../AccountFilterAssemblerSubscription.kt | 2 +- .../AccountFollowsLoaderSubAssembler.kt | 2 +- .../ChannelFinderFilterAssemblyGroup.kt | 2 +- ...ChannelFinderFilterAssemblySubscription.kt | 2 +- ...adataAndLiveActivityWatcherSubAssembler.kt | 2 +- .../event/EventFinderFilterAssembler.kt | 2 +- .../EventFinderFilterAssemblerSubscription.kt | 2 +- .../watchers/EventWatcherSubAssembler.kt | 2 +- .../NWCFinderFilterAssemblerSubscription.kt | 2 +- .../nwc/NWCPaymentFilterAssembler.kt | 2 +- .../user/UserFinderFilterAssembler.kt | 2 +- .../UserFinderFilterAssemblerSubscription.kt | 2 +- .../loaders/UserOutboxFinderSubAssembler.kt | 2 +- .../user/watchers/UserCardsSubAssembler.kt | 2 +- .../user/watchers/UserReportsSubAssembler.kt | 2 +- .../user/watchers/UserWatcherSubAssembler.kt | 2 +- .../searchCommand/SearchFilterAssembler.kt | 4 +- .../TextSearchDataSourceSubscription.kt | 2 +- .../UserSearchDataSourceSubscription.kt | 2 +- .../datasource/ChatroomFilterAssembler.kt | 2 +- .../ChatroomFilterAssemblerSubscription.kt | 2 +- .../datasource/ChannelFilterAssembler.kt | 2 +- .../ChannelFilterAssemblerSubscription.kt | 2 +- .../datasource/ChatroomListFilterAssembler.kt | 2 +- ...ChatroomListFilterAssemblerSubscription.kt | 2 +- .../CommunityFeedFilterSubAssembler.kt | 2 +- .../datasource/CommunityFilterAssembler.kt | 2 +- .../CommunityFilterAssemblerSubscription.kt | 2 +- .../datasource/DiscoveryFilterAssembler.kt | 2 +- .../DiscoveryFilterAssemblerSubscription.kt | 2 +- .../FollowPackFeedFilterAssembler.kt | 2 +- ...llowPackFeedFilterAssemblerSubscription.kt | 2 +- .../FollowPackFeedFilterSubAssembler.kt | 2 +- .../datasource/GeoHashFilterAssembler.kt | 2 +- .../GeoHashFilterAssemblerSubscription.kt | 2 +- .../datasource/HashtagFilterAssembler.kt | 2 +- .../HashtagFilterAssemblerSubscription.kt | 2 +- .../home/datasource/HomeFilterAssembler.kt | 2 +- .../HomeFilterAssemblerSubscription.kt | 2 +- .../datasource/UserProfileFilterAssembler.kt | 2 +- .../UserProfileFilterAssemblerSubscription.kt | 2 +- .../UserProfileMetadataFilterSubAssembler.kt | 2 +- .../datasources/ThreadFilterAssembler.kt | 2 +- .../ThreadFilterAssemblerSubscription.kt | 2 +- .../video/datasource/VideoFilterAssembler.kt | 2 +- .../VideoFilterAssemblerSubscription.kt | 2 +- .../assemblers/FeedMetadataCoordinator.kt | 221 ++++++++++++++++++ .../assemblers/MetadataFilterAssembler.kt | 93 ++++++++ .../assemblers/ReactionsFilterAssembler.kt | 92 ++++++++ .../ComposeSubscriptionManager.kt | 2 +- .../ComposeSubscriptionManagerControls.kt | 2 +- .../MutableComposeSubscriptionManager.kt | 2 +- .../eoseManagers/BaseEoseManager.kt | 14 +- .../relayClient/eoseManagers/IEoseManager.kt | 27 +++ .../eoseManagers/PerKeyEoseManager.kt | 162 +++++++++++++ .../eoseManagers/SingleSubEoseManager.kt | 6 +- .../relayClient/preload/MetadataPreloader.kt | 109 +++++++++ .../preload/MetadataRateLimiter.kt | 109 +++++++++ .../KeyDataSourceSubscription.kt | 8 +- .../PrioritizedSubscriptionQueue.kt | 129 ++++++++++ .../subscriptions/SubscriptionPriority.kt | 55 +++++ .../amethyst/commons/relays/EOSECache.kt | 144 ++++++++++++ .../commons/network/RelayConnectionManager.kt | 27 ++- .../vitorpamplona/amethyst/desktop/Main.kt | 27 ++- .../DesktopRelaySubscriptionsCoordinator.kt | 141 +++++++++++ .../amethyst/desktop/ui/BookmarksScreen.kt | 12 + .../amethyst/desktop/ui/FeedScreen.kt | 51 ++-- .../desktop/ui/NotificationsScreen.kt | 11 + .../amethyst/desktop/ui/SearchScreen.kt | 2 + .../amethyst/desktop/ui/ThreadScreen.kt | 47 +++- .../amethyst/desktop/ui/UserProfileScreen.kt | 2 + 77 files changed, 1490 insertions(+), 107 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt (96%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt (95%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt (97%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/relayClient/eoseManagers/BaseEoseManager.kt (88%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/IEoseManager.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/relayClient/eoseManagers/SingleSubEoseManager.kt (95%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataPreloader.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions}/KeyDataSourceSubscription.kt (87%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/PrioritizedSubscriptionQueue.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubscriptionPriority.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt 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..30d303ce8 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,9 +20,9 @@ */ 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 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/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt index 5600979a5..c06c81764 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 @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.service.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 eebcea166..209fd7d97 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 @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Channel -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/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt index 56781d1f7..c6cced14e 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 @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.service.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/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 8beefc621..f90e7592a 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 @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel -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 0dc9e50f8..6eca5a187 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 @@ -22,8 +22,8 @@ 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.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Channel -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/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/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/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/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/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt index 8669b66e8..ff25e8f47 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/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) } /** @@ -118,7 +121,7 @@ open class RelayConnectionManager( if (relay !in availableRelays.value) { updateRelayStatus(relay) { it.copy(connected = false, error = null) } } - client.send(event, setOf(relay)) + _client.send(event, setOf(relay)) } /** @@ -135,7 +138,7 @@ open class RelayConnectionManager( updateRelayStatus(relay) { it.copy(connected = false, error = null) } } val filterMap = mapOf(relay to filters) - client.openReqSubscription( + _client.openReqSubscription( subId = subId, filters = filterMap, listener = @@ -159,7 +162,7 @@ open class RelayConnectionManager( relay: NormalizedRelayUrl, subId: String, ) { - client.close(subId) + _client.close(subId) } private fun updateRelayStatus( 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 e5d06bd4d..a97db089c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder 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 @@ -251,6 +252,17 @@ fun App( 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) { @@ -260,7 +272,12 @@ fun App( relayManager.addDefaultRelays() relayManager.connect() + + // Start subscriptions coordinator + subscriptionsCoordinator.start() + onDispose { + subscriptionsCoordinator.clear() relayManager.disconnect() } } @@ -296,6 +313,7 @@ fun App( accountManager = accountManager, account = account, nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, ) @@ -324,6 +342,7 @@ fun MainContent( accountManager: AccountManager, account: AccountState.LoggedIn, nwcConnection: Nip47WalletConnect.Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, ) { @@ -429,6 +448,7 @@ fun MainContent( localCache = localCache, account = account, nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, onCompose = onShowComposeDialog, onNavigateToProfile = { pubKeyHex -> onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) @@ -454,6 +474,7 @@ fun MainContent( SearchScreen( localCache = localCache, relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, onNavigateToProfile = { pubKeyHex -> onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) }, @@ -467,6 +488,7 @@ fun MainContent( localCache = localCache, account = account, nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, onNavigateToProfile = { pubKeyHex -> onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) }, @@ -476,7 +498,7 @@ fun MainContent( onZapFeedback = onZapFeedback, ) DesktopScreen.Messages -> MessagesPlaceholder() - DesktopScreen.Notifications -> NotificationsScreen(relayManager, account) + DesktopScreen.Notifications -> NotificationsScreen(relayManager, account, subscriptionsCoordinator) DesktopScreen.MyProfile -> UserProfileScreen( pubKeyHex = account.pubKeyHex, @@ -484,6 +506,7 @@ fun MainContent( localCache = localCache, account = account, nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, onBack = { onScreenChange(DesktopScreen.Feed) }, onCompose = onShowComposeDialog, onNavigateToProfile = { pubKeyHex -> @@ -498,6 +521,7 @@ fun MainContent( localCache = localCache, account = account, nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, onBack = { onScreenChange(DesktopScreen.Feed) }, onCompose = onShowComposeDialog, onNavigateToProfile = { pubKeyHex -> @@ -512,6 +536,7 @@ fun MainContent( localCache = localCache, account = account, nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, onBack = { onScreenChange(DesktopScreen.Feed) }, onNavigateToProfile = { pubKeyHex -> onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) 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/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index fbc40ff75..c4222545e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @@ -71,6 +72,7 @@ fun BookmarksScreen( 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 = {}, @@ -109,6 +111,16 @@ fun BookmarksScreen( } 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 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 f6676ae14..2167b492e 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 @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -146,6 +148,7 @@ fun FeedScreen( 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 = {}, @@ -168,9 +171,14 @@ fun FeedScreen( var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } - var reactionsByEvent by remember { mutableStateOf>(emptyMap()) } - var repliesByEvent by remember { mutableStateOf>(emptyMap()) } - var repostsByEvent 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()) } @@ -355,9 +363,10 @@ fun FeedScreen( onEvent = { event, _, _, _ -> if (event is ReactionEvent) { val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription - reactionsByEvent = - reactionsByEvent.toMutableMap().apply { - this[targetEventId] = (this[targetEventId] ?: 0) + 1 + reactionIdsByEvent = + reactionIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id } } }, @@ -382,9 +391,10 @@ fun FeedScreen( .lastOrNull() ?.get(1) ?: return@createRepliesSubscription if (replyToId in eventIds) { - repliesByEvent = - repliesByEvent.toMutableMap().apply { - this[replyToId] = (this[replyToId] ?: 0) + 1 + replyIdsByEvent = + replyIdsByEvent.toMutableMap().apply { + val existing = this[replyToId] ?: emptySet() + this[replyToId] = existing + event.id } } }, @@ -404,9 +414,10 @@ fun FeedScreen( onEvent = { event, _, _, _ -> if (event is RepostEvent) { val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription - repostsByEvent = - repostsByEvent.toMutableMap().apply { - this[targetEventId] = (this[targetEventId] ?: 0) + 1 + repostIdsByEvent = + repostIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id } } }, @@ -415,7 +426,21 @@ fun FeedScreen( // Subscribe to metadata for note authors (to enable zaps and populate search cache) val authorPubkeys = events.map { it.pubKey }.distinct() - rememberSubscription(relayStatuses, authorPubkeys, relayManager = relayManager) { + + // 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 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 77edace1c..8e5bd5ed4 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,6 +39,7 @@ 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 @@ -60,6 +61,7 @@ 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.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -108,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() @@ -123,6 +126,14 @@ 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 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 index 0257bf852..9ccb0cb4e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -69,6 +69,7 @@ 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.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -76,6 +77,7 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull fun SearchScreen( localCache: DesktopLocalCache, relayManager: DesktopRelayConnectionManager, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, onNavigateToHashtag: (String) -> Unit = {}, 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 b73def3f0..f9c839456 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 @@ -68,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -87,6 +89,7 @@ fun ThreadScreen( localCache: DesktopLocalCache, account: AccountState.LoggedIn?, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, @@ -121,14 +124,31 @@ fun ThreadScreen( // Track zaps per event var zapsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } - var reactionsByEvent by remember(noteId) { mutableStateOf>(emptyMap()) } - var repliesByEvent by remember(noteId) { mutableStateOf>(emptyMap()) } - var repostsByEvent 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 @@ -244,9 +264,10 @@ fun ThreadScreen( onEvent = { event, _, _, _ -> if (event is ReactionEvent) { val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription - reactionsByEvent = - reactionsByEvent.toMutableMap().apply { - this[targetEventId] = (this[targetEventId] ?: 0) + 1 + reactionIdsByEvent = + reactionIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id } } }, @@ -270,9 +291,10 @@ fun ThreadScreen( .lastOrNull() ?.get(1) ?: return@createRepliesSubscription if (replyToId in allEventIds) { - repliesByEvent = - repliesByEvent.toMutableMap().apply { - this[replyToId] = (this[replyToId] ?: 0) + 1 + replyIdsByEvent = + replyIdsByEvent.toMutableMap().apply { + val existing = this[replyToId] ?: emptySet() + this[replyToId] = existing + event.id } } }, @@ -292,9 +314,10 @@ fun ThreadScreen( onEvent = { event, _, _, _ -> if (event is RepostEvent) { val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription - repostsByEvent = - repostsByEvent.toMutableMap().apply { - this[targetEventId] = (this[targetEventId] ?: 0) + 1 + repostIdsByEvent = + repostIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id } } }, 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 7b745ee98..54600dcfb 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 @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -94,6 +95,7 @@ fun UserProfileScreen( localCache: DesktopLocalCache, account: AccountState.LoggedIn?, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, From c801e9ce8678321f893e7ac0c305596da03e5eba Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 20 Jan 2026 13:34:58 +0000 Subject: [PATCH 21/38] New Crowdin translations by GitHub Action --- .../src/main/res/values-fr-rFR/strings.xml | 64 +++++++++++++++++++ .../src/main/res/values-hi-rIN/strings.xml | 7 ++ .../src/main/res/values-hu-rHU/strings.xml | 6 ++ .../src/main/res/values-zh-rCN/strings.xml | 7 ++ 4 files changed, 84 insertions(+) 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..aa9ccf9d0 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 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 @@ 广播包 删除列表 删除包 + 类型 From 29faa7882d8270e8a7229ac7eacffecdaa267ffc Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Tue, 20 Jan 2026 18:27:20 +0000 Subject: [PATCH 22/38] Increase max voice record duration to 600 seconds --- .../vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c7fe883aa..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,7 +42,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -const val MAX_VOICE_RECORD_SECONDS = 180 +const val MAX_VOICE_RECORD_SECONDS = 600 @OptIn(ExperimentalPermissionsApi::class) @Composable From d542e4d6a03aeed5b4f5d452405cccd4a304a1e3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 20 Jan 2026 19:55:19 +0100 Subject: [PATCH 23/38] change to 2 minutes delay before cleaning up temp shared file --- .../amethyst/ui/components/ZoomableContentView.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 1b4d60cd6..6e18d8342 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 @@ -124,6 +124,10 @@ 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, @@ -931,10 +935,10 @@ private suspend fun shareVideoFile( } } - // Schedule cleanup after 60 seconds to allow the receiving app time to copy the file - // Launch in separate coroutine so we don't block the completion callback + // 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(60_000) + delay(SHARED_VIDEO_CLEANUP_DELAY_MS) sharedFile?.delete() } From ac4a1de0cd9c7a12e52ae151813416538defd19d Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 20 Jan 2026 22:31:14 +0100 Subject: [PATCH 24/38] Block share menu from closing while downloading video --- .../ui/components/ZoomableContentView.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 6e18d8342..18a39142f 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 @@ -735,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 @@ -798,22 +801,19 @@ fun ShareImageAction( is MediaUrlVideo -> { videoUri?.let { if (videoUri.isNotEmpty()) { - // State is read via Compose recomposition in text{} and enabled parameter - val isDownloading = remember { mutableStateOf(false) } - DropdownMenuItem( text = { Row(verticalAlignment = Alignment.CenterVertically) { Text(stringRes(R.string.share_video)) - if (isDownloading.value) { + if (isDownloadingVideo.value) { Spacer(modifier = Modifier.width(8.dp)) LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp) } } }, - enabled = !isDownloading.value, + enabled = !isDownloadingVideo.value, onClick = { - isDownloading.value = true + isDownloadingVideo.value = true scope.launch { shareVideoFile( context = context, @@ -823,11 +823,11 @@ fun ShareImageAction( accountViewModel.httpClientBuilder.okHttpClientForVideo(url) }, onComplete = { - isDownloading.value = false + isDownloadingVideo.value = false onDismiss() }, onError = { - isDownloading.value = false + isDownloadingVideo.value = false }, ) } From 80c946be444baa0a139d396fdefabb9ff6831217 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 20 Jan 2026 22:42:48 +0100 Subject: [PATCH 25/38] rename method to reflect new functionality --- .../playback/composable/controls/RenderControlButtons.kt | 6 +++--- .../amethyst/ui/components/ZoomableContentDialog.kt | 2 +- .../amethyst/ui/components/ZoomableContentView.kt | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) 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/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 18a39142f..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 @@ -684,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, @@ -704,7 +704,7 @@ fun ShareImageAction( content = content, ) } else if (content is MediaPreloadedContent) { - ShareImageAction( + ShareMediaAction( accountViewModel = accountViewModel, popupExpanded = popupExpanded, videoUri = content.localFile?.toUri().toString(), @@ -721,7 +721,7 @@ fun ShareImageAction( @OptIn(ExperimentalPermissionsApi::class) @Composable -fun ShareImageAction( +fun ShareMediaAction( accountViewModel: AccountViewModel, popupExpanded: MutableState, videoUri: String?, From ff47080cf1cca83bf1ef56b659fb4b191b92b6e8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 21 Jan 2026 06:23:58 +0200 Subject: [PATCH 26/38] fix: Resolve API mismatches after merge with upstream - Fix import paths to use desktop.subscriptions instead of commons.subscriptions - Update DesktopLocalCache to match ICacheProvider interface changes - Use correct commons.relayClient paths for ComposeSubscriptionManager - Add missing justConsumeMyOwnEvent and getOrCreateAddressableNote methods Co-Authored-By: Claude Opus 4.5 --- .../amethyst/model/LocalCache.kt | 1 + .../ChannelFinderFilterAssemblyGroup.kt | 2 +- ...ChannelFinderFilterAssemblySubscription.kt | 2 +- ...adataAndLiveActivityWatcherSubAssembler.kt | 2 +- .../datasource/ChannelFilterAssembler.kt | 2 +- .../ChannelFilterAssemblerSubscription.kt | 2 +- .../amethyst/desktop/DesktopPreferences.kt | 2 +- .../desktop/cache/DesktopLocalCache.kt | 29 +++++++++++++++---- .../amethyst/desktop/ui/BookmarksScreen.kt | 11 ++++--- .../amethyst/desktop/ui/FeedScreen.kt | 3 +- .../amethyst/desktop/ui/ReadsScreen.kt | 12 ++++---- .../amethyst/desktop/ui/SearchScreen.kt | 6 ++-- 12 files changed, 46 insertions(+), 28 deletions(-) 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 231170893..593c6264e 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 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/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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 6e11bf154..d0004dec5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop -import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import java.util.prefs.Preferences /** 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 index 7b0144e93..844851ceb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -20,12 +20,15 @@ */ 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.model.cache.IChannel 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 @@ -48,6 +51,7 @@ import java.util.concurrent.ConcurrentHashMap class DesktopLocalCache : ICacheProvider { private val users = ConcurrentHashMap() private val notes = ConcurrentHashMap() + private val addressableNotes = ConcurrentHashMap() private val deletedEvents = ConcurrentHashMap.newKeySet() private val eventStream = DesktopCacheEventStream() @@ -63,10 +67,10 @@ class DesktopLocalCache : ICacheProvider { // Create placeholder notes for relay lists val nip65Note = getOrCreateNote("nip65:$pubkey") val dmNote = getOrCreateNote("dm:$pubkey") - User(pubkey, nip65Note, dmNote, this) + User(pubkey, nip65Note, dmNote) } - override fun countUsers(predicate: (String, Any) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) } + override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) } override fun findUsersStartingWith( prefix: String, @@ -188,12 +192,17 @@ class DesktopLocalCache : ICacheProvider { fun getOrCreateNote(hexKey: HexKey): Note = notes.getOrPut(hexKey) { - Note(hexKey, this) + Note(hexKey) + } + + override fun getOrCreateAddressableNote(key: Address): AddressableNote = + addressableNotes.getOrPut(key.toValue()) { + AddressableNote(key) } // ----- Channel operations ----- - override fun getAnyChannel(note: Any?): IChannel? { + override fun getAnyChannel(note: Note): Channel? { // Desktop doesn't support channels yet return null } @@ -203,7 +212,7 @@ class DesktopLocalCache : ICacheProvider { override fun hasBeenDeleted(event: Any): Boolean = when (event) { is Note -> deletedEvents.contains(event.idHex) - is com.vitorpamplona.quartz.nip01Core.core.Event -> deletedEvents.contains(event.id) + is Event -> deletedEvents.contains(event.id) else -> false } @@ -211,6 +220,13 @@ class DesktopLocalCache : ICacheProvider { 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 @@ -238,6 +254,7 @@ class DesktopLocalCache : ICacheProvider { fun clear() { users.clear() notes.clear() + addressableNotes.clear() deletedEvents.clear() } } 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 index c4222545e..23c52fb12 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -44,18 +44,17 @@ 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.account.AccountState import com.vitorpamplona.amethyst.commons.state.EventCollectionState -import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders -import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig -import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.note.NoteCard -import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData +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 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 6e2a03ae0..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 @@ -63,6 +63,7 @@ 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 @@ -207,7 +208,7 @@ fun FeedScreen( rememberSubscription(relayStatuses, account, relayManager = relayManager) { val configuredRelays = relayStatuses.keys if (configuredRelays.isNotEmpty() && account != null) { - com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig( + SubscriptionConfig( subId = "bookmarks-${account.pubKeyHex.take(8)}", filters = listOf( 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 index 044045d23..81b415e1f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -53,17 +53,17 @@ 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.account.AccountState import com.vitorpamplona.amethyst.commons.state.EventCollectionState -import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode -import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription -import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingLongFormFeedSubscription -import com.vitorpamplona.amethyst.commons.subscriptions.createLongFormFeedSubscription -import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription 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 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 index 9ccb0cb4e..4f3bd7164 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -62,14 +62,14 @@ 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.subscriptions.createMetadataSubscription -import com.vitorpamplona.amethyst.commons.subscriptions.createSearchPeopleSubscription -import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription 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 From b8ecb03ddd61902521a85b627fd2df7ab5663dbb Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 21 Jan 2026 17:30:54 +0100 Subject: [PATCH 27/38] refactor to reduce complexity decodeAudioToPcm --- .../ui/actions/uploads/VoiceAnonymizer.kt | 210 +++++++++++------- 1 file changed, 134 insertions(+), 76 deletions(-) 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 8c4210bc4..5ae965e51 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 @@ -136,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, @@ -145,95 +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 + decoder = + MediaCodec.createDecoderByType(trackInfo.mime).apply { + configure(trackInfo.format, null, null, 0) + start() } - } - check(audioTrackIndex != -1 && format != null) { "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 estimatedSamples = (sampleRate.toLong() * durationUs / 1_000_000).toInt() + val estimatedSamples = (trackInfo.sampleRate.toLong() * trackInfo.durationUs / 1_000_000).toInt() val pcmSamples = ArrayList(estimatedSamples) - 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)) - } - } - } - } + val inputFeeder = DecoderInputFeeder(decoder, extractor, trackInfo.durationUs, onProgress) + val outputDrainer = DecoderOutputDrainer(decoder, pcmSamples) - 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 - } - } + 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 { - try { - decoder?.stop() - } catch (_: IllegalStateException) { - // Decoder was never started - } - 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, From 1a736f1c8aeefa8eaf1f8a784e79fc79ef965b20 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 21 Jan 2026 17:35:13 +0100 Subject: [PATCH 28/38] refactor to reduce complexity encodePcmToAac --- .../ui/actions/uploads/VoiceAnonymizer.kt | 221 +++++++++++------- 1 file changed, 135 insertions(+), 86 deletions(-) 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 5ae965e51..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 @@ -402,110 +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 { - try { - encoder.stop() - } catch (_: IllegalStateException) { - // Encoder was never started - } - 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( From 5f75d5a48bd2b57e94c0e3734e45628015519d5e Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 22 Jan 2026 09:25:17 +0000 Subject: [PATCH 29/38] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-de-rDE/strings.xml | 1 + amethyst/src/main/res/values-hu-rHU/strings.xml | 2 +- amethyst/src/main/res/values-pl-rPL/strings.xml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) 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-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index aa9ccf9d0..dece24a0f 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -703,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..853644faa 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 From 1e611a3fb55446958ed284100e158135d46f6af2 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 22 Jan 2026 18:32:37 +0100 Subject: [PATCH 30/38] Enabled the reactions expand control for zapraisers even when there are no zaps --- .../java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ffcb0a2d4..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 @@ -415,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() } } From 3ac8ba669bebc607b62d3d8709a0af725d645a71 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 23 Jan 2026 06:50:25 +0200 Subject: [PATCH 31/38] feat(broadcast): Add transparent event broadcasting feedback (#1682) Phase 1 - Core infrastructure: - BroadcastModels: BroadcastEvent, RelayResult, BroadcastStatus data classes - BroadcastTracker: State management with live progress updates - BroadcastBanner: Global progress indicator (animated) - BroadcastSnackbar: Result notifications with actions - BroadcastDetailsSheet: Relay status detail bottom sheet Refs: #1682 Co-Authored-By: Claude Opus 4.5 --- .../service/broadcast/BroadcastModels.kt | 134 +++++++++ .../service/broadcast/BroadcastTracker.kt | 271 +++++++++++++++++ .../amethyst/ui/broadcast/BroadcastBanner.kt | 203 +++++++++++++ .../ui/broadcast/BroadcastDetailsSheet.kt | 277 ++++++++++++++++++ .../ui/broadcast/BroadcastSnackbar.kt | 140 +++++++++ 5 files changed, 1025 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastSnackbar.kt 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..5a5980a02 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt @@ -0,0 +1,134 @@ +/** + * 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() +} + +/** + * 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 (for retry) */ + val failedRelays: List + get() = results.filter { it.value is RelayResult.Error || it.value is RelayResult.Timeout }.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..2a71fea2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt @@ -0,0 +1,271 @@ +/** + * 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 + } + + private val _activeBroadcasts = MutableStateFlow>(emptyList()) + val activeBroadcasts: StateFlow> = _activeBroadcasts.asStateFlow() + + private val _completedBroadcast = MutableSharedFlow(extraBufferCapacity = 10) + val completedBroadcast: SharedFlow = _completedBroadcast.asSharedFlow() + + /** + * 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 + _activeBroadcasts.update { it + broadcast } + + 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, + ) + } + + /** + * Retries sending an event to failed relays. + * + * @param originalBroadcast The original broadcast with failures + * @param event The original event to resend + * @param client The Nostr client + * @param specificRelay Optional specific relay to retry (null = all failed) + */ + suspend fun retry( + originalBroadcast: BroadcastEvent, + event: Event, + client: INostrClient, + specificRelay: NormalizedRelayUrl? = null, + ): BroadcastResult { + val relaysToRetry = + if (specificRelay != null) { + setOf(specificRelay) + } else { + originalBroadcast.failedRelays.toSet() + } + + if (relaysToRetry.isEmpty()) { + return BroadcastResult(originalBroadcast, originalBroadcast.successCount > 0) + } + + return trackBroadcast( + event = event, + eventName = "${originalBroadcast.eventName} (retry)", + relays = relaysToRetry, + client = client, + ) + } + + /** + * Gets details for a specific broadcast by tracking ID. + */ + fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId } + + /** + * Clears all active broadcasts (e.g., on logout). + */ + fun clear() { + _activeBroadcasts.update { emptyList() } + } + + /** + * 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/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..3cbdee92f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -0,0 +1,277 @@ +/** + * 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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.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.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 + +/** + * Modal bottom sheet showing detailed relay results for a broadcast. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BroadcastDetailsSheet( + broadcast: BroadcastEvent, + onDismiss: () -> Unit, + onRetryRelay: (NormalizedRelayUrl) -> Unit, + onRetryAllFailed: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + // Header + Text( + text = "Broadcast Results", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(8.dp)) + + // Summary + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatusIcon(broadcast.status) + + Text( + text = "${broadcast.eventName} (kind ${broadcast.kind})", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = "${broadcast.successCount}/${broadcast.totalRelays} relays", + style = MaterialTheme.typography.labelLarge, + color = + when (broadcast.status) { + BroadcastStatus.SUCCESS -> Color(0xFF22C55E) + BroadcastStatus.PARTIAL -> Color(0xFFF59E0B) + BroadcastStatus.FAILED -> Color(0xFFEF4444) + BroadcastStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary + }, + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + // Relay list + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f, fill = false), + ) { + items(broadcast.targetRelays) { relay -> + val result = broadcast.results[relay] ?: RelayResult.Pending + RelayResultRow( + relay = relay, + result = result, + onRetry = { onRetryRelay(relay) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Text("Dismiss") + } + + if (broadcast.failedRelays.isNotEmpty()) { + Button( + onClick = onRetryAllFailed, + modifier = Modifier.weight(1f), + 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 StatusIcon(status: BroadcastStatus) { + val (icon, tint) = + when (status) { + BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to Color(0xFF22C55E) + BroadcastStatus.PARTIAL -> Icons.Default.Error to Color(0xFFF59E0B) + BroadcastStatus.FAILED -> Icons.Default.Error to Color(0xFFEF4444) + BroadcastStatus.IN_PROGRESS -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.primary + } + + Icon( + imageVector = icon, + contentDescription = status.name, + tint = tint, + modifier = Modifier.size(24.dp), + ) +} + +@Composable +private fun RelayResultRow( + relay: NormalizedRelayUrl, + result: RelayResult, + onRetry: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Status icon + val (icon, tint) = + when (result) { + is RelayResult.Success -> Icons.Default.CheckCircle to Color(0xFF22C55E) + is RelayResult.Error -> Icons.Default.Error to Color(0xFFEF4444) + is RelayResult.Timeout -> Icons.Default.HourglassEmpty to Color(0xFFF59E0B) + is RelayResult.Pending -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant + } + + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(20.dp), + ) + + 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 = Color(0xFFEF4444), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + is RelayResult.Timeout -> { + Text( + text = "Timeout", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFF59E0B), + ) + } + else -> {} + } + } + + // Retry button for failed relays + if (result is RelayResult.Error || result is RelayResult.Timeout) { + 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) + } +} From c5d9fc3c15a9501aaaaf310477debfecede1a9ee Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 23 Jan 2026 06:55:13 +0200 Subject: [PATCH 32/38] wip: Add BroadcastTracker to AccountViewModel (#1682) Phase 2 progress - added BroadcastTracker instance to AccountViewModel. Integration with reaction buttons pending. Co-Authored-By: Claude Opus 4.5 --- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) 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..a9abbf968 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 @@ -60,6 +60,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 @@ -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) From dedfd59d0bad9f67d4f557ba175c56f721dd979c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 23 Jan 2026 16:27:29 +0200 Subject: [PATCH 33/38] feat(broadcast): Enhance retry, multi-broadcast UI, and post tracking (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Retrying state to RelayResult for in-flight retry tracking - Implement synced 360° rotating animation for pending/retrying icons - Refactor BroadcastDetailsSheet for multi-broadcast sections (max 2 expanded) - Add overflow summary for additional broadcasts beyond 2 - Fix sheet text colors using theme colors for better visibility - Make sheet fully expandable (skipPartiallyExpanded) - Add event cache to BroadcastTracker for retry support - Implement retry that updates existing broadcast in-place - Add tracked broadcasting for posts and quotes via createPostEvent/consumePostEvent - Fix relay list computation for new posts (use event-based, not empty note) - Make post editor close immediately, broadcast continues in background - Style CompletedBroadcastIndicator consistently with BroadcastBanner Co-Authored-By: Claude Opus 4.5 --- .../vitorpamplona/amethyst/model/Account.kt | 132 +++++++ .../service/broadcast/BroadcastModels.kt | 17 +- .../service/broadcast/BroadcastTracker.kt | 207 +++++++++- .../ui/broadcast/BroadcastDetailsSheet.kt | 373 ++++++++++++++---- .../ui/broadcast/DisplayBroadcastProgress.kt | 294 ++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/screen/loggedIn/AccountViewModel.kt | 128 +++++- .../loggedIn/home/ShortNotePostViewModel.kt | 39 +- 8 files changed, 1098 insertions(+), 94 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt 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..5ff728e24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -149,6 +149,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 @@ -511,6 +512,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 +665,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 +1240,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 +1665,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/service/broadcast/BroadcastModels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt index 5a5980a02..6688e7c7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastModels.kt @@ -43,6 +43,9 @@ sealed class RelayResult { /** Waiting for relay response */ data object Pending : RelayResult() + + /** Retry in progress for this relay */ + data object Retrying : RelayResult() } /** @@ -100,9 +103,19 @@ data class BroadcastEvent( val isComplete: Boolean get() = results.size >= targetRelays.size - /** List of relays that failed (for retry) */ + /** 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 }.keys.toList() + 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( 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 index 2a71fea2b..0e0c4e41e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt @@ -55,6 +55,7 @@ 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()) @@ -63,6 +64,9 @@ class BroadcastTracker { 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. * @@ -90,8 +94,9 @@ class BroadcastTracker { targetRelays = relays.toList(), ) - // Add to active broadcasts + // 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") @@ -207,36 +212,192 @@ class BroadcastTracker { } /** - * Retries sending an event to failed relays. + * 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 originalBroadcast The original broadcast with failures - * @param event The original event to resend + * @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( - originalBroadcast: BroadcastEvent, - event: Event, + broadcast: BroadcastEvent, client: INostrClient, specificRelay: NormalizedRelayUrl? = null, - ): BroadcastResult { + ): BroadcastEvent? { + val event = eventCache[broadcast.id] ?: return null + val relaysToRetry = if (specificRelay != null) { setOf(specificRelay) } else { - originalBroadcast.failedRelays.toSet() + broadcast.failedRelays.toSet() } if (relaysToRetry.isEmpty()) { - return BroadcastResult(originalBroadcast, originalBroadcast.successCount > 0) + return broadcast } - return trackBroadcast( - event = event, - eventName = "${originalBroadcast.eventName} (retry)", - relays = relaysToRetry, - client = client, - ) + // 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 } /** @@ -245,10 +406,24 @@ class BroadcastTracker { fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId } /** - * Clears all active broadcasts (e.g., on logout). + * 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() } /** 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 index 3cbdee92f..4358ef697 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -20,6 +20,16 @@ */ 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 @@ -29,11 +39,13 @@ 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.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 @@ -46,12 +58,18 @@ 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 @@ -61,8 +79,11 @@ 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 a broadcast. + * 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 @@ -71,93 +92,218 @@ fun BroadcastDetailsSheet( onDismiss: () -> Unit, onRetryRelay: (NormalizedRelayUrl) -> Unit, onRetryAllFailed: () -> Unit, - sheetState: SheetState = rememberModalBottomSheetState(), + 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), + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()), ) { // Header Text( - text = "Broadcast Results", + text = if (broadcasts.size == 1) "Broadcast Results" else "Broadcasts (${broadcasts.size})", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) - // Summary - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - StatusIcon(broadcast.status) + // Visible broadcast sections + visibleBroadcasts.forEachIndexed { index, broadcast -> + val isExpanded = + broadcast.id in expandedSections || + (broadcast.status == BroadcastStatus.IN_PROGRESS && broadcast.id !in expandedSections) - Text( - text = "${broadcast.eventName} (kind ${broadcast.kind})", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + // 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, ) - Spacer(Modifier.weight(1f)) + if (index < visibleBroadcasts.size - 1 || overflowBroadcasts.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + } + } - Text( - text = "${broadcast.successCount}/${broadcast.totalRelays} relays", - style = MaterialTheme.typography.labelLarge, - color = - when (broadcast.status) { - BroadcastStatus.SUCCESS -> Color(0xFF22C55E) - BroadcastStatus.PARTIAL -> Color(0xFFF59E0B) - BroadcastStatus.FAILED -> Color(0xFFEF4444) - BroadcastStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary - }, - ) + // Overflow summary + if (overflowBroadcasts.isNotEmpty()) { + OverflowSummary(overflowBroadcasts) } Spacer(Modifier.height(16.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - // Relay list - LazyColumn( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.weight(1f, fill = false), + // Dismiss button + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), ) { - items(broadcast.targetRelays) { relay -> + 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, ) } - } - - Spacer(Modifier.height(16.dp)) - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = onDismiss, - modifier = Modifier.weight(1f), - ) { - Text("Dismiss") - } + // Retry all button if (broadcast.failedRelays.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) Button( onClick = onRetryAllFailed, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, @@ -178,20 +324,87 @@ fun BroadcastDetailsSheet( } @Composable -private fun StatusIcon(status: BroadcastStatus) { - val (icon, tint) = +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 -> Icons.Default.CheckCircle to Color(0xFF22C55E) - BroadcastStatus.PARTIAL -> Icons.Default.Error to Color(0xFFF59E0B) - BroadcastStatus.FAILED -> Icons.Default.Error to Color(0xFFEF4444) - BroadcastStatus.IN_PROGRESS -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.primary + 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), + modifier = + Modifier + .size(24.dp) + .then( + if (shouldRotate) { + Modifier.graphicsLayer { rotationZ = rotationAngle } + } else { + Modifier + }, + ), ) } @@ -200,7 +413,12 @@ 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 @@ -209,19 +427,29 @@ private fun RelayResultRow( verticalAlignment = Alignment.CenterVertically, ) { // Status icon - val (icon, tint) = + val (icon, tint, shouldRotate) = when (result) { - is RelayResult.Success -> Icons.Default.CheckCircle to Color(0xFF22C55E) - is RelayResult.Error -> Icons.Default.Error to Color(0xFFEF4444) - is RelayResult.Timeout -> Icons.Default.HourglassEmpty to Color(0xFFF59E0B) - is RelayResult.Pending -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant + 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), + modifier = + Modifier + .size(20.dp) + .then( + if (shouldRotate) { + Modifier.graphicsLayer { rotationZ = rotationAngle } + } else { + Modifier + }, + ), ) Spacer(Modifier.width(12.dp)) @@ -243,7 +471,7 @@ private fun RelayResultRow( text = "[${result.code}]${result.message?.let { " $it" } ?: ""}", style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, - color = Color(0xFFEF4444), + color = errorColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -252,15 +480,22 @@ private fun RelayResultRow( Text( text = "Timeout", style = MaterialTheme.typography.bodySmall, - color = Color(0xFFF59E0B), + color = warningColor, + ) + } + is RelayResult.Retrying -> { + Text( + text = "Retrying...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, ) } else -> {} } } - // Retry button for failed relays - if (result is RelayResult.Error || result is RelayResult.Timeout) { + // 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), 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/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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a9abbf968..ed50011f9 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 @@ -317,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) + } } } } @@ -711,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) } @@ -733,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/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) From 71e68dc35bc66e032c52f392615e33e752aec4f4 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 25 Jan 2026 13:29:55 +0200 Subject: [PATCH 34/38] refactor(commons): migrate from Moko to Compose Multiplatform Resources Remove Moko Resources dependency that blocks AGP 9.0 migration. Migrate to built-in Compose Multiplatform Resources which is compatible with the new KMP Android plugin. - Remove moko-resources plugin and libraries from commons module - Move strings.xml from moko-resources/ to composeResources/values/ - Update imports from SharedRes.strings.* to Res.string.* - Add compose.components.resources dependency to desktopApp - Configure resource package as com.vitorpamplona.amethyst.commons.resources Closes #1675 Co-Authored-By: Claude Opus 4.5 --- commons/build.gradle.kts | 13 +++++------ .../values}/strings.xml | 0 .../commons/ui/components/LoadingState.kt | 16 +++++++++----- .../commons/ui/screens/PlaceholderScreens.kt | 22 ++++++++++++------- desktopApp/build.gradle.kts | 1 + .../amethyst/desktop/ui/LoginScreen.kt | 10 +++++---- .../amethyst/desktop/ui/auth/KeyInputField.kt | 14 +++++++----- .../amethyst/desktop/ui/auth/LoginCard.kt | 16 +++++++++----- .../desktop/ui/auth/NewKeyWarningCard.kt | 19 ++++++++++------ gradle/libs.versions.toml | 4 ---- 10 files changed, 68 insertions(+), 47 deletions(-) rename commons/src/commonMain/{moko-resources/base => composeResources/values}/strings.xml (100%) 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/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/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/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/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/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 38e7607b4..30fd4a4df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,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" @@ -137,8 +136,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" } @@ -178,4 +175,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" } From 058cdc782adcc88eb9600bcc41d429de8122df05 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Mon, 26 Jan 2026 11:18:48 -0300 Subject: [PATCH 35/38] Load and save payment targets --- .../amethyst/LocalPreferences.kt | 5 ++ .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/model/AccountSettings.kt | 12 +++ .../amethyst/model/LocalCache.kt | 47 +++++++++++ .../NipA3PaymentTargetsState.kt | 65 +++++++++++++++ .../account/AccountFilterAssembler.kt | 2 + .../AccountPaymentTargetsEoseManager.kt | 83 +++++++++++++++++++ .../FilterPaymentTargetsFromKey.kt | 52 ++++++++++++ .../experimental/nipA3/PaymentTargetsEvent.kt | 3 + 9 files changed, 272 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/AccountPaymentTargetsEoseManager.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/FilterPaymentTargetsFromKey.kt 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..d4f4bc29c 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 @@ -353,6 +354,8 @@ class Account( val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) + val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings) + val feedDecryptionCaches = FeedDecryptionCaches( peopleListCache = peopleListDecryptionCache, 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..9b7589588 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -48,6 +48,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 @@ -783,6 +784,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?, @@ -2959,6 +3005,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/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index b62529dcc..c5ab6e0d9 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 @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadat 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/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/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, From f618741211ca35b04d88547c0a8fb482a3641d0d Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 26 Jan 2026 14:44:00 +0000 Subject: [PATCH 36/38] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 853644faa..eaf7cce96 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -733,7 +733,7 @@ Popularne Wybrane przez algorytm Market - Transmisja na żywo + Na żywo Społeczności Czaty Zatwierdzone posty From 6fff7dc17b0fbc271af8ebede24e1fb476f9ce3b Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 26 Jan 2026 14:55:12 +0000 Subject: [PATCH 37/38] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 +++--- amethyst/src/main/res/values-de-rDE/strings.xml | 3 --- amethyst/src/main/res/values-pt-rBR/strings.xml | 3 --- amethyst/src/main/res/values-sv-rSE/strings.xml | 3 --- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6f1e6eaa8..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. @@ -1234,7 +1237,4 @@ Smazat seznam Smazat sadu doporučení Typy - Sdílet video… - Video nelze sdílet, zkuste to prosím později… - Stahování videa… diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index c629cc2d1..9015e0965 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1239,7 +1239,4 @@ anz der Bedingungen ist erforderlich Liste löschen Empfehlungspaket löschen Typen - Video teilen… - Video kann nicht geteilt werden, bitte versuchen Sie es später erneut… - Video wird heruntergeladen… diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index c9a50c779..a90421341 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1234,7 +1234,4 @@ Excluir lista Excluir pacote de recomendações Tipos - Compartilhar vídeo… - Não foi possível compartilhar o vídeo, tente novamente mais tarde… - Baixando vídeo… diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index f7eefcb4c..44571cb2a 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1233,7 +1233,4 @@ Ta bort lista Ta bort rekommendationspaket Typer - Dela video… - Det gick inte att dela videon, försök igen senare… - Laddar ner videon… From f2c27dbdc83cf11d0d49faf4c2ac3bbeceb6a0e5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 26 Jan 2026 15:00:22 +0000 Subject: [PATCH 38/38] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 +++--- amethyst/src/main/res/values-de-rDE/strings.xml | 3 --- amethyst/src/main/res/values-pt-rBR/strings.xml | 3 --- amethyst/src/main/res/values-sv-rSE/strings.xml | 3 --- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6f1e6eaa8..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. @@ -1234,7 +1237,4 @@ Smazat seznam Smazat sadu doporučení Typy - Sdílet video… - Video nelze sdílet, zkuste to prosím později… - Stahování videa… diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index c629cc2d1..9015e0965 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1239,7 +1239,4 @@ anz der Bedingungen ist erforderlich Liste löschen Empfehlungspaket löschen Typen - Video teilen… - Video kann nicht geteilt werden, bitte versuchen Sie es später erneut… - Video wird heruntergeladen… diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index c9a50c779..a90421341 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1234,7 +1234,4 @@ Excluir lista Excluir pacote de recomendações Tipos - Compartilhar vídeo… - Não foi possível compartilhar o vídeo, tente novamente mais tarde… - Baixando vídeo… diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index f7eefcb4c..44571cb2a 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1233,7 +1233,4 @@ Ta bort lista Ta bort rekommendationspaket Typer - Dela video… - Det gick inte att dela videon, försök igen senare… - Laddar ner videon…