From ec629ac3fea7ca01a34133bce44ddf92855b20e8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 12 Jan 2026 06:34:14 +0200 Subject: [PATCH 1/5] 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 2/5] 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 8f6de1d304a88523b2dbae9a983babc64b907667 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 19 Jan 2026 16:32:01 +0200 Subject: [PATCH 3/5] 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 4/5] 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 ff47080cf1cca83bf1ef56b659fb4b191b92b6e8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 21 Jan 2026 06:23:58 +0200 Subject: [PATCH 5/5] 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