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 9b7589588..e1c866921 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor @@ -227,7 +228,7 @@ object LocalCache : ILocalCache, ICacheProvider { val liveChatChannels = LargeCache() val ephemeralChannels = LargeCache() - val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + val paymentTracker = NwcPaymentTracker() val relayHints = HintIndexer() @@ -310,7 +311,7 @@ object LocalCache : ILocalCache, ICacheProvider { fun load(keys: Set): Set = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) - fun getOrCreateUser(key: HexKey): User { + override fun getOrCreateUser(key: HexKey): User { require(isValidHex(key = key)) { "$key is not a valid hex" } return users.getOrCreate(key) { @@ -2060,7 +2061,7 @@ object LocalCache : ILocalCache, ICacheProvider { zappedNote?.addZapPayment(note, null) - awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) + paymentTracker.registerRequest(event.id, zappedNote, onResponse) refreshNewNoteObservers(note) @@ -2076,9 +2077,10 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val requestId = event.requestId() - val pair = awaitingPaymentRequests[requestId] ?: return false + val pending = paymentTracker.onResponseReceived(requestId) ?: return false - val (zappedNote, responseCallback) = pair + val zappedNote = pending.zappedNote + val responseCallback = pending.onResponse val requestNote = requestId?.let { checkGetOrCreateNote(requestId) } 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 c5ab6e0d9..74ceae5a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -20,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 3a46ae22e..eee6db615 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt index 7a5534b39..f4f2bf0ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt index 07a032201..789dc3909 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixCha import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.filterChannelMetadataUpdatesById import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities.filterLiveStreamUpdatesByAddress diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index db3d6bb25..a3bf87923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt index d0ffe095e..a9c131539 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index ff3f946a6..efa768df6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.MutableTime diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt index fbb130e33..caf176734 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt index b71822d98..9fd6d793a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index dc4240828..712a84916 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt index 7e1450331..2bd9789fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @SuppressLint("StateFlowValueCalledInComposition") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index 76108f540..a7dcadb85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index a8bdc42cd..030afe5d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.toHexSet +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.MutableTime import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index 2b4843562..3b102fc38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.toHexSet +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.MutableTime import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index d3dd7e145..414e831ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt index 811cd7338..a29198da3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt index 534621242..a38617087 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/TextSearchDataSourceSubscription.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt index c6a337bee..a5744c98d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/UserSearchDataSourceSubscription.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/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 fffbe41a2..af4874cb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -77,7 +78,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/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 96ef67020..41fd483a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt index bb98b4d51..31249fafd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt index 91d249db6..60495011c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelFromUserFilterSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelPublicFilterSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index e9ff59571..6ba41d901 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.Channel -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 79174b783..a8be570de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt index 9c041e289..6104739fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt index 2007746cc..5ddb60842 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt index 531a781bd..2a49e068a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt index ed4f4f3f1..c3d2f1fdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssemblerSubscription.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription @Composable fun CommunityFilterAssemblerSubscription( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt index 9d9f83089..d5048b017 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt index 5a2a10b0c..d34fdd0f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt index ef3f1a91a..f11ea2a23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt index c30204f15..7b18453a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasourc import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt index 80681a5f2..c7b018856 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.filterHomePostsByAuthors import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt index 8b80a73b5..ef29b850b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt index 49bbf8ef1..3e3cf3307 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt index 09b7c405c..a64e66fc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt index 1bf1a8387..e1f882f2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt index 4b749ce55..99e2ef0cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxEventsEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt index 34c15b570..21eda3d9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index db70c3229..eb0fd60a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -26,6 +26,8 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState +import com.vitorpamplona.amethyst.commons.ui.notifications.Card +import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt index 7f696f9bf..58c7278b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt @@ -21,8 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState +import com.vitorpamplona.amethyst.commons.ui.notifications.Card import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji @@ -30,14 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.flow.MutableStateFlow - -@Immutable -interface Card { - fun createdAt(): Long - - fun id(): String -} @Immutable class BadgeCard( @@ -113,19 +104,3 @@ class MessageSetCard( override fun id() = note.idHex } - -@Immutable -sealed class CardFeedState { - @Immutable object Loading : CardFeedState() - - @Stable - class Loaded( - val feed: MutableStateFlow>, - ) : CardFeedState() - - @Immutable object Empty : CardFeedState() - - @Immutable class FeedError( - val errorMessage: String, - ) : CardFeedState() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index bffecaa20..df5ee3349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -43,6 +43,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.notifications.Card +import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedError diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt index 5a217020b..3f7b17701 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt index 94af9e02b..deecd3e27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssemblerSubscription.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription @Composable fun UserProfileFilterAssemblerSubscription( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt index 07c905301..9d3545f79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt index e389d4501..cce418cd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadFilterSubAssembler import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt index 7954bda88..5360d95c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt index 435189d58..8f4c6b9e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoOutboxEventsFilterSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt index 691cb8dbb..a0dffc8d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt new file mode 100644 index 000000000..368c22e12 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.icons + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Preview +@Composable +private fun VectorPreview() { + Image(Bookmark, null) +} + +@Preview +@Composable +private fun FilledPreview() { + Image(BookmarkFilled, null) +} + +private var bookmark: ImageVector? = null +private var bookmarkFilled: ImageVector? = null + +val Bookmark: ImageVector + get() = + bookmark ?: ImageVector + .Builder( + name = "Bookmark", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + // Material bookmark outline + moveTo(17.0f, 3.0f) + horizontalLineTo(7.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(16.0f) + lineToRelative(7.0f, -3.0f) + lineToRelative(7.0f, 3.0f) + verticalLineTo(5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + moveTo(17.0f, 18.0f) + lineToRelative(-5.0f, -2.18f) + lineTo(7.0f, 18.0f) + verticalLineTo(5.0f) + horizontalLineToRelative(10.0f) + verticalLineToRelative(13.0f) + close() + } + }.build() + .also { bookmark = it } + +val BookmarkFilled: ImageVector + get() = + bookmarkFilled ?: ImageVector + .Builder( + name = "BookmarkFilled", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + // Material bookmark filled + moveTo(17.0f, 3.0f) + horizontalLineTo(7.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(16.0f) + lineToRelative(7.0f, -3.0f) + lineToRelative(7.0f, 3.0f) + verticalLineTo(5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + } + }.build() + .also { bookmarkFilled = it } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt index 721fdc6f8..bd87690ba 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt @@ -112,5 +112,27 @@ interface ICacheProvider { */ fun hasBeenDeleted(event: Any): Boolean + /** + * Finds users whose name, displayName, nip05, or lud16 starts with the given prefix. + * Used by search functionality to find users by name. + * + * @param prefix The search prefix to match against user names + * @param limit Maximum number of results to return + * @return List of Users matching the prefix + */ + fun findUsersStartingWith( + prefix: String, + limit: Int = 50, + ): List = emptyList() + + /** + * Gets or creates a User by public key hex. + * Used when processing events that reference users. + * + * @param pubkey The user's public key in hex format + * @return The User (existing or newly created) + */ + fun getOrCreateUser(pubkey: HexKey): Any? + fun justConsumeMyOwnEvent(event: Event): Boolean } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt new file mode 100644 index 000000000..87ab2a074 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -0,0 +1,221 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.assemblers + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.PrioritizedSubscriptionQueue +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubscriptionPriority +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.CoroutineScope + +/** + * Coordinates metadata and reactions loading for feed items. + * Ensures metadata (display names, avatars) loads before reactions. + * + * Priority order: + * 1. METADATA - Display names, avatars (highest priority) + * 2. REACTIONS - Likes, zaps, reposts (second priority) + * + * Usage: + * ``` + * val coordinator = FeedMetadataCoordinator(client, scope, indexRelays, preloader) + * coordinator.start() + * + * // When feed loads new notes + * LaunchedEffect(notes) { + * coordinator.loadMetadataForNotes(notes) + * } + * ``` + */ +class FeedMetadataCoordinator( + private val client: INostrClient, + private val scope: CoroutineScope, + private val indexRelays: Set, + private val preloader: MetadataPreloader? = null, + private val onEvent: ((Event, NormalizedRelayUrl) -> Unit)? = null, +) { + private val priorityQueue = PrioritizedSubscriptionQueue(scope) + + // Track what we've already queued to avoid duplicates + private val queuedPubkeys = mutableSetOf() + private val queuedNoteIds = mutableSetOf() + + /** + * Start processing the subscription queue. + * Call once when coordinator is created. + */ + fun start() { + priorityQueue.start { filter -> + // Convert filter to relay-based map for all index relays + val filterMap = indexRelays.associateWith { listOf(filter) } + + // Create listener to pass events to the callback + val listener = + if (onEvent != null) { + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onEvent.invoke(event, relay) + } + } + } else { + null + } + + client.openReqSubscription( + subId = newSubId(), + filters = filterMap, + listener = listener, + ) + } + } + + /** + * Load metadata and reactions for a list of notes. + * Metadata loads first (priority 1), then reactions (priority 2). + * + * @param notes The notes to load metadata/reactions for + */ + fun loadMetadataForNotes(notes: List) { + if (notes.isEmpty()) return + + // Extract unique authors that we haven't already queued + val authors = + notes + .mapNotNull { it.author?.pubkeyHex } + .filter { it !in queuedPubkeys } + .distinct() + + // Extract unique note IDs that we haven't already queued + val noteIds = + notes + .map { it.idHex } + .filter { it !in queuedNoteIds } + .distinct() + + // Queue metadata first (highest priority) + if (authors.isNotEmpty()) { + queuedPubkeys.addAll(authors) + + // Use preloader if available for rate-limited loading + if (preloader != null) { + notes.mapNotNull { it.author }.forEach { user -> + preloader.preloadForUser(user) + } + } else { + // Direct queue without rate limiting + val metadataFilter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = authors, + limit = authors.size, + ) + priorityQueue.enqueue( + SubscriptionPriority.METADATA, + metadataFilter, + tag = "feed-metadata", + ) + } + } + + // Queue reactions second (lower priority) + if (noteIds.isNotEmpty()) { + queuedNoteIds.addAll(noteIds) + + val reactionsFilter = + Filter( + kinds = listOf(ReactionEvent.KIND), + tags = mapOf("e" to noteIds), + ) + priorityQueue.enqueue( + SubscriptionPriority.REACTIONS, + reactionsFilter, + tag = "feed-reactions", + ) + } + } + + /** + * Load metadata for specific pubkeys. + * Useful for loading follower/following metadata. + */ + fun loadMetadataForPubkeys(pubkeys: List) { + val newPubkeys = pubkeys.filter { it !in queuedPubkeys } + if (newPubkeys.isEmpty()) return + + queuedPubkeys.addAll(newPubkeys) + + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = newPubkeys, + limit = newPubkeys.size, + ) + priorityQueue.enqueue( + SubscriptionPriority.METADATA, + filter, + tag = "pubkey-metadata", + ) + } + + /** + * Load reactions for specific note IDs. + */ + fun loadReactionsForNotes(noteIds: List) { + val newNoteIds = noteIds.filter { it !in queuedNoteIds } + if (newNoteIds.isEmpty()) return + + queuedNoteIds.addAll(newNoteIds) + + val filter = + Filter( + kinds = listOf(ReactionEvent.KIND), + tags = mapOf("e" to newNoteIds), + ) + priorityQueue.enqueue( + SubscriptionPriority.REACTIONS, + filter, + tag = "note-reactions", + ) + } + + /** + * Clear queued items. Call when switching feeds. + */ + fun clear() { + priorityQueue.clear() + queuedPubkeys.clear() + queuedNoteIds.clear() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt new file mode 100644 index 000000000..fa148168a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.assemblers + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Query state for metadata subscriptions. + * Groups pubkeys with their preferred index relays. + */ +data class MetadataQueryState( + val pubkeys: Set, + val indexRelays: Set, +) + +/** + * Subscribes to Kind 0 (user metadata) for a set of pubkeys. + * Used to load display names, avatars, and other profile information. + * + * This assembler: + * - Batches multiple pubkey requests into single subscription + * - Sends requests to index relays for efficient discovery + * - Caches EOSE to avoid re-fetching known metadata + */ +class MetadataFilterAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun distinct(key: MetadataQueryState): Any = key.pubkeys.hashCode() + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + // Collect all pubkeys and relays from all query states + val allPubkeys = mutableSetOf() + val allRelays = mutableSetOf() + + keys.forEach { state -> + allPubkeys.addAll(state.pubkeys) + allRelays.addAll(state.indexRelays) + } + + if (allPubkeys.isEmpty() || allRelays.isEmpty()) return null + + val pubkeyList = allPubkeys.toList() + + // Create filter for metadata (Kind 0) + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = pubkeyList, + limit = pubkeyList.size, + ) + + // Apply since times per relay + return allRelays.map { relay -> + val sinceTime = since?.get(relay)?.time + val filterWithSince = + if (sinceTime != null) { + filter.copy(since = sinceTime) + } else { + filter + } + RelayBasedFilter(relay, filterWithSince) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt new file mode 100644 index 000000000..213b4e34d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.assemblers + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + +/** + * Query state for reactions subscriptions. + * Groups note IDs with their preferred relays. + */ +data class ReactionsQueryState( + val noteIds: Set, + val relays: Set, +) + +/** + * Subscribes to Kind 7 (reactions) for a set of note IDs. + * Used to load like counts, zap counts, and other reactions. + * + * This assembler: + * - Batches multiple note ID requests into single subscription + * - Uses e-tags to filter reactions for specific notes + * - Caches EOSE to avoid re-fetching known reactions + */ +class ReactionsFilterAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun distinct(key: ReactionsQueryState): Any = key.noteIds.hashCode() + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + // Collect all note IDs and relays from all query states + val allNoteIds = mutableSetOf() + val allRelays = mutableSetOf() + + keys.forEach { state -> + allNoteIds.addAll(state.noteIds) + allRelays.addAll(state.relays) + } + + if (allNoteIds.isEmpty() || allRelays.isEmpty()) return null + + val noteIdList = allNoteIds.toList() + + // Create filter for reactions (Kind 7) targeting these notes via e-tags + val filter = + Filter( + kinds = listOf(ReactionEvent.KIND), + tags = mapOf("e" to noteIdList), + ) + + // Apply since times per relay + return allRelays.map { relay -> + val sinceTime = since?.get(relay)?.time + val filterWithSince = + if (sinceTime != null) { + filter.copy(since = sinceTime) + } else { + filter + } + RelayBasedFilter(relay, filterWithSince) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index 4c9bb3f66..e6c6c8202 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers +package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import java.util.concurrent.ConcurrentHashMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt index f26b1752d..7ee190b8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManagerControls.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers +package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers interface ComposeSubscriptionManagerControls { fun invalidateKeys() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index 9966f901d..aa890e82b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers +package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index 970e79779..7e5322fef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers -import com.vitorpamplona.amethyst.isDebug -import com.vitorpamplona.amethyst.service.BundledUpdate +import com.vitorpamplona.amethyst.commons.service.BundledUpdate +import com.vitorpamplona.amethyst.commons.utils.isDebug import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId @@ -29,18 +29,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscriptio import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers -interface IEoseManager { - fun invalidateFilters(ignoreIfDoing: Boolean = false) - - fun destroy() -} - abstract class BaseEoseManager( val client: INostrClient, val allKeys: () -> Set, val sampleTime: Long = 500, ) : IEoseManager { - protected val logTag: String = this.javaClass.simpleName + protected val logTag: String = this::class.simpleName ?: "BaseEoseManager" private val orchestrator = SubscriptionController(client) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/IEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/IEoseManager.kt new file mode 100644 index 000000000..787e9a64c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/IEoseManager.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers + +interface IEoseManager { + fun invalidateFilters(ignoreIfDoing: Boolean = false) + + fun destroy() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt new file mode 100644 index 000000000..4768f18c8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt @@ -0,0 +1,162 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.commons.relays.EOSECache +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Generic per-key EOSE manager that creates a subscription for each unique key. + * + * This query type creates a new relay subscription for every distinct key K + * extracted from query state T. It is ideal for screens that need separate + * subscriptions per entity (user, thread, etc.). + * + * @param T The query state type (e.g., ThreadQueryState) + * @param K The key type used to deduplicate subscriptions (e.g., String noteId) + * + * This class keeps EOSEs for each key for as long as possible and can be + * shared among multiple query states that map to the same key. + */ +abstract class PerKeyEoseManager( + client: INostrClient, + allKeys: () -> Set, + val invalidateAfterEose: Boolean = false, + cacheSize: Int = 200, +) : BaseEoseManager(client, allKeys) { + // EOSE cache keyed by K + private val latestEOSEs = EOSECache(cacheSize) + + // Map from key K to subscription ID + private val keySubscriptionMap = mutableMapOf() + + /** + * Get the since map for a query state's key. + */ + fun since(queryState: T): SincePerRelayMap? = latestEOSEs.since(extractKey(queryState)) + + /** + * Record a new EOSE for a query state. + */ + open fun newEose( + queryState: T, + relayUrl: NormalizedRelayUrl, + time: Long, + filters: List? = null, + ) { + latestEOSEs.newEose(extractKey(queryState), relayUrl, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } + + /** + * Create a new subscription for a query state. + */ + open fun newSub(queryState: T): Subscription = + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(queryState, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(queryState, relay, TimeUtils.now(), forFilters) + } + } + }, + ) + + /** + * End a subscription for a key. + */ + open fun endSub( + key: K, + subId: String, + ) { + dismissSubscription(subId) + keySubscriptionMap.remove(key) + } + + /** + * Find or create a subscription for a query state. + */ + fun findOrCreateSubFor(queryState: T): Subscription { + val key = extractKey(queryState) + val subId = keySubscriptionMap[key] + return if (subId == null) { + newSub(queryState).also { keySubscriptionMap[key] = it.id } + } else { + getSubscription(subId) ?: newSub(queryState).also { keySubscriptionMap[key] = it.id } + } + } + + override fun updateSubscriptions(keys: Set) { + val uniqueByKey = keys.distinctBy { extractKey(it) } + + val updatedKeys = mutableSetOf() + + uniqueByKey.forEach { queryState -> + val key = extractKey(queryState) + val newFilters = updateFilter(queryState, since(queryState))?.ifEmpty { null } + findOrCreateSubFor(queryState).updateFilters(newFilters?.groupByRelay()) + updatedKeys.add(key) + } + + // Clean up subscriptions for keys no longer active + keySubscriptionMap.filter { it.key !in updatedKeys }.forEach { + endSub(it.key, it.value) + } + } + + /** + * Build filters for a query state. + * @return List of relay-based filters, or null to clear the subscription + */ + abstract fun updateFilter( + queryState: T, + since: SincePerRelayMap?, + ): List? + + /** + * Extract the deduplication key from a query state. + * Subscriptions are shared among query states with the same key. + */ + abstract fun extractKey(queryState: T): K +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt index 795af8564..04ab7376c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers -import com.vitorpamplona.amethyst.service.relays.EOSERelayList -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relays.EOSERelayList +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataPreloader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataPreloader.kt new file mode 100644 index 000000000..14771a607 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataPreloader.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.preload + +import com.vitorpamplona.amethyst.commons.model.User + +/** + * Preloads user metadata and avatar images for feed items. + * Coordinates between metadata subscription (rate-limited) and image preloading. + * + * Priority order: + * 1. Metadata (display names, avatar URLs) - FIRST + * 2. Avatar images (prefetch when metadata arrives) - SECOND + */ +class MetadataPreloader( + private val rateLimiter: MetadataRateLimiter, + private val imagePrefetcher: ImagePrefetcher? = null, +) { + /** + * Queue users for metadata preloading. + * If user already has metadata, prefetch their avatar image. + * Otherwise, queue for metadata subscription. + */ + fun preloadForUsers(users: Collection) { + users.forEach { user -> + val metadata = user.info + if (metadata != null) { + // Already have metadata, prefetch avatar + metadata.picture?.let { avatarUrl -> + imagePrefetcher?.prefetch(avatarUrl) + } + } else { + // Need to fetch metadata first + rateLimiter.enqueue(user.pubkeyHex) + } + } + } + + /** + * Queue a single user for metadata preloading. + */ + fun preloadForUser(user: User) { + val metadata = user.info + if (metadata != null) { + metadata.picture?.let { avatarUrl -> + imagePrefetcher?.prefetch(avatarUrl) + } + } else { + rateLimiter.enqueue(user.pubkeyHex) + } + } + + /** + * Called when metadata arrives for a user. + * Triggers avatar image prefetch. + */ + fun onMetadataReceived(user: User) { + user.info?.picture?.let { avatarUrl -> + imagePrefetcher?.prefetch(avatarUrl) + } + } + + /** + * Prefetch avatar images for users that already have metadata. + */ + fun prefetchAvatars(users: Collection) { + users.forEach { user -> + user.info?.picture?.let { avatarUrl -> + imagePrefetcher?.prefetch(avatarUrl) + } + } + } +} + +/** + * Interface for image prefetching. + * Platform-specific implementations use Coil (Android) or similar (Desktop). + */ +interface ImagePrefetcher { + /** + * Prefetch an image URL into the cache. + */ + fun prefetch(url: String) + + /** + * Prefetch multiple image URLs. + */ + fun prefetchAll(urls: Collection) { + urls.forEach { prefetch(it) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt new file mode 100644 index 000000000..f359f0012 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/preload/MetadataRateLimiter.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.preload + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch + +/** + * Global rate limiter for metadata requests to prevent thundering herd on fast scroll. + * Batches pubkeys and processes at a controlled rate. + * + * @param maxRequestsPerSecond Maximum requests to process per second (default 20) + * @param scope CoroutineScope for processing + */ +class MetadataRateLimiter( + private val maxRequestsPerSecond: Int = 20, + private val scope: CoroutineScope, +) { + private val queue = Channel(Channel.BUFFERED) + private val processed = mutableSetOf() + + /** + * Enqueue a pubkey for metadata fetching. + * Duplicates within the same batch are automatically filtered. + */ + fun enqueue(pubkey: String) { + if (pubkey !in processed) { + queue.trySend(pubkey) + } + } + + /** + * Enqueue multiple pubkeys for metadata fetching. + */ + fun enqueueAll(pubkeys: Collection) { + pubkeys.forEach { enqueue(it) } + } + + /** + * Start processing the queue with rate limiting. + * @param onRequest Callback invoked for each pubkey to process + */ + fun start(onRequest: suspend (String) -> Unit) { + scope.launch { + val batch = mutableListOf() + queue.consumeAsFlow().collect { pubkey -> + if (pubkey !in processed) { + batch.add(pubkey) + processed.add(pubkey) + } + + // Process batch when we hit the limit or queue is empty + if (batch.size >= maxRequestsPerSecond) { + processBatch(batch, onRequest) + batch.clear() + delay(1000) // Wait 1 second before next batch + } + } + + // Process remaining + if (batch.isNotEmpty()) { + processBatch(batch, onRequest) + } + } + } + + private suspend fun processBatch( + batch: List, + onRequest: suspend (String) -> Unit, + ) { + batch.forEach { pubkey -> + onRequest(pubkey) + } + } + + /** + * Clear the processed set to allow re-fetching. + * Call this when switching accounts or clearing cache. + */ + fun reset() { + processed.clear() + } + + /** + * Check if a pubkey has already been processed. + */ + fun isProcessed(pubkey: String): Boolean = pubkey in processed +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt index 039b715fe..60cb75256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/KeyDataSourceSubscription.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/KeyDataSourceSubscription.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager -import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState @Composable fun KeyDataSourceSubscription( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/PrioritizedSubscriptionQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/PrioritizedSubscriptionQueue.kt new file mode 100644 index 000000000..8eac02945 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/PrioritizedSubscriptionQueue.kt @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch + +/** + * A subscription request with its priority. + */ +data class PrioritizedFilter( + val priority: SubscriptionPriority, + val filter: Filter, + val tag: String? = null, +) + +/** + * Queue that processes subscription requests in priority order. + * Ensures metadata loads before reactions, reactions before replies, etc. + * + * Usage: + * ``` + * val queue = PrioritizedSubscriptionQueue(scope) + * queue.start { filter -> relayClient.subscribe(filter) } + * + * // Add subscriptions - they'll be processed in priority order + * queue.enqueue(SubscriptionPriority.METADATA, metadataFilter) + * queue.enqueue(SubscriptionPriority.REACTIONS, reactionsFilter) + * ``` + */ +class PrioritizedSubscriptionQueue( + private val scope: CoroutineScope, +) { + // Separate channels per priority for efficient processing + private val queues = + SubscriptionPriority.entries.associateWith { + Channel(Channel.BUFFERED) + } + + private var isRunning = false + + /** + * Enqueue a filter with the given priority. + */ + fun enqueue( + priority: SubscriptionPriority, + filter: Filter, + tag: String? = null, + ) { + queues[priority]?.trySend(PrioritizedFilter(priority, filter, tag)) + } + + /** + * Enqueue multiple filters with the same priority. + */ + fun enqueueAll( + priority: SubscriptionPriority, + filters: List, + tag: String? = null, + ) { + filters.forEach { enqueue(priority, it, tag) } + } + + /** + * Start processing the queue. + * Processes higher priority items first within each batch. + * + * @param onSubscribe Callback to execute the subscription + */ + fun start(onSubscribe: suspend (Filter) -> Unit) { + if (isRunning) return + isRunning = true + + // Process each priority queue in order + SubscriptionPriority.sortedByPriority().forEach { priority -> + scope.launch { + queues[priority]?.consumeAsFlow()?.collect { item -> + onSubscribe(item.filter) + } + } + } + } + + /** + * Process all pending items immediately in priority order. + * Useful for batch operations. + */ + suspend fun flush(onSubscribe: suspend (Filter) -> Unit) { + SubscriptionPriority.sortedByPriority().forEach { priority -> + val queue = queues[priority] ?: return@forEach + while (true) { + val item = queue.tryReceive().getOrNull() ?: break + onSubscribe(item.filter) + } + } + } + + /** + * Clear all pending items. + */ + fun clear() { + queues.values.forEach { channel -> + while (channel.tryReceive().isSuccess) { + // Drain the channel + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubscriptionPriority.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubscriptionPriority.kt new file mode 100644 index 000000000..b752ca47a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubscriptionPriority.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +/** + * Priority levels for relay subscriptions. + * Lower order = higher priority = processed first. + * + * Priority order ensures progressive rendering: + * 1. METADATA - Display names, avatars load first + * 2. REACTIONS - Like/zap counts load second + * 3. REPLIES - Reply counts + * 4. CONTENT - Additional content + */ +enum class SubscriptionPriority( + val order: Int, +) { + /** User metadata (Kind 0) - display names, avatars. Highest priority. */ + METADATA(1), + + /** Reactions (Kind 7) - likes, zaps, reposts */ + REACTIONS(2), + + /** Replies - reply counts and threads */ + REPLIES(3), + + /** Additional content - lower priority items */ + CONTENT(4), + ; + + companion object { + /** + * Get priorities sorted by order (highest priority first). + */ + fun sortedByPriority(): List = entries.sortedBy { it.order } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt new file mode 100644 index 000000000..a4f5d4eaa --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relays + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Generic EOSE cache keyed by any type K. + * KMP-compatible version (no LruCache dependency). + * + * For memory-constrained environments, consider using platform-specific + * LRU implementations in androidMain/jvmMain. + */ +open class EOSECache( + private val maxSize: Int = 200, +) { + private val cache = linkedMapOf() + private val lock = Any() + + fun addOrUpdate( + key: K, + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + synchronized(lock) { + val relayList = cache[key] + if (relayList == null) { + // Evict oldest if at capacity + if (cache.size >= maxSize) { + cache.remove(cache.keys.first()) + } + val newList = EOSERelayList() + newList.addOrUpdate(relayUrl, time) + cache[key] = newList + } else { + relayList.addOrUpdate(relayUrl, time) + } + } + } + + fun since(key: K): SincePerRelayMap? = + synchronized(lock) { + cache[key]?.relayList?.toMutableMap() + } + + fun newEose( + key: K, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(key, relayUrl, time) + + fun remove(key: K) { + synchronized(lock) { + cache.remove(key) + } + } + + fun clear() { + synchronized(lock) { + cache.clear() + } + } + + fun size(): Int = synchronized(lock) { cache.size } +} + +/** + * Two-level EOSE cache: outer key -> inner key -> relay list. + * Useful for user + list combinations. + */ +open class EOSETwoLevelCache( + private val outerMaxSize: Int = 20, + private val innerMaxSize: Int = 200, +) { + private val cache = linkedMapOf>() + private val lock = Any() + + fun addOrUpdate( + outerKey: K1, + innerKey: K2, + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + synchronized(lock) { + val innerCache = cache[outerKey] + if (innerCache == null) { + // Evict oldest if at capacity + if (cache.size >= outerMaxSize) { + cache.remove(cache.keys.first()) + } + val newCache = EOSECache(innerMaxSize) + newCache.addOrUpdate(innerKey, relayUrl, time) + cache[outerKey] = newCache + } else { + innerCache.addOrUpdate(innerKey, relayUrl, time) + } + } + } + + fun since( + outerKey: K1, + innerKey: K2, + ): SincePerRelayMap? = + synchronized(lock) { + cache[outerKey]?.since(innerKey) + } + + fun newEose( + outerKey: K1, + innerKey: K2, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(outerKey, innerKey, relayUrl, time) + + fun removeOuter(key: K1) { + synchronized(lock) { + cache.remove(key) + } + } + + fun clear() { + synchronized(lock) { + cache.clear() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/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/ui/notifications/CardFeedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt new file mode 100644 index 000000000..53406c4f6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.notifications + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Base interface for notification cards. + * + * Cards represent grouped or individual notification items in the feed. + * Platform implementations provide concrete card types (NoteCard, MultiSetCard, etc.). + */ +@Immutable +interface Card { + /** + * Returns the creation timestamp of this card. + * Used for sorting cards in chronological order. + */ + fun createdAt(): Long + + /** + * Returns a unique identifier for this card. + * Used for list diffing and deduplication. + */ + fun id(): String +} + +/** + * Feed state for notification cards. + * + * Mirrors FeedState but specifically typed for Card content. + */ +@Stable +sealed class CardFeedState { + @Immutable + object Loading : CardFeedState() + + @Stable + class Loaded( + val feed: MutableStateFlow>, + ) : CardFeedState() + + @Immutable + object Empty : CardFeedState() + + @Immutable + class FeedError( + val errorMessage: String, + ) : CardFeedState() +} + +/** + * Comparator for sorting cards by creation time (newest first). + */ +val DefaultCardComparator: Comparator = + compareByDescending { it.createdAt() } + .thenByDescending { it.id() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt new file mode 100644 index 000000000..6e873eae4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/SearchBarState.kt @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.search.SearchResult +import com.vitorpamplona.amethyst.commons.search.parseSearchInput +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * State holder for search bar functionality. + * Shared between Android and Desktop search screens. + * + * Handles: + * - Search text input + * - Bech32/hex parsing + * - Local cache search (with debounce) + * - Relay search results aggregation + * + * Platform-specific concerns (relay subscriptions, navigation) remain in the UI layer. + */ +class SearchBarState( + private val cache: ICacheProvider, + private val scope: CoroutineScope, + private val debounceMs: Long = 300L, +) { + private val _searchText = MutableStateFlow("") + val searchText: StateFlow = _searchText.asStateFlow() + + private val _bech32Results = MutableStateFlow>(emptyList()) + val bech32Results: StateFlow> = _bech32Results.asStateFlow() + + private val _cachedUserResults = MutableStateFlow>(emptyList()) + val cachedUserResults: StateFlow> = _cachedUserResults.asStateFlow() + + private val _relaySearchResults = MutableStateFlow>(emptyList()) + val relaySearchResults: StateFlow> = _relaySearchResults.asStateFlow() + + private val _isSearchingRelays = MutableStateFlow(false) + val isSearchingRelays: StateFlow = _isSearchingRelays.asStateFlow() + + val hasResults: Boolean + get() = + _bech32Results.value.isNotEmpty() || + _cachedUserResults.value.isNotEmpty() || + _relaySearchResults.value.isNotEmpty() + + val shouldSearchRelays: Boolean + get() = + _searchText.value.length >= 2 && + _bech32Results.value.isEmpty() && + _cachedUserResults.value.size < 5 + + init { + setupSearchTextObserver() + } + + @OptIn(FlowPreview::class) + private fun setupSearchTextObserver() { + // Debounced cache search + _searchText + .debounce(debounceMs) + .onEach { query -> + if (query.length >= 2 && _bech32Results.value.isEmpty()) { + @Suppress("UNCHECKED_CAST") + _cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List + } else { + _cachedUserResults.value = emptyList() + } + }.launchIn(scope) + } + + fun updateSearchText(text: String) { + _searchText.value = text + _relaySearchResults.value = emptyList() + _isSearchingRelays.value = false + + // Parse Bech32/hex immediately (no debounce) + _bech32Results.value = parseSearchInput(text) + + // Clear cached results if query too short or is bech32 + if (text.length < 2 || _bech32Results.value.isNotEmpty()) { + _cachedUserResults.value = emptyList() + } + } + + fun clearSearch() { + updateSearchText("") + } + + fun startRelaySearch() { + _isSearchingRelays.value = true + } + + fun endRelaySearch() { + _isSearchingRelays.value = false + } + + fun addRelaySearchResult(user: User) { + if (!_relaySearchResults.value.any { it.pubkeyHex == user.pubkeyHex }) { + _relaySearchResults.value = _relaySearchResults.value + user + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt new file mode 100644 index 000000000..52c59420b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Bookmarks/BookmarkAction.kt @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip51Bookmarks + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark + +/** + * Handles NIP-51 bookmark operations. + * Shared between Android and Desktop. + */ +object BookmarkAction { + /** + * Creates a new bookmark list with a single event bookmarked. + */ + suspend fun createWithBookmark( + eventId: HexKey, + relayHint: NormalizedRelayUrl? = null, + isPrivate: Boolean = false, + signer: NostrSigner, + ): BookmarkListEvent { + val bookmark = EventBookmark(eventId, relayHint) + return BookmarkListEvent.create( + bookmarkIdTag = bookmark, + isPrivate = isPrivate, + signer = signer, + ) + } + + /** + * Adds an event to an existing bookmark list. + */ + suspend fun addBookmark( + existingList: BookmarkListEvent, + eventId: HexKey, + relayHint: NormalizedRelayUrl? = null, + isPrivate: Boolean = false, + signer: NostrSigner, + ): BookmarkListEvent { + val bookmark = EventBookmark(eventId, relayHint) + return BookmarkListEvent.add( + earlierVersion = existingList, + bookmarkIdTag = bookmark, + isPrivate = isPrivate, + signer = signer, + ) + } + + /** + * Removes an event from a bookmark list. + * Checks both public and private bookmarks. + */ + suspend fun removeBookmark( + existingList: BookmarkListEvent, + eventId: HexKey, + signer: NostrSigner, + ): BookmarkListEvent { + val bookmark = EventBookmark(eventId) + return BookmarkListEvent.remove( + earlierVersion = existingList, + bookmarkIdTag = bookmark, + signer = signer, + ) + } + + /** + * Removes an event from a bookmark list (public or private specifically). + */ + suspend fun removeBookmark( + existingList: BookmarkListEvent, + eventId: HexKey, + isPrivate: Boolean, + signer: NostrSigner, + ): BookmarkListEvent { + val bookmark = EventBookmark(eventId) + return BookmarkListEvent.remove( + earlierVersion = existingList, + bookmarkIdTag = bookmark, + isPrivate = isPrivate, + signer = signer, + ) + } + + /** + * Checks if an event ID is in the public bookmarks. + */ + fun isInPublicBookmarks( + bookmarkList: BookmarkListEvent?, + eventId: HexKey, + ): Boolean { + if (bookmarkList == null) return false + return bookmarkList.publicBookmarks().any { + it is EventBookmark && it.eventId == eventId + } + } + + /** + * Checks if an event ID is in the private bookmarks. + * Requires decryption via signer. + */ + suspend fun isInPrivateBookmarks( + bookmarkList: BookmarkListEvent?, + eventId: HexKey, + signer: NostrSigner, + ): Boolean { + if (bookmarkList == null) return false + val privateBookmarks = bookmarkList.privateBookmarks(signer) ?: return false + return privateBookmarks.any { + it is EventBookmark && it.eventId == eventId + } + } + + /** + * Checks if an event ID is bookmarked (public or private). + */ + suspend fun isBookmarked( + bookmarkList: BookmarkListEvent?, + eventId: HexKey, + signer: NostrSigner, + ): Boolean = + isInPublicBookmarks(bookmarkList, eventId) || + isInPrivateBookmarks(bookmarkList, eventId, signer) + + /** + * Gets the bookmark list address for a user. + */ + fun getBookmarkListAddress(pubKey: HexKey) = BookmarkListEvent.createBookmarkAddress(pubKey) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt new file mode 100644 index 000000000..3fb510267 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip57Zaps + +import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +/** + * Handles NIP-57 zap requests and invoice fetching. + * Shared between Android and Desktop. + */ +object ZapAction { + /** + * Result of a zap operation. + */ + sealed class ZapResult { + data class Invoice( + val bolt11: String, + ) : ZapResult() + + data class Error( + val message: String, + ) : ZapResult() + } + + /** + * Creates a zap request and fetches a BOLT11 invoice. + * + * @param targetEvent Event to zap + * @param lnAddress Lightning address of recipient + * @param amountSats Amount in satoshis + * @param message Optional zap message + * @param relays Relay hints (normalized URLs) + * @param signer Signer for the request + * @param resolver Lightning address resolver + * @param zapType Type of zap (default PUBLIC) + * @param onProgress Progress callback + */ + suspend fun fetchZapInvoice( + targetEvent: Event, + lnAddress: String, + amountSats: Long, + message: String = "", + relays: Set, + signer: NostrSigner, + resolver: LightningAddressResolver, + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + onProgress: (Float) -> Unit = {}, + ): ZapResult { + if (!signer.isWriteable()) { + return ZapResult.Error("Signer is not writeable") + } + + // Create zap request using quartz factory + val zapRequest = + try { + LnZapRequestEvent.create( + zappedEvent = targetEvent, + relays = relays, + signer = signer, + pollOption = null, + message = message, + zapType = zapType, + toUserPubHex = null, + ) + } catch (e: Exception) { + return ZapResult.Error("Failed to create zap request: ${e.message}") + } + + onProgress(0.3f) + + // Fetch invoice + val result = + resolver.fetchInvoice( + lnAddress = lnAddress, + milliSats = amountSats * 1000, + message = message, + zapRequest = zapRequest, + onProgress = { progress -> + onProgress(0.3f + progress * 0.7f) + }, + ) + + return when (result) { + is LightningAddressResolver.Result.Success -> ZapResult.Invoice(result.invoice) + is LightningAddressResolver.Result.Error -> ZapResult.Error(result.message) + } + } + + /** + * Creates a zap request for a user profile (no event). + */ + suspend fun fetchZapInvoiceForUser( + userPubHex: String, + lnAddress: String, + amountSats: Long, + message: String = "", + relays: Set, + signer: NostrSigner, + resolver: LightningAddressResolver, + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + onProgress: (Float) -> Unit = {}, + ): ZapResult { + if (!signer.isWriteable()) { + return ZapResult.Error("Signer is not writeable") + } + + // Create zap request for user + val zapRequest = + try { + LnZapRequestEvent.create( + userHex = userPubHex, + relays = relays, + signer = signer, + message = message, + zapType = zapType, + ) + } catch (e: Exception) { + return ZapResult.Error("Failed to create zap request: ${e.message}") + } + + onProgress(0.3f) + + // Fetch invoice + val result = + resolver.fetchInvoice( + lnAddress = lnAddress, + milliSats = amountSats * 1000, + message = message, + zapRequest = zapRequest, + onProgress = { progress -> + onProgress(0.3f + progress * 0.7f) + }, + ) + + return when (result) { + is LightningAddressResolver.Result.Success -> ZapResult.Invoice(result.invoice) + is LightningAddressResolver.Result.Error -> ZapResult.Error(result.message) + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt new file mode 100644 index 000000000..98a60a57d --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt @@ -0,0 +1,224 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.services.lnurl + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.math.BigDecimal +import java.math.RoundingMode +import java.net.URLEncoder +import kotlin.coroutines.cancellation.CancellationException + +/** + * Platform-agnostic Lightning Address resolver for LNURL-pay flow. + * Shared between Android and Desktop for zap functionality. + * + * Flow: + * 1. Lightning address (user@domain) → LNURL endpoint URL + * 2. Fetch LNURL-pay JSON → extract callback URL + * 3. Call callback with amount → get BOLT11 invoice + */ +class LightningAddressResolver( + private val httpClient: OkHttpClient, +) { + private val mapper = jacksonObjectMapper() + + /** + * Result of resolving a lightning address to a BOLT11 invoice. + */ + sealed class Result { + data class Success( + val invoice: String, + ) : Result() + + data class Error( + val message: String, + ) : Result() + } + + /** + * Converts a lightning address to its LNURL-pay endpoint URL. + * Supports: user@domain, LNURL bech32 + */ + fun assembleUrl(lnAddress: String): String? { + val parts = lnAddress.split("@") + + if (parts.size == 2) { + return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" + } + + if (lnAddress.lowercase().startsWith("lnurl")) { + return Lud06().toLnUrlp(lnAddress) + } + + return null + } + + /** + * Resolves a lightning address to a BOLT11 invoice. + * + * @param lnAddress Lightning address (user@domain) or LNURL + * @param milliSats Amount in millisatoshis + * @param message Optional comment for the payment + * @param zapRequest Optional NIP-57 zap request event + * @param onProgress Progress callback (0.0 to 1.0) + */ + suspend fun fetchInvoice( + lnAddress: String, + milliSats: Long, + message: String = "", + zapRequest: LnZapRequestEvent? = null, + onProgress: (Float) -> Unit = {}, + ): Result = + withContext(Dispatchers.IO) { + try { + // Step 1: Resolve LN address to LNURL endpoint + val url = + assembleUrl(lnAddress) + ?: return@withContext Result.Error("Invalid lightning address: $lnAddress") + + onProgress(0.2f) + + // Step 2: Fetch LNURL-pay JSON + val lnurlJson = + fetchUrl(url) + ?: return@withContext Result.Error("Failed to fetch LNURL endpoint: $url") + + onProgress(0.4f) + + val lnurlp = + try { + mapper.readTree(lnurlJson) + } catch (e: Exception) { + return@withContext Result.Error("Failed to parse LNURL response") + } + + val callbackUrl = + lnurlp?.get("callback")?.asText()?.ifBlank { null } + ?: return@withContext Result.Error("No callback URL in LNURL response") + + val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false + + onProgress(0.5f) + + // Step 3: Fetch invoice from callback + val invoiceJson = + fetchInvoiceFromCallback( + callbackUrl = callbackUrl, + milliSats = milliSats, + message = message, + zapRequest = if (allowsNostr) zapRequest else null, + ) ?: return@withContext Result.Error("Failed to fetch invoice from callback") + + onProgress(0.7f) + + val invoiceResponse = + try { + mapper.readTree(invoiceJson) + } catch (e: Exception) { + return@withContext Result.Error("Failed to parse invoice response") + } + + val pr = invoiceResponse?.get("pr")?.asText()?.ifBlank { null } + + if (pr == null) { + val reason = invoiceResponse?.get("reason")?.asText()?.ifBlank { null } + return@withContext Result.Error(reason ?: "No invoice in response") + } + + // Step 4: Validate invoice amount + val expectedAmountInSats = + BigDecimal(milliSats) + .divide(BigDecimal(1000), RoundingMode.HALF_UP) + .toLong() + + val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr) + + if (invoiceAmount.toLong() != expectedAmountInSats) { + return@withContext Result.Error( + "Invoice amount mismatch: got ${invoiceAmount.toLong()} sats, expected $expectedAmountInSats sats", + ) + } + + onProgress(1.0f) + + Result.Success(pr) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.Error(e.message ?: "Unknown error") + } + } + + private suspend fun fetchUrl(url: String): String? = + withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body?.string() + } else { + null + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + + private suspend fun fetchInvoiceFromCallback( + callbackUrl: String, + milliSats: Long, + message: String, + zapRequest: LnZapRequestEvent?, + ): String? = + withContext(Dispatchers.IO) { + try { + val encodedMessage = URLEncoder.encode(message, "utf-8") + val urlBinder = if (callbackUrl.contains("?")) "&" else "?" + var url = "$callbackUrl${urlBinder}amount=$milliSats&comment=$encodedMessage" + + if (zapRequest != null) { + val encodedRequest = URLEncoder.encode(zapRequest.toJson(), "utf-8") + url += "&nostr=$encodedRequest" + } + + val request = Request.Builder().url(url).build() + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body?.string() + } else { + null + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt new file mode 100644 index 000000000..68100f6a7 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/nwc/NwcPaymentTracker.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.services.nwc + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks pending NIP-47 (Nostr Wallet Connect) payment requests awaiting responses. + * + * Shared between Android and Desktop to provide consistent payment tracking behavior. + * Platform-specific caches (LocalCache, DesktopLocalCache) delegate to this tracker + * for the core request/response matching logic. + * + * Flow: + * 1. When sending payment request: [registerRequest] stores callback + * 2. When response arrives: [onResponseReceived] retrieves and removes pending request + * 3. Caller invokes callback and links notes via Note.addZapPayment() + */ +class NwcPaymentTracker { + /** + * Data for a pending payment request. + * + * @property zappedNote The note being zapped, if payment is for a zap + * @property onResponse Callback to invoke when wallet responds + */ + data class PendingRequest( + val zappedNote: Note?, + val onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + ) + + private val awaitingRequests = ConcurrentHashMap(10) + + /** + * Registers a pending payment request. + * + * @param requestId Event ID of the LnZapPaymentRequestEvent + * @param zappedNote The note being zapped (null if not a zap payment) + * @param onResponse Callback invoked when response arrives + */ + fun registerRequest( + requestId: HexKey, + zappedNote: Note?, + onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + ) { + awaitingRequests[requestId] = PendingRequest(zappedNote, onResponse) + } + + /** + * Called when a payment response event is received. + * Retrieves and removes the pending request for the given request ID. + * + * @param requestId The 'e' tag from the response, pointing to original request + * @return PendingRequest if found, null otherwise + */ + fun onResponseReceived(requestId: HexKey?): PendingRequest? { + if (requestId == null) return null + return awaitingRequests.remove(requestId) + } + + /** + * Checks if there's a pending request for the given ID. + */ + fun hasPendingRequest(requestId: HexKey): Boolean = awaitingRequests.containsKey(requestId) + + /** + * Manually removes a pending request (e.g., on timeout). + */ + fun cleanup(requestId: HexKey) { + awaitingRequests.remove(requestId) + } + + /** + * Returns count of pending requests (for debugging/monitoring). + */ + fun pendingCount(): Int = awaitingRequests.size +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt new file mode 100644 index 000000000..d0004dec5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop + +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import java.util.prefs.Preferences + +/** + * Simple preferences storage using Java's Preferences API. + * Data is stored in platform-appropriate location: + * - macOS: ~/Library/Preferences/com.apple.java.util.prefs.plist + * - Linux: ~/.java/.userPrefs/ + * - Windows: Registry under HKEY_CURRENT_USER\Software\JavaSoft\Prefs + */ +object DesktopPreferences { + private val prefs: Preferences = Preferences.userNodeForPackage(DesktopPreferences::class.java) + + private const val KEY_FEED_MODE = "feed_mode" + private const val KEY_LAST_SCREEN = "last_screen" + + var feedMode: FeedMode + get() { + val name = prefs.get(KEY_FEED_MODE, FeedMode.GLOBAL.name) + return try { + FeedMode.valueOf(name) + } catch (e: Exception) { + FeedMode.GLOBAL + } + } + set(value) { + prefs.put(KEY_FEED_MODE, value.name) + } + + var lastScreen: String + get() = prefs.get(KEY_LAST_SCREEN, "Feed") + set(value) { + prefs.put(KEY_LAST_SCREEN, value) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 7493784ee..1f42eeabd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications @@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -50,12 +52,15 @@ import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -74,18 +79,24 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder -import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen +import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen +import com.vitorpamplona.amethyst.desktop.ui.SearchScreen import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -99,8 +110,12 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") sealed class DesktopScreen { object Feed : DesktopScreen() + object Reads : DesktopScreen() + object Search : DesktopScreen() + object Bookmarks : DesktopScreen() + object Messages : DesktopScreen() object Notifications : DesktopScreen() @@ -127,6 +142,8 @@ fun main() = position = WindowPosition.Aligned(Alignment.Center), ) var showComposeDialog by remember { mutableStateOf(false) } + var replyToNote by remember { mutableStateOf(null) } + var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } Window( onCloseRequest = ::exitApplication, @@ -154,7 +171,7 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { /* TODO: Open settings */ }, + onClick = { currentScreen = DesktopScreen.Settings }, ) Separator() Item( @@ -202,25 +219,50 @@ fun main() = } App( + currentScreen = currentScreen, + onScreenChange = { currentScreen = it }, showComposeDialog = showComposeDialog, onShowComposeDialog = { showComposeDialog = true }, - onDismissComposeDialog = { showComposeDialog = false }, + onShowReplyDialog = { event -> + replyToNote = event + showComposeDialog = true + }, + onDismissComposeDialog = { + showComposeDialog = false + replyToNote = null + }, + replyToNote = replyToNote, ) } } @Composable fun App( + currentScreen: DesktopScreen, + onScreenChange: (DesktopScreen) -> Unit, showComposeDialog: Boolean, onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, + replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { - var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } val relayManager = remember { DesktopRelayConnectionManager() } + val localCache = remember { DesktopLocalCache() } val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Subscriptions coordinator for metadata/reactions loading + val subscriptionsCoordinator = + remember(relayManager, localCache) { + DesktopRelaySubscriptionsCoordinator( + client = relayManager.client, + scope = scope, + indexRelays = relayManager.availableRelays.value, + localCache = localCache, + ) + } + // Try to load saved account on startup DisposableEffect(Unit) { scope.launch(Dispatchers.IO) { @@ -230,7 +272,12 @@ fun App( relayManager.addDefaultRelays() relayManager.connect() + + // Start subscriptions coordinator + subscriptionsCoordinator.start() + onDispose { + subscriptionsCoordinator.clear() relayManager.disconnect() } } @@ -246,19 +293,29 @@ fun App( is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { currentScreen = DesktopScreen.Feed }, + onLoginSuccess = { onScreenChange(DesktopScreen.Feed) }, ) } is AccountState.LoggedIn -> { val account = accountState as AccountState.LoggedIn + val nwcConnection by accountManager.nwcConnection.collectAsState() + + // Load NWC connection on first composition + LaunchedEffect(Unit) { + accountManager.loadNwcConnection() + } MainContent( currentScreen = currentScreen, - onScreenChange = { currentScreen = it }, + onScreenChange = onScreenChange, relayManager = relayManager, + localCache = localCache, accountManager = accountManager, account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, ) // Compose dialog @@ -267,6 +324,7 @@ fun App( onDismiss = onDismissComposeDialog, relayManager = relayManager, account = account, + replyTo = replyToNote, ) } } @@ -280,127 +338,225 @@ fun MainContent( currentScreen: DesktopScreen, onScreenChange: (DesktopScreen) -> Unit, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + nwcConnection: Nip47WalletConnect.Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, ) { - Row(Modifier.fillMaxSize()) { - // Sidebar Navigation - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - Spacer(Modifier.height(16.dp)) + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() - NavigationRailItem( - icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, - label = { Text("Feed") }, - selected = currentScreen == DesktopScreen.Feed, - onClick = { onScreenChange(DesktopScreen.Feed) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, - label = { Text("Search") }, - selected = currentScreen == DesktopScreen.Search, - onClick = { onScreenChange(DesktopScreen.Search) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, - label = { Text("DMs") }, - selected = currentScreen == DesktopScreen.Messages, - onClick = { onScreenChange(DesktopScreen.Messages) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, - label = { Text("Alerts") }, - selected = currentScreen == DesktopScreen.Notifications, - onClick = { onScreenChange(DesktopScreen.Notifications) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, - onClick = { onScreenChange(DesktopScreen.MyProfile) }, - ) - - Spacer(Modifier.weight(1f)) - - HorizontalDivider(Modifier.padding(horizontal = 16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, - selected = currentScreen == DesktopScreen.Settings, - onClick = { onScreenChange(DesktopScreen.Settings) }, - ) - - Spacer(Modifier.height(16.dp)) + val onZapFeedback: (ZapFeedback) -> Unit = { feedback -> + scope.launch { + val message = + when (feedback) { + is ZapFeedback.Success -> "Zapped ${feedback.amountSats} sats" + is ZapFeedback.ExternalWallet -> "Invoice sent to wallet (${feedback.amountSats} sats)" + is ZapFeedback.Error -> "Zap failed: ${feedback.message}" + is ZapFeedback.Timeout -> "Zap timed out" + is ZapFeedback.NoLightningAddress -> "User has no lightning address" + } + snackbarHostState.showSnackbar(message) } + } - VerticalDivider() + Box(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxSize()) { + // Sidebar Navigation + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + Spacer(Modifier.height(16.dp)) - // Main Content - Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), - ) { - when (currentScreen) { - DesktopScreen.Feed -> - FeedScreen( - relayManager = relayManager, - account = account, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - DesktopScreen.Search -> SearchPlaceholder() - DesktopScreen.Messages -> MessagesPlaceholder() - DesktopScreen.Notifications -> NotificationsScreen(relayManager, account) - DesktopScreen.MyProfile -> - UserProfileScreen( - pubKeyHex = account.pubKeyHex, - relayManager = relayManager, - account = account, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - ) - is DesktopScreen.UserProfile -> - UserProfileScreen( - pubKeyHex = currentScreen.pubKeyHex, - relayManager = relayManager, - account = account, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - ) - is DesktopScreen.Thread -> - ThreadScreen( - noteId = currentScreen.noteId, - relayManager = relayManager, - account = account, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account) + NavigationRailItem( + icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == DesktopScreen.Feed, + onClick = { onScreenChange(DesktopScreen.Feed) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") }, + label = { Text("Reads") }, + selected = currentScreen == DesktopScreen.Reads, + onClick = { onScreenChange(DesktopScreen.Reads) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + label = { Text("Search") }, + selected = currentScreen == DesktopScreen.Search, + onClick = { onScreenChange(DesktopScreen.Search) }, + ) + + NavigationRailItem( + icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") }, + label = { Text("Bookmarks") }, + selected = currentScreen == DesktopScreen.Bookmarks, + onClick = { onScreenChange(DesktopScreen.Bookmarks) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == DesktopScreen.Messages, + onClick = { onScreenChange(DesktopScreen.Messages) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == DesktopScreen.Notifications, + onClick = { onScreenChange(DesktopScreen.Notifications) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, + onClick = { onScreenChange(DesktopScreen.MyProfile) }, + ) + + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == DesktopScreen.Settings, + onClick = { onScreenChange(DesktopScreen.Settings) }, + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // Main Content + Box( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), + ) { + when (currentScreen) { + DesktopScreen.Feed -> + FeedScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + onZapFeedback = onZapFeedback, + ) + DesktopScreen.Reads -> + ReadsScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToArticle = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + ) + DesktopScreen.Search -> + SearchScreen( + localCache = localCache, + relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + ) + DesktopScreen.Bookmarks -> + BookmarksScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + onZapFeedback = onZapFeedback, + ) + DesktopScreen.Messages -> MessagesPlaceholder() + DesktopScreen.Notifications -> NotificationsScreen(relayManager, account, subscriptionsCoordinator) + DesktopScreen.MyProfile -> + UserProfileScreen( + pubKeyHex = account.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onZapFeedback = onZapFeedback, + ) + is DesktopScreen.UserProfile -> + UserProfileScreen( + pubKeyHex = currentScreen.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onZapFeedback = onZapFeedback, + ) + is DesktopScreen.Thread -> + ThreadScreen( + noteId = currentScreen.noteId, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + onNavigateToThread = { noteId -> + onScreenChange(DesktopScreen.Thread(noteId)) + }, + onZapFeedback = onZapFeedback, + onReply = onShowReplyDialog, + ) + DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account, accountManager) + } } } + + // Snackbar for zap feedback + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + ) } } @@ -443,10 +599,19 @@ fun ProfileScreen( fun RelaySettingsScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, + accountManager: AccountManager, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() + val nwcConnection by accountManager.nwcConnection.collectAsState() var newRelayUrl by remember { mutableStateOf("") } + var nwcInput by remember { mutableStateOf("") } + var nwcError by remember { mutableStateOf(null) } + + // Load NWC on first composition + LaunchedEffect(Unit) { + accountManager.loadNwcConnection() + } Column(modifier = Modifier.fillMaxSize()) { Text( @@ -457,6 +622,88 @@ fun RelaySettingsScreen( Spacer(Modifier.height(24.dp)) + // Wallet Connect Section + Text( + "Wallet Connect (NWC)", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Text( + "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + if (nwcConnection != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + "Wallet Connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "Relay: ${nwcConnection!!.relayUri.url}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + onClick = { accountManager.clearNwcConnection() }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Disconnect") + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = nwcInput, + onValueChange = { + nwcInput = it + nwcError = null + }, + label = { Text("NWC Connection String") }, + placeholder = { Text("nostr+walletconnect://...") }, + modifier = Modifier.weight(1f), + singleLine = true, + isError = nwcError != null, + supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + ) + Button( + onClick = { + val result = accountManager.setNwcConnection(nwcInput) + result.fold( + onSuccess = { nwcInput = "" }, + onFailure = { nwcError = it.message ?: "Invalid connection string" }, + ) + }, + enabled = nwcInput.isNotBlank(), + ) { + Text("Connect") + } + } + } + + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + // Developer Settings Section (only in debug mode) if (DebugConfig.isDebugMode) { com.vitorpamplona.amethyst.desktop.ui diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 60c902d78..49098ee98 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -69,6 +70,9 @@ class AccountManager private constructor( private val _accountState = MutableStateFlow(AccountState.LoggedOut) val accountState: StateFlow = _accountState.asStateFlow() + private val _nwcConnection = MutableStateFlow(null) + val nwcConnection: StateFlow = _nwcConnection.asStateFlow() + /** * Loads the last saved account from secure storage. * Call on app startup. @@ -206,6 +210,47 @@ class AccountManager private constructor( fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn + // NWC (Nostr Wallet Connect) methods + fun hasNwcSetup(): Boolean = _nwcConnection.value != null + + fun setNwcConnection(uri: String): Result = + try { + val parsed = Nip47WalletConnect.parse(uri) + _nwcConnection.value = parsed + saveNwcUri(uri) + Result.success(parsed) + } catch (e: Exception) { + Result.failure(e) + } + + fun clearNwcConnection() { + _nwcConnection.value = null + getNwcFile().delete() + } + + fun loadNwcConnection() { + val uri = getNwcFile().takeIf { it.exists() }?.readText()?.trim() + if (!uri.isNullOrEmpty()) { + try { + _nwcConnection.value = Nip47WalletConnect.parse(uri) + } catch (e: Exception) { + // Invalid stored URI, clear it + getNwcFile().delete() + } + } + } + + private fun saveNwcUri(uri: String) { + val file = getNwcFile() + file.parentFile?.mkdirs() + file.writeText(uri) + } + + private fun getNwcFile(): java.io.File { + val homeDir = System.getProperty("user.home") + return java.io.File(homeDir, ".amethyst/nwc_connection.txt") + } + // Simple file-based storage for last npub (non-sensitive data) private fun getLastNpub(): String? { val file = getPrefsFile() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt new file mode 100644 index 000000000..844851ceb --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -0,0 +1,279 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Desktop implementation of ICacheProvider. + * + * Provides in-memory caching of Users and Notes for the desktop application. + * Supports searching users by name prefix for the search functionality. + */ +class DesktopLocalCache : ICacheProvider { + private val users = ConcurrentHashMap() + private val notes = ConcurrentHashMap() + private val addressableNotes = ConcurrentHashMap() + private val deletedEvents = ConcurrentHashMap.newKeySet() + + private val eventStream = DesktopCacheEventStream() + + val paymentTracker = NwcPaymentTracker() + + // ----- User operations ----- + + override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] + + override fun getOrCreateUser(pubkey: HexKey): User = + users.getOrPut(pubkey) { + // Create placeholder notes for relay lists + val nip65Note = getOrCreateNote("nip65:$pubkey") + val dmNote = getOrCreateNote("dm:$pubkey") + User(pubkey, nip65Note, dmNote) + } + + override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) } + + override fun findUsersStartingWith( + prefix: String, + limit: Int, + ): List { + if (prefix.isBlank()) return emptyList() + + // Check if it's a valid pubkey/npub first + val pubkeyHex = decodePublicKeyAsHexOrNull(prefix) + if (pubkeyHex != null) { + val user = getUserIfExists(pubkeyHex) + if (user != null) return listOf(user) + } + + // Search by name/displayName/nip05/lud16 + return users.values + .filter { user -> + user.anyNameStartsWith(prefix) || + user.pubkeyHex.startsWith(prefix, ignoreCase = true) || + user.pubkeyNpub().startsWith(prefix, ignoreCase = true) + }.sortedWith( + compareBy( + { !it.toBestDisplayName().startsWith(prefix, ignoreCase = true) }, + { it.toBestDisplayName().lowercase() }, + { it.pubkeyHex }, + ), + ).take(limit) + } + + /** + * Updates user metadata from a MetadataEvent. + * Called when receiving kind 0 events from relays. + */ + fun consumeMetadata(event: MetadataEvent) { + val user = getOrCreateUser(event.pubKey) + + // Only update if newer + val currentMetadata = user.latestMetadata + if (currentMetadata == null || event.createdAt > currentMetadata.createdAt) { + user.latestMetadata = event + user.info = event.contactMetaData() + } + } + + // ----- NWC Payment operations ----- + + /** + * Consumes a NIP-47 payment request event. + * Registers the request with the tracker and links it to the zapped note. + * + * @param event The payment request event + * @param zappedNote The note being zapped (if this payment is for a zap) + * @param relay The relay this event came from + * @param onResponse Callback invoked when wallet responds + * @return true if event was processed, false if already seen + */ + fun consume( + event: LnZapPaymentRequestEvent, + zappedNote: Note?, + relay: NormalizedRelayUrl?, + onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + ): Boolean { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event + if (note.event != null) return false + + note.loadEvent(event, author, emptyList()) + relay?.let { note.addRelay(it) } + + zappedNote?.addZapPayment(note, null) + paymentTracker.registerRequest(event.id, zappedNote, onResponse) + + return true + } + + /** + * Consumes a NIP-47 payment response event. + * Matches to pending request, links notes, and invokes callback. + * + * @param event The payment response event + * @param relay The relay this event came from + * @return true if event was processed, false if no matching request + */ + fun consume( + event: LnZapPaymentResponseEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val requestId = event.requestId() + val pending = paymentTracker.onResponseReceived(requestId) ?: return false + + val requestNote = requestId?.let { getNoteIfExists(it) } + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event + if (note.event != null) return false + + note.loadEvent(event, author, emptyList()) + relay?.let { note.addRelay(it) } + + // Link response to zapped note via request + requestNote?.let { req -> pending.zappedNote?.addZapPayment(req, note) } + + // Invoke callback on IO dispatcher + GlobalScope.launch(Dispatchers.IO) { + pending.onResponse(event) + } + + return true + } + + // ----- Note operations ----- + + override fun getNoteIfExists(hexKey: HexKey): Note? = notes[hexKey] + + override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey) + + fun getOrCreateNote(hexKey: HexKey): Note = + notes.getOrPut(hexKey) { + Note(hexKey) + } + + override fun getOrCreateAddressableNote(key: Address): AddressableNote = + addressableNotes.getOrPut(key.toValue()) { + AddressableNote(key) + } + + // ----- Channel operations ----- + + override fun getAnyChannel(note: Note): Channel? { + // Desktop doesn't support channels yet + return null + } + + // ----- Deletion tracking ----- + + override fun hasBeenDeleted(event: Any): Boolean = + when (event) { + is Note -> deletedEvents.contains(event.idHex) + is Event -> deletedEvents.contains(event.id) + else -> false + } + + fun markAsDeleted(eventId: HexKey) { + deletedEvents.add(eventId) + } + + // ----- Own event consumption ----- + + override fun justConsumeMyOwnEvent(event: Event): Boolean { + // Desktop doesn't track own events separately + return false + } + + // ----- Event stream ----- + + override fun getEventStream(): ICacheEventStream = eventStream + + /** + * Emits a new note bundle to observers. + */ + suspend fun emitNewNotes(notes: Set) { + eventStream.emitNewNotes(notes) + } + + /** + * Emits deleted notes to observers. + */ + suspend fun emitDeletedNotes(notes: Set) { + eventStream.emitDeletedNotes(notes) + } + + // ----- Stats ----- + + fun userCount(): Int = users.size + + fun noteCount(): Int = notes.size + + fun clear() { + users.clear() + notes.clear() + addressableNotes.clear() + deletedEvents.clear() + } +} + +/** + * Desktop implementation of ICacheEventStream. + */ +class DesktopCacheEventStream : ICacheEventStream { + private val _newEventBundles = MutableSharedFlow>(replay = 0) + private val _deletedEventBundles = MutableSharedFlow>(replay = 0) + + override val newEventBundles: SharedFlow> = _newEventBundles + override val deletedEventBundles: SharedFlow> = _deletedEventBundles + + suspend fun emitNewNotes(notes: Set) { + _newEventBundles.emit(notes) + } + + suspend fun emitDeletedNotes(notes: Set) { + _deletedEventBundles.emit(notes) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt index 8ef7207a2..221cbf35d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt @@ -44,24 +44,27 @@ import kotlinx.coroutines.flow.asStateFlow open class RelayConnectionManager( websocketBuilder: WebsocketBuilder, ) : IRelayClientListener { - private val client = NostrClient(websocketBuilder) + private val _client = NostrClient(websocketBuilder) + + /** Exposes the underlying INostrClient for subscription coordinators */ + val client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient get() = _client private val _relayStatuses = MutableStateFlow>(emptyMap()) val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() - val connectedRelays: StateFlow> = client.connectedRelaysFlow() - val availableRelays: StateFlow> = client.availableRelaysFlow() + val connectedRelays: StateFlow> = _client.connectedRelaysFlow() + val availableRelays: StateFlow> = _client.availableRelaysFlow() init { - client.subscribe(this) + _client.subscribe(this) } fun connect() { - client.connect() + _client.connect() } fun disconnect() { - client.disconnect() + _client.disconnect() } fun addRelay(url: String): NormalizedRelayUrl? { @@ -85,18 +88,18 @@ open class RelayConnectionManager( listener: IRequestListener? = null, ) { val filterMap = relays.associateWith { filters } - client.openReqSubscription(subId, filterMap, listener) + _client.openReqSubscription(subId, filterMap, listener) } fun unsubscribe(subId: String) { - client.close(subId) + _client.close(subId) } fun send( event: Event, relays: Set = connectedRelays.value, ) { - client.send(event, relays) + _client.send(event, relays) } /** @@ -107,6 +110,61 @@ open class RelayConnectionManager( send(event, connected) } + /** + * Sends an event to a specific relay (for NWC). + * Adds the relay if not already in the list. + */ + fun sendToRelay( + relay: NormalizedRelayUrl, + event: Event, + ) { + if (relay !in availableRelays.value) { + updateRelayStatus(relay) { it.copy(connected = false, error = null) } + } + _client.send(event, setOf(relay)) + } + + /** + * Subscribes on a specific relay (for NWC). + * Adds the relay if not already in the list. + */ + fun subscribeOnRelay( + relay: NormalizedRelayUrl, + subId: String, + filters: List, + onEvent: (Event, NormalizedRelayUrl) -> Unit, + ) { + if (relay !in availableRelays.value) { + updateRelayStatus(relay) { it.copy(connected = false, error = null) } + } + val filterMap = mapOf(relay to filters) + _client.openReqSubscription( + subId = subId, + filters = filterMap, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onEvent(event, relay) + } + }, + ) + } + + /** + * Closes a subscription on a specific relay. + */ + fun closeSubscription( + relay: NormalizedRelayUrl, + subId: String, + ) { + _client.close(subId) + } + private fun updateRelayStatus( url: NormalizedRelayUrl, update: (RelayStatus) -> RelayStatus, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt new file mode 100644 index 000000000..2f9388e38 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nwc + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume + +/** + * Handles NIP-47 (Nostr Wallet Connect) payments for desktop. + * + * Flow: + * 1. Create payment request event with BOLT11 invoice + * 2. Register with tracker for persistent tracking in Note.zapPayments + * 3. Send to wallet's relay + * 4. Subscribe and wait for wallet response + * + * @param relayManager Manages relay connections for sending/subscribing + * @param localCache Cache for persistent payment tracking + */ +class NwcPaymentHandler( + private val relayManager: DesktopRelayConnectionManager, + private val localCache: DesktopLocalCache, +) { + sealed class PaymentResult { + data class Success( + val preimage: String?, + ) : PaymentResult() + + data class Error( + val message: String, + ) : PaymentResult() + + data object Timeout : PaymentResult() + } + + /** + * Sends a payment request via NWC and waits for response. + * Payment is tracked in Note.zapPayments for the zapped note. + * + * @param bolt11 The BOLT11 invoice to pay + * @param nwcConnection The NWC connection details (pubkey, relay, secret) + * @param zappedNote The note being zapped (for tracking in Note.zapPayments) + * @param timeoutMs How long to wait for payment response (default 60s) + * @return PaymentResult indicating success, error, or timeout + */ + suspend fun payInvoice( + bolt11: String, + nwcConnection: Nip47WalletConnect.Nip47URINorm, + zappedNote: Note? = null, + timeoutMs: Long = 60_000, + ): PaymentResult { + val secret = nwcConnection.secret ?: return PaymentResult.Error("NWC connection has no secret") + + // Create signer from NWC secret + val nwcSigner = NostrSignerInternal(KeyPair(secret.hexToByteArray())) + + // Create payment request event + val requestEvent = + LnZapPaymentRequestEvent.create( + lnInvoice = bolt11, + walletServicePubkey = nwcConnection.pubKeyHex, + signer = nwcSigner, + ) + + // Register request note in cache for tracking + val requestNote = localCache.getOrCreateNote(requestEvent.id) + requestNote.loadEvent(requestEvent, localCache.getOrCreateUser(requestEvent.pubKey), emptyList()) + requestNote.addRelay(nwcConnection.relayUri) + + // Link to zapped note for persistent tracking + zappedNote?.addZapPayment(requestNote, null) + + // Send request to wallet's relay + relayManager.sendToRelay(nwcConnection.relayUri, requestEvent) + + // Subscribe and wait for response with timeout + return withTimeoutOrNull(timeoutMs) { + waitForResponse(requestEvent.id, nwcConnection, nwcSigner, zappedNote, requestNote) + } ?: PaymentResult.Timeout + } + + private suspend fun waitForResponse( + requestId: String, + nwcConnection: Nip47WalletConnect.Nip47URINorm, + nwcSigner: NostrSignerInternal, + zappedNote: Note?, + requestNote: Note, + ): PaymentResult = + suspendCancellableCoroutine { continuation -> + val filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(nwcConnection.pubKeyHex), + tags = mapOf("e" to listOf(requestId)), + ) + + val subId = "nwc-response-${requestId.take(8)}" + + relayManager.subscribeOnRelay( + relay = nwcConnection.relayUri, + subId = subId, + filters = listOf(filter), + onEvent = { event, relay -> + if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) { + // Unsubscribe + relayManager.closeSubscription(nwcConnection.relayUri, subId) + + // Store response note and link to zapped note + val responseNote = localCache.getOrCreateNote(event.id) + responseNote.loadEvent(event, localCache.getOrCreateUser(event.pubKey), emptyList()) + responseNote.addRelay(relay) + zappedNote?.addZapPayment(requestNote, responseNote) + + // Decrypt and process response + try { + kotlinx.coroutines.runBlocking { + val response = event.decrypt(nwcSigner) + val result = processResponse(response) + if (continuation.isActive) { + continuation.resume(result) + } + } + } catch (e: Exception) { + if (continuation.isActive) { + continuation.resume(PaymentResult.Error("Failed to decrypt response: ${e.message}")) + } + } + } + }, + ) + + continuation.invokeOnCancellation { + relayManager.closeSubscription(nwcConnection.relayUri, subId) + } + } + + private fun processResponse(response: Response): PaymentResult = + when (response) { + is PayInvoiceSuccessResponse -> { + PaymentResult.Success(response.result?.preimage) + } + is PayInvoiceErrorResponse -> { + PaymentResult.Error(response.error?.message ?: "Unknown error") + } + else -> { + PaymentResult.Error("Unexpected response type: ${response.resultType}") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt new file mode 100644 index 000000000..5076561bd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.subscriptions + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator +import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader +import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope + +/** + * Desktop-specific relay subscriptions coordinator. + * Manages metadata and reactions loading with rate limiting and prioritization. + * + * This coordinator ensures: + * - Display names and avatars load before reactions + * - Metadata requests are rate-limited (20/sec) to avoid relay flooding + * - Subscriptions are batched efficiently + * + * Usage: + * ``` + * val coordinator = DesktopRelaySubscriptionsCoordinator( + * client = relayManager.client, + * scope = viewModelScope, + * indexRelays = relayManager.availableRelays.value, + * ) + * coordinator.start() + * + * // In screens: + * LaunchedEffect(notes) { + * coordinator.loadMetadataForNotes(notes) + * } + * ``` + */ +class DesktopRelaySubscriptionsCoordinator( + private val client: INostrClient, + private val scope: CoroutineScope, + private val indexRelays: Set, + private val localCache: DesktopLocalCache, +) { + // Rate limiter: 20 requests per second to avoid flooding relays + private val rateLimiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope) + + // Preloader handles metadata + avatar prefetching + private val preloader = MetadataPreloader(rateLimiter, imagePrefetcher = null) + + // Feed metadata coordinator with priority queue + val feedMetadata = + FeedMetadataCoordinator( + client = client, + scope = scope, + indexRelays = indexRelays, + preloader = preloader, + onEvent = { event, _ -> + // Consume metadata events into local cache + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + + /** + * Start the coordinator. + * Call once when app starts or user logs in. + */ + fun start() { + // Start rate limiter to process queued metadata requests + rateLimiter.start { pubkey -> + // When rate limiter dequeues a pubkey, subscribe to its metadata + client.openReqSubscription( + filters = + indexRelays.associateWith { + listOf( + com.vitorpamplona.quartz.nip01Core.relay.filters.Filter( + kinds = listOf(com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.KIND), + authors = listOf(pubkey), + limit = 1, + ), + ) + }, + ) + } + + // Start feed metadata coordinator + feedMetadata.start() + } + + /** + * Load metadata and reactions for notes. + * Delegates to FeedMetadataCoordinator. + */ + fun loadMetadataForNotes(notes: List) { + feedMetadata.loadMetadataForNotes(notes) + } + + /** + * Load metadata for specific pubkeys. + */ + fun loadMetadataForPubkeys(pubkeys: List) { + feedMetadata.loadMetadataForPubkeys(pubkeys) + } + + /** + * Load reactions for specific notes. + */ + fun loadReactionsForNotes(noteIds: List) { + feedMetadata.loadReactionsForNotes(noteIds) + } + + /** + * Clear all queued requests. + * Call when switching accounts or during cleanup. + */ + fun clear() { + feedMetadata.clear() + rateLimiter.reset() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index 016d2eff4..5a1c59677 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -131,3 +131,187 @@ fun createThreadRepliesSubscription( onEvent = onEvent, onEose = onEose, ) + +/** + * Creates a NIP-50 search subscription for user profiles. + * Requires NIP-50 compatible relays (e.g., relay.nostr.band, nostr.wine). + * + * @param searchQuery Text to search for in user profiles + * @param limit Maximum results to return + */ +fun createSearchPeopleSubscription( + relays: Set, + searchQuery: String, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (searchQuery.isBlank()) return null + + return SubscriptionConfig( + subId = generateSubId("search-people-${searchQuery.take(8)}"), + filters = listOf(FilterBuilders.searchPeople(searchQuery, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a NIP-50 search subscription for text notes. + * Requires NIP-50 compatible relays. + * + * @param searchQuery Text to search for in notes + * @param limit Maximum results to return + */ +fun createSearchNotesSubscription( + relays: Set, + searchQuery: String, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (searchQuery.isBlank()) return null + + return SubscriptionConfig( + subId = generateSubId("search-notes-${searchQuery.take(8)}"), + filters = listOf(FilterBuilders.searchNotes(searchQuery, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription for zap receipts (kind 9735) for specific events. + * + * @param eventIds Event IDs to get zaps for + * @param limit Maximum zaps per event + */ +fun createZapsSubscription( + relays: Set, + eventIds: List, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (eventIds.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("zaps-${eventIds.first().take(8)}"), + filters = listOf(FilterBuilders.zapsForEvents(eventIds, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription for reactions (kind 7) for specific events. + * + * @param eventIds Event IDs to get reactions for + * @param limit Maximum reactions per event + */ +fun createReactionsSubscription( + relays: Set, + eventIds: List, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (eventIds.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("reactions-${eventIds.first().take(8)}"), + filters = listOf(FilterBuilders.reactionsForEvents(eventIds, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription for replies (kind 1) to specific events. + * + * @param eventIds Event IDs to get replies for + * @param limit Maximum replies per event + */ +fun createRepliesSubscription( + relays: Set, + eventIds: List, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (eventIds.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("replies-${eventIds.first().take(8)}"), + filters = listOf(FilterBuilders.repliesForEvents(eventIds, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription for reposts (kind 6) of specific events. + * + * @param eventIds Event IDs to get reposts for + * @param limit Maximum reposts per event + */ +fun createRepostsSubscription( + relays: Set, + eventIds: List, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (eventIds.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("reposts-${eventIds.first().take(8)}"), + filters = listOf(FilterBuilders.repostsForEvents(eventIds, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for global long-form content (kind 30023, NIP-23). + */ +fun createLongFormFeedSubscription( + relays: Set, + limit: Int = 30, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("longform-feed"), + filters = listOf(FilterBuilders.longFormGlobal(limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) + +/** + * Creates a subscription config for long-form content from followed users. + */ +fun createFollowingLongFormFeedSubscription( + relays: Set, + followedUsers: List, + limit: Int = 30, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (followedUsers.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("longform-following"), + filters = listOf(FilterBuilders.longFormFromAuthors(followedUsers, limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index cd0766e10..4baeb1fe5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -83,6 +83,18 @@ object FilterBuilders { limit = 1, ) + /** + * Creates a filter for user metadata (kind 0) from multiple authors. + * + * @param pubKeyHexList List of author public keys (hex-encoded, 64 chars each) + * @return Filter for user metadata + */ + fun userMetadataBatch(pubKeyHexList: List): Filter = + Filter( + kinds = listOf(0), // MetadataEvent.KIND + authors = pubKeyHexList, + ) + /** * Creates a filter for contact list (kind 3) from a specific author. * @@ -256,6 +268,155 @@ object FilterBuilders { since = since, until = until, ) + + /** + * Creates a NIP-50 search filter for user metadata (kind 0). + * Searches user profiles by name, displayName, about, nip05, etc. + * Requires a NIP-50 compatible relay (e.g., relay.nostr.band, nostr.wine). + * + * @param searchQuery The text to search for in user profiles + * @param limit Maximum number of results to return + * @return Filter for NIP-50 search + */ + fun searchPeople( + searchQuery: String, + limit: Int = 50, + ): Filter = + Filter( + kinds = listOf(0), // MetadataEvent.KIND + search = searchQuery, + limit = limit, + ) + + /** + * Creates a NIP-50 search filter for text notes (kind 1). + * Searches note content. + * Requires a NIP-50 compatible relay. + * + * @param searchQuery The text to search for in notes + * @param limit Maximum number of results to return + * @return Filter for NIP-50 search + */ + fun searchNotes( + searchQuery: String, + limit: Int = 50, + ): Filter = + Filter( + kinds = listOf(1), // TextNoteEvent.KIND + search = searchQuery, + limit = limit, + ) + + /** + * Creates a filter for zap receipts (kind 9735) for specific events. + * + * @param eventIds List of event IDs to get zaps for + * @param limit Maximum number of events to request + * @return Filter for zap receipts + */ + fun zapsForEvents( + eventIds: List, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(9735), // LnZapEvent.KIND + tags = mapOf("e" to eventIds), + limit = limit, + ) + + /** + * Creates a filter for reactions (kind 7) for specific events. + * + * @param eventIds List of event IDs to get reactions for + * @param limit Maximum number of events to request + * @return Filter for reactions + */ + fun reactionsForEvents( + eventIds: List, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(7), // ReactionEvent.KIND + tags = mapOf("e" to eventIds), + limit = limit, + ) + + /** + * Creates a filter for replies (kind 1) to specific events. + * + * @param eventIds List of event IDs to get replies for + * @param limit Maximum number of events to request + * @return Filter for replies + */ + fun repliesForEvents( + eventIds: List, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(1), // TextNoteEvent.KIND + tags = mapOf("e" to eventIds), + limit = limit, + ) + + /** + * Creates a filter for reposts (kind 6) of specific events. + * + * @param eventIds List of event IDs to get reposts for + * @param limit Maximum number of events to request + * @return Filter for reposts + */ + fun repostsForEvents( + eventIds: List, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(6), // RepostEvent.KIND + tags = mapOf("e" to eventIds), + limit = limit, + ) + + /** + * Creates a filter for long-form content (kind 30023, NIP-23). + * + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter for long-form content + */ + fun longFormGlobal( + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + kinds = listOf(30023), // LongTextNoteEvent.KIND + limit = limit, + since = since, + until = until, + ) + + /** + * Creates a filter for long-form content (kind 30023) from specific authors. + * + * @param authors List of author public keys (hex-encoded, 64 chars each) + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter for long-form content from specified authors + */ + fun longFormFromAuthors( + authors: List, + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + kinds = listOf(30023), // LongTextNoteEvent.KIND + authors = authors, + limit = limit, + since = since, + until = until, + ) } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt index be45a7911..f8524bf03 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt @@ -26,20 +26,50 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * Creates a subscription config for user metadata (kind 0). + * Returns null if the pubKeyHex is invalid (not 64 characters). */ fun createMetadataSubscription( relays: Set, pubKeyHex: String, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, -): SubscriptionConfig = - SubscriptionConfig( +): SubscriptionConfig? { + // Validate pubkey length + if (pubKeyHex.length != 64) { + return null + } + return SubscriptionConfig( subId = generateSubId("meta-${pubKeyHex.take(8)}"), filters = listOf(FilterBuilders.userMetadata(pubKeyHex)), relays = relays, onEvent = onEvent, onEose = onEose, ) +} + +/** + * Creates a subscription config for metadata of multiple users (kind 0). + * Useful for batch-fetching author profiles. + * Filters out any invalid pubkeys (not 64 characters). + */ +fun createBatchMetadataSubscription( + relays: Set, + pubKeyHexList: List, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + // Filter out invalid pubkeys + val validPubkeys = pubKeyHexList.filter { it.length == 64 } + if (validPubkeys.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("meta-batch-${validPubkeys.size}"), + filters = listOf(FilterBuilders.userMetadataBatch(validPubkeys)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} /** * Creates a subscription config for user posts (kind 1). diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt new file mode 100644 index 000000000..23c52fb12 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -0,0 +1,347 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.launch + +private enum class BookmarkTab { PUBLIC, PRIVATE } + +/** + * Screen displaying user's bookmarked notes (public and private). + */ +@Composable +fun BookmarksScreen( + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, +) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val scope = rememberCoroutineScope() + + // Tab state + var selectedTab by remember { mutableStateOf(BookmarkTab.PUBLIC) } + + // State for bookmark list + var bookmarkList by remember { mutableStateOf(null) } + var publicBookmarkIds by remember { mutableStateOf>(emptyList()) } + var privateBookmarkIds by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var hasReceivedEose by remember { mutableStateOf(false) } + + // State for fetched bookmark events + val publicEventState = + remember(account.pubKeyHex) { + EventCollectionState( + getId = { it.id }, + maxSize = 100, + scope = scope, + ) + } + val publicEvents by publicEventState.items.collectAsState() + + val privateEventState = + remember(account.pubKeyHex) { + EventCollectionState( + getId = { it.id }, + maxSize = 100, + scope = scope, + ) + } + val privateEvents by privateEventState.items.collectAsState() + + // Load metadata for bookmark authors via coordinator + LaunchedEffect(publicEvents, privateEvents, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null) { + val pubkeys = (publicEvents + privateEvents).map { it.pubKey }.distinct() + if (pubkeys.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys) + } + } + } + + // Subscribe to user's bookmark list (kind 30001) + rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + SubscriptionConfig( + subId = "bookmarks-list-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + // Extract public bookmarked event IDs + val pubIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + publicBookmarkIds = pubIds + } + }, + onEose = { _, _ -> + hasReceivedEose = true + isLoading = false + }, + ) + } else { + isLoading = false + null + } + } + + // Decrypt private bookmarks when bookmark list changes + LaunchedEffect(bookmarkList) { + bookmarkList?.let { list -> + scope.launch { + try { + val privateBookmarks = list.privateBookmarks(account.signer) + val privIds = + privateBookmarks + ?.filterIsInstance() + ?.map { it.eventId } + ?: emptyList() + privateBookmarkIds = privIds + } catch (e: Exception) { + println("Failed to decrypt private bookmarks: ${e.message}") + privateBookmarkIds = emptyList() + } + } + } + } + + // Subscribe to fetch the actual public bookmarked events + rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) { + publicEventState.clear() + SubscriptionConfig( + subId = "public-bookmarked-events-${System.currentTimeMillis()}", + filters = + listOf( + FilterBuilders.byIds(publicBookmarkIds), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + publicEventState.addItem(event) + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Subscribe to fetch the actual private bookmarked events + rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) { + privateEventState.clear() + SubscriptionConfig( + subId = "private-bookmarked-events-${System.currentTimeMillis()}", + filters = + listOf( + FilterBuilders.byIds(privateBookmarkIds), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + privateEventState.addItem(event) + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents + val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds + + Column(modifier = Modifier.fillMaxSize()) { + // Header with tabs + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Bookmarks", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.weight(1f)) + + // Tab selector + Row( + horizontalArrangement = + androidx.compose.foundation.layout.Arrangement + .spacedBy(8.dp), + ) { + FilterChip( + selected = selectedTab == BookmarkTab.PUBLIC, + onClick = { selectedTab = BookmarkTab.PUBLIC }, + label = { Text("Public (${publicBookmarkIds.size})") }, + ) + FilterChip( + selected = selectedTab == BookmarkTab.PRIVATE, + onClick = { selectedTab = BookmarkTab.PRIVATE }, + label = { Text("Private (${privateBookmarkIds.size})") }, + ) + } + } + + // Content + when { + isLoading && !hasReceivedEose -> { + LoadingState(message = "Loading bookmarks...") + } + currentBookmarkIds.isEmpty() && hasReceivedEose -> { + EmptyState( + title = if (selectedTab == BookmarkTab.PUBLIC) "No public bookmarks" else "No private bookmarks", + description = + if (selectedTab == BookmarkTab.PUBLIC) { + "Bookmark notes publicly to save them here" + } else { + "Private bookmarks are encrypted and only visible to you" + }, + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(currentEvents, key = { it.id }) { event -> + Column( + modifier = + Modifier.clickable { + onNavigateToThread(event.id) + }, + ) { + NoteCard( + note = event.toNoteDisplayData(localCache), + onAuthorClick = onNavigateToProfile, + ) + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onNavigateToThread(event.id) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + isBookmarked = true, + bookmarkList = bookmarkList, + onBookmarkChanged = { newList -> + bookmarkList = newList + // Update public bookmark IDs + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + publicBookmarkIds = pubIds + + // Decrypt and update private bookmark IDs + scope.launch { + try { + val privateBookmarks = newList.privateBookmarks(account.signer) + val privIds = + privateBookmarks + ?.filterIsInstance() + ?.map { it.eventId } + ?: emptyList() + privateBookmarkIds = privIds + } catch (e: Exception) { + // Keep existing private IDs if decryption fails + } + } + + // Remove unbookmarked event from appropriate list + if (!pubIds.contains(event.id)) { + publicEventState.removeItem(event.id) + } + if (!privateBookmarkIds.contains(event.id)) { + privateEventState.removeItem(event.id) + } + }, + ) + } + HorizontalDivider(thickness = 1.dp) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 954864647..38b01f339 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull @@ -28,7 +30,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub /** * Extension to convert Event to NoteDisplayData for the shared NoteCard. */ -fun Event.toNoteDisplayData(): NoteDisplayData { +fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData { val npub = try { pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." @@ -36,10 +38,13 @@ fun Event.toNoteDisplayData(): NoteDisplayData { pubKey.take(16) + "..." } + val pictureUrl = (cache?.getUserIfExists(pubKey) as? User)?.profilePicture() + return NoteDisplayData( id = id, pubKeyHex = pubKey, pubKeyDisplay = npub, + profilePictureUrl = pictureUrl, content = content, createdAt = createdAt, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 79a92ddbe..76261ef03 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,17 +54,33 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent /** * Note card with action buttons. @@ -72,11 +89,23 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent fun FeedNoteCard( event: Event, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, onReply: () -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + zapReceipts: List = emptyList(), + reactionCount: Int = 0, + replyCount: Int = 0, + repostCount: Int = 0, + bookmarkList: BookmarkListEvent? = null, + isBookmarked: Boolean = false, + onBookmarkChanged: (BookmarkListEvent) -> Unit = {}, ) { + val zapAmountSats = zapReceipts.sumOf { it.amountSats } + Column( modifier = Modifier.clickable { @@ -84,7 +113,7 @@ fun FeedNoteCard( }, ) { NoteCard( - note = event.toNoteDisplayData(), + note = event.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, ) @@ -93,9 +122,21 @@ fun FeedNoteCard( NoteActionsRow( event = event, relayManager = relayManager, + localCache = localCache, account = account, + nwcConnection = nwcConnection, onReplyClick = onReply, + onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = zapReceipts.size, + zapAmountSats = zapAmountSats, + zapReceipts = zapReceipts, + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + bookmarkList = bookmarkList, + isBookmarked = isBookmarked, + onBookmarkChanged = onBookmarkChanged, ) } } @@ -104,10 +145,14 @@ fun FeedNoteCard( @Composable fun FeedScreen( relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -123,8 +168,23 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } - var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } + var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } + var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } + // Track reaction event IDs per target event to deduplicate + var reactionIdsByEvent by remember { mutableStateOf>>(emptyMap()) } + val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } + // Track reply/repost event IDs per target event to deduplicate + var replyIdsByEvent by remember { mutableStateOf>>(emptyMap()) } + val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } + var repostIdsByEvent by remember { mutableStateOf>>(emptyMap()) } + val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // Track EOSE to know when initial load is complete + var eoseReceivedCount by remember { mutableStateOf(0) } + val initialLoadComplete = eoseReceivedCount > 0 // Load followed users for Following feed mode rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { @@ -144,9 +204,45 @@ fun FeedScreen( } } - // Clear events when feed mode changes + // Load user's bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = "bookmarks-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + // Extract public bookmarked event IDs + val pubIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Clear events and reset EOSE when feed mode changes remember(feedMode) { eventState.clear() + eoseReceivedCount = 0 } // Subscribe to feed based on mode @@ -161,8 +257,15 @@ fun FeedScreen( createGlobalFeedSubscription( relays = configuredRelays, onEvent = { event, _, _, _ -> + // Store metadata events in cache + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } eventState.addItem(event) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } FeedMode.FOLLOWING -> { @@ -171,8 +274,15 @@ fun FeedScreen( relays = configuredRelays, followedUsers = followedUsers.toList(), onEvent = { event, _, _, _ -> + // Store metadata events in cache + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } eventState.addItem(event) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } else { null @@ -181,6 +291,181 @@ fun FeedScreen( } } + // Subscribe to zaps for visible events + val eventIds = events.map { it.id } + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { + return@rememberSubscription null + } + + createZapsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent) { + val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription + val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription + zapsByEvent = + zapsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptyList() + if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { + this[targetEventId] = existing + receipt + } + } + } + }, + ) + } + + // Subscribe to metadata for zap senders (to show display names) + val zapSenderPubkeys = + zapsByEvent.values + .flatten() + .map { it.senderPubKey } + .distinct() + rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) { + return@rememberSubscription null + } + + // Only fetch metadata for users we don't have yet + val missingPubkeys = + zapSenderPubkeys.filter { pubkey -> + localCache.getUserIfExists(pubkey)?.info == null + } + if (missingPubkeys.isEmpty()) { + return@rememberSubscription null + } + + createBatchMetadataSubscription( + relays = configuredRelays, + pubKeyHexList = missingPubkeys, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + } + + // Subscribe to reactions for visible events + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { + return@rememberSubscription null + } + + createReactionsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is ReactionEvent) { + val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription + reactionIdsByEvent = + reactionIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to replies for visible events + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { + return@rememberSubscription null + } + + createRepliesSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + // Find the event this is replying to + val replyToId = + event.tags + .filter { it.size >= 2 && it[0] == "e" } + .lastOrNull() + ?.get(1) ?: return@createRepliesSubscription + if (replyToId in eventIds) { + replyIdsByEvent = + replyIdsByEvent.toMutableMap().apply { + val existing = this[replyToId] ?: emptySet() + this[replyToId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to reposts for visible events + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { + return@rememberSubscription null + } + + createRepostsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is RepostEvent) { + val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription + repostIdsByEvent = + repostIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to metadata for note authors (to enable zaps and populate search cache) + val authorPubkeys = events.map { it.pubKey }.distinct() + + // Use coordinator for rate-limited metadata loading (preferred) + LaunchedEffect(authorPubkeys, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys) + } + } + + // Fallback subscription if coordinator not available + rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { + // Skip if using coordinator + if (subscriptionsCoordinator != null) { + return@rememberSubscription null + } + + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) { + return@rememberSubscription null + } + + // Only fetch metadata for users we don't have yet + val missingPubkeys = + authorPubkeys.filter { pubkey -> + localCache.getUserIfExists(pubkey)?.info == null + } + if (missingPubkeys.isEmpty()) { + return@rememberSubscription null + } + + createBatchMetadataSubscription( + relays = configuredRelays, + pubKeyHexList = missingPubkeys, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + } + Column(modifier = Modifier.fillMaxSize()) { // Header with compose button Row( @@ -204,12 +489,18 @@ fun FeedScreen( Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { FilterChip( selected = feedMode == FeedMode.GLOBAL, - onClick = { feedMode = FeedMode.GLOBAL }, + onClick = { + feedMode = FeedMode.GLOBAL + DesktopPreferences.feedMode = FeedMode.GLOBAL + }, label = { Text("Global") }, ) FilterChip( selected = feedMode == FeedMode.FOLLOWING, - onClick = { feedMode = FeedMode.FOLLOWING }, + onClick = { + feedMode = FeedMode.FOLLOWING + DesktopPreferences.feedMode = FeedMode.FOLLOWING + }, label = { Text("Following") }, ) } @@ -262,13 +553,23 @@ fun FeedScreen( LoadingState("Connecting to relays...") } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { LoadingState("Loading followed users...") - } else if (events.isEmpty()) { - LoadingState( - if (feedMode == FeedMode.FOLLOWING) { - "No notes from followed users yet" - } else { - "Loading notes..." - }, + } else if (events.isEmpty() && !initialLoadComplete) { + LoadingState("Loading notes...") + } else if (events.isEmpty() && initialLoadComplete) { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users" + } else { + "No notes found" + }, + description = + if (feedMode == FeedMode.FOLLOWING) { + "Notes from people you follow will appear here" + } else { + "Notes from the network will appear here" + }, + onRefresh = { relayManager.connect() }, ) } else { LazyColumn( @@ -278,10 +579,29 @@ fun FeedScreen( FeedNoteCard( event = event, relayManager = relayManager, + localCache = localCache, account = account, + nwcConnection = nwcConnection, onReply = { replyToEvent = event }, + onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, + zapReceipts = zapsByEvent[event.id] ?: emptyList(), + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 2a96f8399..ccf6ab061 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -20,18 +20,34 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -39,30 +55,459 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.Bookmark +import com.vitorpamplona.amethyst.commons.icons.BookmarkFilled import com.vitorpamplona.amethyst.commons.icons.Reply import com.vitorpamplona.amethyst.commons.icons.Repost +import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction +import com.vitorpamplona.amethyst.commons.model.nip51Bookmarks.BookmarkAction +import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction +import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume + +private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L) /** - * Action buttons row for a note (react, reply, repost). + * Feedback from a zap operation for UI display. + */ +sealed class ZapFeedback { + data class Success( + val amountSats: Long, + ) : ZapFeedback() + + data class ExternalWallet( + val amountSats: Long, + ) : ZapFeedback() + + data class Error( + val message: String, + ) : ZapFeedback() + + data object Timeout : ZapFeedback() + + data class NoLightningAddress( + val pubKey: String, + ) : ZapFeedback() +} + +/** + * Data class representing a zap receipt for display. + */ +data class ZapReceipt( + val senderPubKey: String, + val amountSats: Long, + val message: String?, + val createdAt: Long, +) + +/** + * Converts an LnZapEvent to a ZapReceipt for display. + */ +fun LnZapEvent.toZapReceipt(localCache: DesktopLocalCache): ZapReceipt? { + val senderPubKey = zappedRequestAuthor() ?: return null + val amountSats = amount?.toLong() ?: return null + + return ZapReceipt( + senderPubKey = senderPubKey, + amountSats = amountSats, + message = zapRequest?.content?.ifBlank { null }, + createdAt = createdAt, + ) +} + +/** + * Gets display name for a pubkey, looking up from cache. + * Falls back to shortened npub if not found. + */ +fun getDisplayName( + pubKey: String, + localCache: DesktopLocalCache, +): String { + val user = localCache.getUserIfExists(pubKey) + return user?.info?.bestName() + ?: pubKey.hexToByteArrayOrNull()?.toNpub()?.let { npub -> + npub.take(12) + "..." + npub.takeLast(6) + } + ?: pubKey.take(12) + "..." +} + +/** + * Dialog for selecting zap amount and optional message. + */ +@Composable +fun ZapAmountDialog( + onDismiss: () -> Unit, + onZap: (Long, String) -> Unit, +) { + var selectedAmount by remember { mutableStateOf(21L) } + var message by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Zap") }, + text = { + Column { + Text( + "Select amount in sats", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ZAP_AMOUNTS.take(3).forEach { amount -> + FilterChip( + selected = selectedAmount == amount, + onClick = { selectedAmount = amount }, + label = { Text("$amount") }, + ) + } + } + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ZAP_AMOUNTS.drop(3).forEach { amount -> + FilterChip( + selected = selectedAmount == amount, + onClick = { selectedAmount = amount }, + label = { Text(formatSats(amount)) }, + ) + } + } + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.OutlinedTextField( + value = message, + onValueChange = { message = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Message (optional)") }, + placeholder = { Text("Add a comment...") }, + singleLine = false, + maxLines = 3, + ) + } + }, + confirmButton = { + Button(onClick = { onZap(selectedAmount, message) }) { + Text("Zap ${formatSats(selectedAmount)} sats") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +private fun formatSats(amount: Long): String = if (amount >= 1000) "${amount / 1000}k" else "$amount" + +/** + * Dialog for choosing bookmark visibility (public or private). + */ +@Composable +fun BookmarkDialog( + onDismiss: () -> Unit, + onBookmark: (isPrivate: Boolean) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Bookmark") }, + text = { + Column { + Text( + "Choose bookmark visibility", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = true, + onClick = { onBookmark(false) }, + label = { Text("Public") }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = false, + onClick = { onBookmark(true) }, + label = { Text("Private") }, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + "Private bookmarks are encrypted and only visible to you.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +/** + * Dialog for displaying zap receipts. + * Automatically loads missing user metadata when opened. + */ +@Composable +fun ZapReceiptsDialog( + receipts: List, + totalAmount: Long, + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + onDismiss: () -> Unit, +) { + var isLoading by remember { mutableStateOf(false) } + // Trigger recomposition when metadata loads + var metadataVersion by remember { mutableIntStateOf(0) } + + // Find users without metadata and load them + LaunchedEffect(receipts) { + val pubKeysNeedingMetadata = + receipts + .map { it.senderPubKey } + .distinct() + .filter { pubKey -> + val user = localCache.getUserIfExists(pubKey) + user?.info == null + } + + if (pubKeysNeedingMetadata.isNotEmpty()) { + isLoading = true + fetchMetadataForUsers(pubKeysNeedingMetadata, relayManager, localCache) { + metadataVersion++ + } + isLoading = false + } + } + + // Force read metadataVersion to trigger recomposition + @Suppress("UNUSED_EXPRESSION") + metadataVersion + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Zap, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text("${formatSats(totalAmount)} sats") + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + } + }, + text = { + if (receipts.isEmpty()) { + Text( + "No zaps yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + receipts.sortedByDescending { it.amountSats }.take(10).forEach { receipt -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = getDisplayName(receipt.senderPubKey, localCache), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!receipt.message.isNullOrBlank()) { + Text( + text = receipt.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + Text( + text = "${formatSats(receipt.amountSats)} sats", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + if (receipts.size > 10) { + Text( + text = "and ${receipts.size - 10} more...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + }, + ) +} + +/** + * Fetches metadata for multiple users in a single subscription. + */ +private suspend fun fetchMetadataForUsers( + pubKeys: List, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + onMetadataLoaded: () -> Unit, +) = withContext(Dispatchers.IO) { + if (pubKeys.isEmpty()) return@withContext + + val subId = "metadata-zaps-${pubKeys.hashCode()}" + val relays = relayManager.connectedRelays.value + val remaining = pubKeys.toMutableSet() + + val filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = pubKeys, + ), + ) + + suspendCancellableCoroutine { continuation -> + val timeoutJob = + kotlinx.coroutines.GlobalScope.launch { + kotlinx.coroutines.delay(5000) // 5 second timeout + if (continuation.isActive) { + relayManager.unsubscribe(subId) + continuation.resume(Unit) + } + } + + relayManager.subscribe( + subId = subId, + filters = filters, + relays = relays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + remaining.remove(event.pubKey) + onMetadataLoaded() + + // All metadata loaded + if (remaining.isEmpty() && continuation.isActive) { + timeoutJob.cancel() + relayManager.unsubscribe(subId) + continuation.resume(Unit) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Wait for all relays or timeout + } + }, + ) + + continuation.invokeOnCancellation { + timeoutJob.cancel() + relayManager.unsubscribe(subId) + } + } +} + +/** + * Action buttons row for a note (react, reply, repost, zap, bookmark). */ @Composable fun NoteActionsRow( event: Event, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn, onReplyClick: () -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, modifier: Modifier = Modifier, + zapCount: Int = 0, + zapAmountSats: Long = 0, + zapReceipts: List = emptyList(), + reactionCount: Int = 0, + replyCount: Int = 0, + repostCount: Int = 0, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, + isBookmarked: Boolean = false, + bookmarkList: BookmarkListEvent? = null, + onBookmarkChanged: (BookmarkListEvent) -> Unit = {}, ) { var isLiked by remember { mutableStateOf(false) } var isReposted by remember { mutableStateOf(false) } + var localReactionCount by remember(reactionCount) { mutableStateOf(reactionCount) } + var localRepostCount by remember(repostCount) { mutableStateOf(repostCount) } + var isZapping by remember { mutableStateOf(false) } + var showZapDialog by remember { mutableStateOf(false) } + var showZapReceiptsDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() Row( @@ -70,70 +515,184 @@ fun NoteActionsRow( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Reply button - IconButton( - onClick = onReplyClick, - modifier = Modifier.size(32.dp), - ) { - Icon( - Reply, - contentDescription = "Reply", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) + // Reply button with count + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = onReplyClick, + modifier = Modifier.size(32.dp), + ) { + Icon( + Reply, + contentDescription = "Reply", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + if (replyCount > 0) { + Text( + text = "$replyCount", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - // Like button - IconButton( - onClick = { - if (!isLiked) { - scope.launch { - reactToNote( - event = event, - reaction = "+", - account = account, - relayManager = relayManager, + // Like button with count + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { + if (!isLiked) { + scope.launch { + reactToNote( + event = event, + reaction = "+", + account = account, + relayManager = relayManager, + ) + isLiked = true + localReactionCount++ + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = if (isLiked) "Unlike" else "Like", + tint = + if (isLiked) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + if (localReactionCount > 0) { + Text( + text = "$localReactionCount", + style = MaterialTheme.typography.labelSmall, + color = if (isLiked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Repost button with count + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { + if (!isReposted) { + scope.launch { + repostNote( + event = event, + account = account, + relayManager = relayManager, + ) + isReposted = true + localRepostCount++ + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Repost, + contentDescription = "Repost", + tint = + if (isReposted) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + if (localRepostCount > 0) { + Text( + text = "$localRepostCount", + style = MaterialTheme.typography.labelSmall, + color = if (isReposted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Zap button with amount (clickable to show receipts) + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { + if (isZapping) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + IconButton( + onClick = { showZapDialog = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Zap, + contentDescription = "Zap", + tint = + if (zapAmountSats > 0) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), ) - isLiked = true } } - }, - modifier = Modifier.size(32.dp), - ) { - Icon( - if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, - contentDescription = if (isLiked) "Unlike" else "Like", - tint = - if (isLiked) { - MaterialTheme.colorScheme.error + } + if (zapAmountSats > 0) { + Text( + text = formatSats(zapAmountSats), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { showZapReceiptsDialog = true }, + ) + } + } + + // Bookmark button + var isBookmarking by remember { mutableStateOf(false) } + var localIsBookmarked by remember(isBookmarked) { mutableStateOf(isBookmarked) } + var showBookmarkDialog by remember { mutableStateOf(false) } + + IconButton( + onClick = { + if (!isBookmarking) { + if (localIsBookmarked) { + // Remove bookmark immediately + scope.launch { + isBookmarking = true + val newBookmarkList = + removeBookmark( + event = event, + bookmarkList = bookmarkList, + account = account, + relayManager = relayManager, + ) + if (newBookmarkList != null) { + localIsBookmarked = false + onBookmarkChanged(newBookmarkList) + } + isBookmarking = false + } } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) - } - - // Repost button - IconButton( - onClick = { - if (!isReposted) { - scope.launch { - repostNote( - event = event, - account = account, - relayManager = relayManager, - ) - isReposted = true + // Show dialog to choose public/private + showBookmarkDialog = true } } }, modifier = Modifier.size(32.dp), + enabled = !isBookmarking, ) { Icon( - Repost, - contentDescription = "Repost", + if (localIsBookmarked) BookmarkFilled else Bookmark, + contentDescription = if (localIsBookmarked) "Remove bookmark" else "Bookmark", tint = - if (isReposted) { + if (localIsBookmarked) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -142,11 +701,111 @@ fun NoteActionsRow( ) } - // Placeholder for action count - Text( - "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Bookmark dialog + if (showBookmarkDialog) { + BookmarkDialog( + onDismiss = { showBookmarkDialog = false }, + onBookmark = { isPrivate -> + showBookmarkDialog = false + scope.launch { + isBookmarking = true + val newBookmarkList = + addBookmark( + event = event, + bookmarkList = bookmarkList, + isPrivate = isPrivate, + account = account, + relayManager = relayManager, + ) + if (newBookmarkList != null) { + localIsBookmarked = true + onBookmarkChanged(newBookmarkList) + } + isBookmarking = false + } + }, + ) + } + + // Overflow menu (three dots) + var showOverflowMenu by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { showOverflowMenu = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "More options", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Copy Note Link") }, + onClick = { + val noteLink = "nostr:${NNote.create(event.id)}" + copyToClipboard(noteLink) + showOverflowMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Copy Event Link") }, + onClick = { + val relays = relayManager.connectedRelays.value.take(3) + val neventLink = "nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}" + copyToClipboard(neventLink) + showOverflowMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Copy Event ID") }, + onClick = { + copyToClipboard(event.id) + showOverflowMenu = false + }, + ) + } + } + } + + // Zap amount selection dialog + if (showZapDialog) { + ZapAmountDialog( + onDismiss = { showZapDialog = false }, + onZap = { amountSats, message -> + showZapDialog = false + scope.launch { + isZapping = true + val feedback = + zapNote( + event = event, + account = account, + relayManager = relayManager, + localCache = localCache, + amountSats = amountSats, + message = message, + nwcConnection = nwcConnection, + ) + isZapping = false + onZapFeedback(feedback) + } + }, + ) + } + + // Zap receipts dialog + if (showZapReceiptsDialog) { + ZapReceiptsDialog( + receipts = zapReceipts, + totalAmount = zapAmountSats, + localCache = localCache, + relayManager = relayManager, + onDismiss = { showZapReceiptsDialog = false }, ) } } @@ -161,14 +820,81 @@ private suspend fun reactToNote( relayManager: DesktopRelayConnectionManager, ) { withContext(Dispatchers.IO) { - // Use shared ReactionAction from commons val signedEvent = ReactionAction.reactTo(event, reaction, account.signer) - - // Broadcast to all relays relayManager.broadcastToAll(signedEvent) } } +/** + * Adds an event to bookmarks (public or private). + * Returns the new bookmark list event, or null if operation failed. + */ +private suspend fun addBookmark( + event: Event, + bookmarkList: BookmarkListEvent?, + isPrivate: Boolean, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +): BookmarkListEvent? = + withContext(Dispatchers.IO) { + try { + val newBookmarkList = + if (bookmarkList != null) { + BookmarkAction.addBookmark( + existingList = bookmarkList, + eventId = event.id, + isPrivate = isPrivate, + signer = account.signer, + ) + } else { + BookmarkAction.createWithBookmark( + eventId = event.id, + isPrivate = isPrivate, + signer = account.signer, + ) + } + + // Broadcast to all relays + relayManager.broadcastToAll(newBookmarkList) + + newBookmarkList + } catch (e: Exception) { + println("Failed to add bookmark: ${e.message}") + null + } + } + +/** + * Removes an event from bookmarks (checks both public and private). + * Returns the new bookmark list event, or null if operation failed. + */ +private suspend fun removeBookmark( + event: Event, + bookmarkList: BookmarkListEvent?, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +): BookmarkListEvent? = + withContext(Dispatchers.IO) { + try { + if (bookmarkList == null) return@withContext null + + val newBookmarkList = + BookmarkAction.removeBookmark( + existingList = bookmarkList, + eventId = event.id, + signer = account.signer, + ) + + // Broadcast to all relays + relayManager.broadcastToAll(newBookmarkList) + + newBookmarkList + } catch (e: Exception) { + println("Failed to remove bookmark: ${e.message}") + null + } + } + /** * Creates a repost event and broadcasts to relays. */ @@ -178,10 +904,199 @@ private suspend fun repostNote( relayManager: DesktopRelayConnectionManager, ) { withContext(Dispatchers.IO) { - // Use shared RepostAction from commons val signedEvent = RepostAction.repost(event, account.signer) - - // Broadcast to all relays relayManager.broadcastToAll(signedEvent) } } + +/** + * Creates a zap request and pays via NWC or opens external wallet. + * Returns feedback for UI display. + */ +private suspend fun zapNote( + event: Event, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + amountSats: Long, + message: String = "", + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, +): ZapFeedback = + withContext(Dispatchers.IO) { + // Get author's lightning address from cache + var user = localCache.getUserIfExists(event.pubKey) + var lnAddress = user?.info?.lud16 ?: user?.info?.lud06 + + // TODO: Use UserFinderFilterAssemblerSubscription pattern from Amethyst + // to proactively load metadata when zap button is displayed. + // For now, fetch on-demand if missing. + if (lnAddress == null) { + lnAddress = fetchUserLightningAddress(event.pubKey, relayManager, localCache) + } + + if (lnAddress == null) { + return@withContext ZapFeedback.NoLightningAddress(event.pubKey.take(8)) + } + + // Create HTTP client and resolver + val httpClient = + OkHttpClient + .Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + val resolver = LightningAddressResolver(httpClient) + + // Get relay URLs for zap request + val relays = relayManager.connectedRelays.value + + // Fetch invoice + val result = + ZapAction.fetchZapInvoice( + targetEvent = event, + lnAddress = lnAddress, + amountSats = amountSats, + message = message, + relays = relays, + signer = account.signer, + resolver = resolver, + ) + + when (result) { + is ZapAction.ZapResult.Invoice -> { + // Pay via NWC if configured, otherwise open external wallet + if (nwcConnection != null) { + // Get/create Note for tracking the payment + val zappedNote = localCache.getOrCreateNote(event.id) + if (zappedNote.event == null) { + zappedNote.loadEvent(event, localCache.getOrCreateUser(event.pubKey), emptyList()) + } + + val paymentHandler = NwcPaymentHandler(relayManager, localCache) + when (val paymentResult = paymentHandler.payInvoice(result.bolt11, nwcConnection, zappedNote)) { + is NwcPaymentHandler.PaymentResult.Success -> { + ZapFeedback.Success(amountSats) + } + is NwcPaymentHandler.PaymentResult.Error -> { + ZapFeedback.Error(paymentResult.message) + } + is NwcPaymentHandler.PaymentResult.Timeout -> { + ZapFeedback.Timeout + } + } + } else { + // Fallback: open lightning: URI in external wallet + openLightningUri(result.bolt11) + ZapFeedback.ExternalWallet(amountSats) + } + } + is ZapAction.ZapResult.Error -> { + ZapFeedback.Error(result.message) + } + } + } + +private fun openLightningUri(bolt11: String) { + val uri = "lightning:$bolt11" + try { + val os = System.getProperty("os.name").lowercase() + val command = + when { + os.contains("mac") -> arrayOf("open", uri) + os.contains("win") -> arrayOf("cmd", "/c", "start", uri) + else -> arrayOf("xdg-open", uri) // Linux + } + Runtime.getRuntime().exec(command) + } catch (e: Exception) { + println("Failed to open lightning URI: ${e.message}") + println("Invoice: $bolt11") + } +} + +/** + * Fetches user metadata on-demand to get lightning address. + * Returns the lightning address if found, null otherwise. + */ +private suspend fun fetchUserLightningAddress( + pubKey: String, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, +): String? = + suspendCancellableCoroutine { continuation -> + val relays = relayManager.connectedRelays.value + if (relays.isEmpty()) { + continuation.resume(null) + return@suspendCancellableCoroutine + } + + val subId = "meta-zap-${pubKey.take(8)}" + var resumed = false + + // Set timeout + val timeoutJob = + kotlinx.coroutines.GlobalScope.launch { + kotlinx.coroutines.delay(5000) // 5 second timeout + if (!resumed) { + resumed = true + relayManager.unsubscribe(subId) + continuation.resume(null) + } + } + + val filters = + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(pubKey), + limit = 1, + ), + ) + + // Subscribe to fetch metadata + relayManager.subscribe( + subId = subId, + filters = filters, + relays = relays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is MetadataEvent && !resumed) { + localCache.consumeMetadata(event) + val user = localCache.getUserIfExists(pubKey) + val lnAddress = user?.info?.lud16 ?: user?.info?.lud06 + if (lnAddress != null && !resumed) { + resumed = true + timeoutJob.cancel() + relayManager.unsubscribe(subId) + continuation.resume(lnAddress) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // If we get EOSE without finding address, wait for timeout or other relays + } + }, + ) + + continuation.invokeOnCancellation { + timeoutJob.cancel() + relayManager.unsubscribe(subId) + } + } + +/** + * Copies text to the system clipboard. + */ +private fun copyToClipboard(text: String) { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 82b75329a..59ac06f2c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -39,10 +39,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -50,11 +53,13 @@ import com.vitorpamplona.amethyst.commons.icons.Reply import com.vitorpamplona.amethyst.commons.icons.Repost import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip01Core.core.Event @@ -105,6 +110,7 @@ sealed class NotificationItem( fun NotificationsScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -120,6 +126,18 @@ fun NotificationsScreen( } val notifications by notificationState.items.collectAsState() + // Load metadata for notification authors via coordinator + LaunchedEffect(notifications, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null && notifications.isNotEmpty()) { + val pubkeys = notifications.map { it.event.pubKey }.distinct() + subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys) + } + } + + // Track EOSE to know when initial load is complete + var eoseReceivedCount by remember { mutableStateOf(0) } + val initialLoadComplete = eoseReceivedCount > 0 + // Subscribe to notifications rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -168,6 +186,9 @@ fun NotificationsScreen( notificationState.addItem(notification) }, + onEose = { _, _ -> + eoseReceivedCount++ + }, ) } else { null @@ -185,8 +206,14 @@ fun NotificationsScreen( if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") - } else if (notifications.isEmpty()) { + } else if (notifications.isEmpty() && !initialLoadComplete) { LoadingState("Loading notifications...") + } else if (notifications.isEmpty() && initialLoadComplete) { + EmptyState( + title = "No notifications yet", + description = "When someone interacts with your posts, you'll see it here", + onRefresh = { relayManager.connect() }, + ) } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt new file mode 100644 index 000000000..81b415e1f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -0,0 +1,364 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingLongFormFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + +private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000)) + +/** + * Card displaying long-form content (NIP-23) with title, summary, and image. + */ +@Composable +fun LongFormCard( + event: LongTextNoteEvent, + localCache: DesktopLocalCache, + onAuthorClick: (String) -> Unit = {}, + onClick: () -> Unit = {}, +) { + val author = localCache.getUserIfExists(event.pubKey) + val authorName = author?.info?.bestName() ?: event.pubKey.take(8) + val publishedAt = event.publishedAt() ?: event.createdAt + + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Title + event.title()?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + } + + // Summary + event.summary()?.let { summary -> + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(12.dp)) + } + + // Footer with author and date + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = authorName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { onAuthorClick(event.pubKey) }, + ) + + Text( + text = formatDate(publishedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Topics/hashtags + val topics = event.topics() + if (topics.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + topics.take(3).forEach { topic -> + Text( + text = "#$topic", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + } + } +} + +@Composable +fun ReadsScreen( + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn? = null, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToArticle: (String) -> Unit = {}, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val scope = rememberCoroutineScope() + + val eventState = + remember { + EventCollectionState( + getId = { it.id }, + sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt }, + maxSize = 100, + scope = scope, + ) + } + val events by eventState.items.collectAsState() + + var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } + var followedUsers by remember { mutableStateOf>(emptySet()) } + var eoseReceivedCount by remember { mutableStateOf(0) } + val initialLoadComplete = eoseReceivedCount > 0 + + // Load followed users for Following feed mode + rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { + createContactListSubscription( + relays = configuredRelays, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, _, _ -> + if (event is ContactListEvent) { + followedUsers = event.verifiedFollowKeySet() + } + }, + ) + } else { + null + } + } + + // Clear events when feed mode changes + remember(feedMode) { + eventState.clear() + eoseReceivedCount = 0 + } + + // Subscribe to long-form content feed + rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty()) { + return@rememberSubscription null + } + + when (feedMode) { + FeedMode.GLOBAL -> { + createLongFormFeedSubscription( + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent) { + eventState.addItem(event) + } + }, + onEose = { _, _ -> + eoseReceivedCount++ + }, + ) + } + FeedMode.FOLLOWING -> { + if (followedUsers.isNotEmpty()) { + createFollowingLongFormFeedSubscription( + relays = configuredRelays, + followedUsers = followedUsers.toList(), + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent) { + eventState.addItem(event) + } + }, + onEose = { _, _ -> + eoseReceivedCount++ + }, + ) + } else { + null + } + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Reads", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + // Feed mode selector + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { feedMode = FeedMode.GLOBAL }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { feedMode = FeedMode.FOLLOWING }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } + } + + Spacer(Modifier.height(8.dp)) + + when { + connectedRelays.isEmpty() -> { + LoadingState("Connecting to relays...") + } + feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty() -> { + LoadingState("Loading followed users...") + } + events.isEmpty() && !initialLoadComplete -> { + LoadingState("Loading articles...") + } + events.isEmpty() && initialLoadComplete -> { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No articles from followed users" + } else { + "No articles found" + }, + description = + if (feedMode == FeedMode.FOLLOWING) { + "Long-form articles from people you follow will appear here" + } else { + "Long-form articles from the network will appear here" + }, + onRefresh = { relayManager.connect() }, + ) + } + else -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(events, key = { it.id }) { event -> + LongFormCard( + event = event, + localCache = localCache, + onAuthorClick = onNavigateToProfile, + onClick = { onNavigateToArticle(event.id) }, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt new file mode 100644 index 000000000..4f3bd7164 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -0,0 +1,453 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.search.SearchResult +import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +@Composable +fun SearchScreen( + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + onNavigateToHashtag: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val searchState = remember { SearchBarState(localCache, scope) } + val focusRequester = remember { FocusRequester() } + val relayStatuses by relayManager.relayStatuses.collectAsState() + + // Collect state from SearchBarState + val searchText by searchState.searchText.collectAsState() + val bech32Results by searchState.bech32Results.collectAsState() + val cachedUserResults by searchState.cachedUserResults.collectAsState() + val relaySearchResults by searchState.relaySearchResults.collectAsState() + val isSearchingRelays by searchState.isSearchingRelays.collectAsState() + + // NIP-50 relay search when local cache has few/no results + rememberSubscription(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty()) return@rememberSubscription null + + // Only search relays if we have a real query and limited local results + if (searchState.shouldSearchRelays) { + searchState.startRelaySearch() + createSearchPeopleSubscription( + relays = configuredRelays, + searchQuery = searchText, + limit = 20, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + val user = localCache.getUserIfExists(event.pubKey) + if (user != null) { + searchState.addRelaySearchResult(user) + } + } + }, + onEose = { _, _ -> + searchState.endRelaySearch() + }, + ) + } else { + null + } + } + + // Subscribe to metadata for searched users (to populate cache) + rememberSubscription(relayStatuses, searchText, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || searchText.length < 2) { + return@rememberSubscription null + } + + // If it's a specific pubkey search, fetch that user's metadata + val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) + if (pubkeyHex != null) { + createMetadataSubscription( + relays = configuredRelays, + pubKeyHex = pubkeyHex, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + } else { + null + } + } + + // Auto-focus the search field + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = modifier.fillMaxSize(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Search", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + "${localCache.userCount()} users cached", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Search input field + OutlinedTextField( + value = searchText, + onValueChange = { searchState.updateSearchText(it) }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text("Search by name, npub, nevent, or #hashtag") }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (searchText.isNotEmpty()) { + IconButton(onClick = { searchState.clearSearch() }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + Spacer(Modifier.height(16.dp)) + + // Results + val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty() + + if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) { + Text( + "No matches found. Try a name, npub, nevent, or #hashtag.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } else if (isSearchingRelays && !hasResults) { + Text( + "Searching relays...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } else if (hasResults) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Bech32/hex results first + if (bech32Results.isNotEmpty()) { + item { + Text( + "Direct lookup", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + items(bech32Results) { result -> + SearchResultCard( + result = result, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onNavigateToHashtag = onNavigateToHashtag, + ) + } + } + + // Cached user results + if (cachedUserResults.isNotEmpty()) { + if (bech32Results.isNotEmpty()) { + item { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + } + } + item { + Text( + "Cached users (${cachedUserResults.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user -> + UserSearchCard( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } + } + + // Relay search results (NIP-50) + if (relaySearchResults.isNotEmpty()) { + if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) { + item { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + } + } + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "From relays (${relaySearchResults.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + if (isSearchingRelays) { + Text( + "searching...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user -> + UserSearchCard( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } + } else if (isSearchingRelays && cachedUserResults.isEmpty()) { + item { + Text( + "Searching relays...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } + } + } else { + // Empty state + Column( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Search for users or notes", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Enter a name or Nostr identifier:", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + SearchHint("vitor", "Search by name") + SearchHint("npub1...", "User profile") + SearchHint("note1...", "Single note") + SearchHint("nevent1...", "Note with metadata") + SearchHint("#hashtag", "Hashtag search") + } + } + } + } +} + +@Composable +private fun SearchHint( + identifier: String, + description: String, +) { + Row( + modifier = Modifier.fillMaxWidth(0.6f), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + identifier, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(16.dp)) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SearchResultCard( + result: SearchResult, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + onNavigateToHashtag: (String) -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable { + when (result) { + is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex) + is SearchResult.CachedUserResult -> onNavigateToProfile(result.user.pubkeyHex) + is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex) + is SearchResult.AddressResult -> { + onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}") + } + is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag) + } + }, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = + when (result) { + is SearchResult.UserResult -> Icons.Default.Person + is SearchResult.CachedUserResult -> Icons.Default.Person + is SearchResult.NoteResult -> Icons.Default.Description + is SearchResult.AddressResult -> Icons.Default.Description + is SearchResult.HashtagResult -> Icons.Default.Tag + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + when (result) { + is SearchResult.UserResult -> "User Profile" + is SearchResult.CachedUserResult -> result.user.toBestDisplayName() + is SearchResult.NoteResult -> "Note" + is SearchResult.AddressResult -> "Event (kind ${result.kind})" + is SearchResult.HashtagResult -> "#${result.hashtag}" + }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + when (result) { + is SearchResult.UserResult -> result.displayId + is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex() + is SearchResult.NoteResult -> result.displayId + is SearchResult.AddressResult -> result.displayId + is SearchResult.HashtagResult -> "Search posts with this hashtag" + }, + style = MaterialTheme.typography.bodySmall, + fontFamily = if (result is SearchResult.HashtagResult) null else FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Navigate", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 4b6390f42..d30d19960 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,15 +52,29 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent /** * Desktop Thread Screen - displays a note and all its replies in a thread view. @@ -70,10 +85,15 @@ import com.vitorpamplona.quartz.nip01Core.core.Event fun ThreadScreen( noteId: String, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, + onReply: (Event) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -97,6 +117,71 @@ fun ThreadScreen( // Cache for calculating reply levels val levelCache = remember(noteId) { mutableMapOf() } + // Track EOSE to know when initial load is complete + var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } + var repliesEoseReceived by remember(noteId) { mutableStateOf(false) } + + // Track zaps per event + var zapsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } + // Track reaction event IDs per target event to deduplicate + var reactionIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } + val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size } + // Track reply/repost event IDs per target event to deduplicate + var replyIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } + val repliesByEvent = replyIdsByEvent.mapValues { it.value.size } + var repostIdsByEvent by remember(noteId) { mutableStateOf>>(emptyMap()) } + val repostsByEvent = repostIdsByEvent.mapValues { it.value.size } + + // Bookmark state + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // Load metadata for thread authors via coordinator + LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { + if (subscriptionsCoordinator != null) { + val pubkeys = mutableListOf() + rootNote?.let { pubkeys.add(it.pubKey) } + pubkeys.addAll(replyEvents.map { it.pubKey }) + if (pubkeys.isNotEmpty()) { + subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct()) + } + } + } + + // Subscribe to user's bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = "thread-bookmarks-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + val pubIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Subscribe to the root note rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -110,6 +195,9 @@ fun ThreadScreen( levelCache[event.id] = 0 } }, + onEose = { _, _ -> + rootNoteEoseReceived = true + }, ) } else { null @@ -126,12 +214,115 @@ fun ThreadScreen( onEvent = { event, _, _, _ -> replyEventState.addItem(event) }, + onEose = { _, _ -> + repliesEoseReceived = true + }, ) } else { null } } + // Subscribe to zaps for thread events + val allEventIds = listOf(noteId) + replyEvents.map { it.id } + rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + return@rememberSubscription null + } + + createZapsSubscription( + relays = configuredRelays, + eventIds = allEventIds, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent) { + val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription + val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription + zapsByEvent = + zapsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptyList() + if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { + this[targetEventId] = existing + receipt + } + } + } + }, + ) + } + + // Subscribe to reactions for thread events + rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + return@rememberSubscription null + } + + createReactionsSubscription( + relays = configuredRelays, + eventIds = allEventIds, + onEvent = { event, _, _, _ -> + if (event is ReactionEvent) { + val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription + reactionIdsByEvent = + reactionIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to replies for thread events (for counts) + rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + return@rememberSubscription null + } + + createRepliesSubscription( + relays = configuredRelays, + eventIds = allEventIds, + onEvent = { event, _, _, _ -> + val replyToId = + event.tags + .filter { it.size >= 2 && it[0] == "e" } + .lastOrNull() + ?.get(1) ?: return@createRepliesSubscription + if (replyToId in allEventIds) { + replyIdsByEvent = + replyIdsByEvent.toMutableMap().apply { + val existing = this[replyToId] ?: emptySet() + this[replyToId] = existing + event.id + } + } + }, + ) + } + + // Subscribe to reposts for thread events + rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + return@rememberSubscription null + } + + createRepostsSubscription( + relays = configuredRelays, + eventIds = allEventIds, + onEvent = { event, _, _, _ -> + if (event is RepostEvent) { + val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription + repostIdsByEvent = + repostIdsByEvent.toMutableMap().apply { + val existing = this[targetEventId] ?: emptySet() + this[targetEventId] = existing + event.id + } + } + }, + ) + } + // Calculate reply level for an event based on e-tags fun calculateLevel(event: Event): Int { levelCache[event.id]?.let { return it } @@ -171,8 +362,15 @@ fun ThreadScreen( if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") - } else if (rootNote == null) { + } else if (rootNote == null && !rootNoteEoseReceived) { LoadingState("Loading thread...") + } else if (rootNote == null && rootNoteEoseReceived) { + EmptyState( + title = "Note not found", + description = "This note may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(0.dp), @@ -186,16 +384,38 @@ fun ThreadScreen( }, ) { NoteCard( - note = rootNote!!.toNoteDisplayData(), + note = rootNote!!.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, ) if (account != null) { + val rootZaps = zapsByEvent[noteId] ?: emptyList() NoteActionsRow( event = rootNote!!, relayManager = relayManager, + localCache = localCache, account = account, - onReplyClick = { /* TODO: Open reply dialog */ }, + nwcConnection = nwcConnection, + onReplyClick = { onReply(rootNote!!) }, + onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = rootZaps.size, + zapAmountSats = rootZaps.sumOf { it.amountSats }, + zapReceipts = rootZaps, + reactionCount = reactionsByEvent[noteId] ?: 0, + replyCount = repliesByEvent[noteId] ?: 0, + repostCount = repostsByEvent[noteId] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(noteId), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } @@ -223,16 +443,38 @@ fun ThreadScreen( }, ) { NoteCard( - note = event.toNoteDisplayData(), + note = event.toNoteDisplayData(localCache), onAuthorClick = onNavigateToProfile, ) if (account != null) { + val eventZaps = zapsByEvent[event.id] ?: emptyList() NoteActionsRow( event = event, relayManager = relayManager, + localCache = localCache, account = account, - onReplyClick = { /* TODO: Open reply dialog */ }, + nwcConnection = nwcConnection, + onReplyClick = { onReply(event) }, + onZapFeedback = onZapFeedback, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = eventZaps.size, + zapAmountSats = eventZaps.sumOf { it.amountSats }, + zapReceipts = eventZaps, + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } @@ -240,7 +482,7 @@ fun ThreadScreen( } // Empty state for no replies - if (replyEvents.isEmpty()) { + if (replyEvents.isEmpty() && repliesEoseReceived) { item { Spacer(Modifier.height(32.dp)) Text( @@ -250,6 +492,16 @@ fun ThreadScreen( modifier = Modifier.padding(16.dp), ) } + } else if (replyEvents.isEmpty() && !repliesEoseReceived) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "Loading replies...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index d08286dee..9f96f4409 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -35,6 +35,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material3.Button @@ -46,6 +48,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,7 +65,11 @@ import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription @@ -72,8 +79,11 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection /** * User profile screen showing user info, follow button, and their posts. @@ -82,10 +92,14 @@ import kotlinx.coroutines.withContext fun UserProfileScreen( pubKeyHex: String, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -188,6 +202,61 @@ fun UserProfileScreen( } } + // Subscribe to profile user's contact list (for following count) + rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + createContactListSubscription( + relays = configuredRelays, + pubKeyHex = pubKeyHex, + onEvent = { event, _, _, _ -> + if (event is ContactListEvent) { + // Count the number of people this user follows + followingCount = event.verifiedFollowKeySet().size + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Track unique followers (authors of contact lists that tag this pubkey) + val followerAuthors = remember(pubKeyHex) { mutableSetOf() } + + // Subscribe to followers (contact lists that tag this user) + rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + // Clear previous followers when subscription restarts + followerAuthors.clear() + followersCount = 0 + + SubscriptionConfig( + subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}", + filters = + listOf( + FilterBuilders.byPTags( + pubKeys = listOf(pubKeyHex), + kinds = listOf(3), // ContactListEvent + limit = 500, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + // Count unique authors who follow this user + if (followerAuthors.add(event.pubKey)) { + followersCount = followerAuthors.size + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Subscribe to user posts rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { val configuredRelays = relayStatuses.keys @@ -339,11 +408,49 @@ fun UserProfileScreen( fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(4.dp)) - Text( - (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(32) ?: pubKeyHex.take(32)) + "...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() + var copied by remember { mutableStateOf(false) } + + // Reset copied state after delay + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (npub != null) { + IconButton( + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(npub), null) + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + if (copied) Icons.Default.Check else Icons.Default.ContentCopy, + contentDescription = if (copied) "Copied" else "Copy npub", + modifier = Modifier.size(14.dp), + tint = + if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } } } @@ -461,8 +568,11 @@ fun UserProfileScreen( FeedNoteCard( event = event, relayManager = relayManager, + localCache = localCache, account = account, + nwcConnection = nwcConnection, onReply = onCompose, + onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, ) }