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