diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt new file mode 100644 index 0000000000..2d13cd22fe --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt @@ -0,0 +1,90 @@ +/* + * 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.nipB7Blossom + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * Shared, platform-agnostic state holder for the user's Blossom media server + * list (NIP-B7 / kind 10063 [BlossomServersEvent]). + * + * This is the same event kind the Amethyst mobile app reads through its own + * `BlossomServerListState`: it loads the addressable event from the injected + * [ICacheProvider] and exposes the declared server URLs as a [StateFlow]. Both + * the Android and Desktop front ends can consume this so a server list + * configured on one client shows up on the other. + */ +class BlossomServerListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val blossomListNote = cache.getOrCreateAddressableNote(getBlossomServersAddress()) + + fun getBlossomServersAddress() = BlossomServersEvent.createAddress(signer.pubKey) + + fun getBlossomServersListFlow(): StateFlow = blossomListNote.flow().metadata.stateFlow + + fun getBlossomServersList(): BlossomServersEvent? = blossomListNote.event as? BlossomServersEvent + + fun normalizeServers(note: Note): List = (note.event as? BlossomServersEvent)?.servers() ?: emptyList() + + val flow: StateFlow> = + getBlossomServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(blossomListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun saveBlossomServersList(servers: List): BlossomServersEvent { + val serverList = getBlossomServersList() + + return if (serverList != null && serverList.tags.isNotEmpty()) { + BlossomServersEvent.updateRelayList( + earlierVersion = serverList, + servers = servers, + signer = signer, + ) + } else { + BlossomServersEvent.createFromScratch( + relays = servers, + signer = signer, + ) + } + } +} 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 90c52eeffe..d4c9671a2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -148,6 +148,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel import kotlinx.collections.immutable.toPersistentMap @@ -1687,9 +1688,10 @@ fun MainContent( ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, + BlossomServersEvent.KIND, ), authors = listOf(account.pubKeyHex), - limit = 4, + limit = 5, ) relayManager.subscribe( subId = bootstrapSubId, @@ -1702,9 +1704,11 @@ fun MainContent( relay: NormalizedRelayUrl, forFilters: List?, ) { - // NIP-65 (kind 10002) must go through justConsumeMyOwnEvent - // because localCache.consume() doesn't handle addressable events - if (event is AdvertisedRelayListEvent) { + // NIP-65 (kind 10002) and the Blossom server list + // (kind 10063) are addressable/replaceable events, so + // they must go through justConsumeMyOwnEvent to land in + // the addressable-note cache their state holders observe. + if (event is AdvertisedRelayListEvent || event is BlossomServersEvent) { scope.launch(Dispatchers.IO) { localCache.justConsumeMyOwnEvent(event) } @@ -1717,6 +1721,19 @@ fun MainContent( onDispose { relayManager.unsubscribe(bootstrapSubId) } } + // Mirror the network Blossom server list (kind 10063) into local prefs so + // the upload path (ComposeNoteDialog) and cold start reflect the list the + // user configured on any Amethyst client. Only overwrite with a non-empty + // network list — an empty flow value means the event hasn't loaded yet, and + // clobbering prefs then would wipe the user's offline fallback. + LaunchedEffect(iAccount) { + iAccount.blossomServerList.flow.collect { servers -> + if (servers.isNotEmpty() && servers != DesktopPreferences.blossomServers) { + DesktopPreferences.blossomServers = servers + } + } + } + // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() } @@ -2219,6 +2236,8 @@ fun RelaySettingsScreen( .TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF), onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, namecoinPreferences: DesktopNamecoinPreferences? = null, + blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, + onBlossomServersChanged: (List) -> Unit = { DesktopPreferences.blossomServers = it }, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -2342,11 +2361,17 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) - // Media Server Settings - MediaServerSettings( - initialServers = DesktopPreferences.blossomServers, - onServersChanged = { DesktopPreferences.blossomServers = it }, - ) + // Media Server Settings (Blossom, kind 10063 — synced with mobile) + val networkBlossomServers by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + // The kind-10063 list is authoritative when present; before it loads + // (or when the user has none) fall back to the local prefs mirror. + val effectiveBlossomServers = networkBlossomServers.ifEmpty { DesktopPreferences.blossomServers } + key(effectiveBlossomServers) { + MediaServerSettings( + initialServers = effectiveBlossomServers, + onServersChanged = onBlossomServersChanged, + ) + } Spacer(Modifier.height(24.dp)) HorizontalDivider() Spacer(Modifier.height(24.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index fc762421e9..d064648643 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -309,11 +310,36 @@ class DesktopLocalCache : ICacheProvider { consumeAdvertisedRelayList(event, relay) } + is BlossomServersEvent -> { + consumeBlossomServerList(event, relay) + } + else -> { false } } + /** + * Consumes a kind 10063 (NIP-B7) Blossom media server list event. Stores + * the newest per-author copy in [addressableNotes] so state holders like + * [com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState] + * observe it via their flows. This is the same event the Amethyst mobile + * app uses for the media server list. Emits nothing to the event stream — + * the UI doesn't render kind 10063s directly. + */ + private fun consumeBlossomServerList( + event: BlossomServersEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val addressableNote = getOrCreateAddressableNote(event.address()) + val existing = addressableNote.event + if (existing != null && existing.createdAt >= event.createdAt) return false + val author = getOrCreateUser(event.pubKey) + addressableNote.loadEvent(event, author, emptyList()) + relay?.let { addressableNote.addRelay(it) } + return false + } + /** * Consumes a kind 10002 (NIP-65) advertised relay list event. Stores * the newest per-author copy in [addressableNotes] so the outbox diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e85c610398..1a64224c85 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -85,6 +86,14 @@ class DesktopIAccount( val oldBookmarkState = OldBookmarkListState(signer, localCache, scope) val bookmarkState = BookmarkListState(signer, localCache, scope) + /** + * User's Blossom media server list (NIP-B7 / kind 10063). Loads from the + * same event kind the Amethyst mobile app uses, so a server list configured + * on mobile shows up here too. Backed by [localCache]; populated by the + * account-config subscription in Main.kt. + */ + val blossomServerList = BlossomServerListState(signer, localCache, scope) + val kind3FollowList = Kind3FollowListState( signer, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 19f2a8248e..7a4f369dd4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.desktop.ui.scheduledposts.DraftsAndScheduledSc import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch class ColumnNavigationState { private val _stack = mutableStateListOf() @@ -501,6 +502,22 @@ internal fun RootContent( torSettings = torState.settings, onTorSettingsChanged = torState.onSettingsChanged, namecoinPreferences = LocalNamecoinPreferences.current, + blossomServers = iAccount.blossomServerList.flow, + onBlossomServersChanged = { servers -> + // Local mirror for the upload path + cold start. + com.vitorpamplona.amethyst.desktop.DesktopPreferences.blossomServers = servers + // Publish a kind-10063 event so the list syncs to every + // Amethyst client, then consume it locally so state updates + // immediately (mirrors the NIP-65 save flow above). Read-only + // accounts can't sign, so keep the change local-only there. + if (iAccount.isWriteable()) { + appScope.launch { + val event = iAccount.blossomServerList.saveBlossomServersList(servers) + relayManager.broadcastToAll(event) + localCache.justConsumeMyOwnEvent(event) + } + } + }, ) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt new file mode 100644 index 0000000000..6cdb574b23 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt @@ -0,0 +1,99 @@ +/* + * 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.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * The desktop app must load the user's media server list from the same event + * kind the Amethyst mobile app uses — the NIP-B7 [BlossomServersEvent] + * (kind 10063). These tests verify that an incoming kind-10063 event lands in + * [DesktopLocalCache] and that the shared [BlossomServerListState] surfaces the + * declared servers. + */ +class DesktopBlossomServerListTest { + private val relayUrl = NormalizedRelayUrl("wss://relay.test/") + + private suspend fun signedServerList( + servers: List, + signer: NostrSignerInternal, + createdAt: Long = 1_700_000_000, + ): BlossomServersEvent = BlossomServersEvent.create(servers, signer, createdAt) + + @Test + fun `consume stores an incoming kind 10063 event in the addressable cache`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val servers = listOf("https://blossom.example.com", "https://cdn.example.org") + val event = signedServerList(servers, signer) + + cache.consume(event, relayUrl) + + val stored = cache.getOrCreateAddressableNote(event.address()).event as? BlossomServersEvent + assertNotNull(stored, "kind 10063 event must be stored in the addressable cache") + assertEquals(servers, stored.servers()) + } + + @Test + fun `an older event does not overwrite a newer one`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val newer = signedServerList(listOf("https://new.example.com"), signer, createdAt = 2_000) + val older = signedServerList(listOf("https://old.example.com"), signer, createdAt = 1_000) + + cache.consume(newer, relayUrl) + cache.consume(older, relayUrl) + + val stored = cache.getOrCreateAddressableNote(newer.address()).event as? BlossomServersEvent + assertEquals(listOf("https://new.example.com"), stored?.servers()) + } + + @Test + fun `BlossomServerListState surfaces the servers from the cached event`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val servers = listOf("https://blossom.example.com") + val event = signedServerList(servers, signer) + cache.consume(event, relayUrl) + + val state = + BlossomServerListState( + signer = signer, + cache = cache, + scope = backgroundScope, + ) + + assertEquals(servers, state.getBlossomServersList()?.servers()) + assertEquals(servers, state.flow.first { it.isNotEmpty() }) + } +}