From e322c939cc89faed8990a8d8bd98a275a86deab5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 20:56:57 +0000 Subject: [PATCH 1/5] feat(desktop): load Blossom servers from kind 10063 like mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app read its Blossom media server list only from a local DesktopPreferences string (defaulting to blossom.primal.net) and never looked at the user's NIP-B7 BlossomServersEvent (kind 10063) — the same event the Amethyst mobile app loads via BlossomServerListState. A server list configured on mobile therefore never showed up on desktop. Load the list from the network event instead, mirroring the existing desktop NIP-65 flow: - Add a shared, platform-agnostic BlossomServerListState in commons that reads the kind-10063 addressable event from ICacheProvider and exposes a StateFlow> plus a save helper. - Store incoming kind-10063 events in DesktopLocalCache.route() (consumeBlossomServerList, newest-per-author wins). - Instantiate blossomServerList on DesktopIAccount and subscribe to kind 10063 in the account-config bootstrap subscription. - Mirror the loaded network list into DesktopPreferences so the upload path and cold start reflect it; the network event stays authoritative. - Feed the media-server settings screen from the network list and, on edit, sign+broadcast a new kind-10063 event so changes sync to every Amethyst client (writeable accounts only). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../nipB7Blossom/BlossomServerListState.kt | 90 +++++++++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 43 ++++++-- .../desktop/cache/DesktopLocalCache.kt | 26 +++++ .../amethyst/desktop/model/DesktopIAccount.kt | 9 ++ .../desktop/ui/deck/DeckColumnContainer.kt | 17 ++++ .../cache/DesktopBlossomServerListTest.kt | 99 +++++++++++++++++++ 6 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nipB7Blossom/BlossomServerListState.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt 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() }) + } +} From 1896d77bd921119c44200b2eb9e0d45573148d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:40:54 +0000 Subject: [PATCH 2/5] refactor(desktop): drop the global blossomServers pref, read per-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind-10063 list is already the per-account source of truth in the cache, so the DesktopPreferences.blossomServers singleton was redundant — and being process-global (not per-account) it was also a latent account-switch bug: the mirror could hand one account's media servers to another. Remove it and have every consumer read the account's list from the cache: - Add ICacheProvider.blossomServers(pubKey) / preferredBlossomServer(pubKey) helpers (+ DEFAULT_BLOSSOM_SERVER fallback). - Upload paths read per-account: ComposeNoteDialog (localCache), ChatPane (its cacheProvider), EditProfileDialog (localCache threaded from UserProfileScreen). - Settings screen falls back to the default constant instead of the pref; drop the flow→prefs mirror LaunchedEffect. - Delete DesktopPreferences.blossomServers / preferredBlossomServer. Uploads require network anyway, by which point the account-config subscription has loaded kind 10063, so no local persistence is needed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../amethyst/desktop/DesktopPreferences.kt | 13 ------ .../vitorpamplona/amethyst/desktop/Main.kt | 22 +++------- .../amethyst/desktop/model/BlossomServers.kt | 42 +++++++++++++++++++ .../amethyst/desktop/ui/ComposeNoteDialog.kt | 14 +++++-- .../amethyst/desktop/ui/UserProfileScreen.kt | 1 + .../amethyst/desktop/ui/chats/ChatPane.kt | 4 +- .../desktop/ui/deck/DeckColumnContainer.kt | 4 +- .../desktop/ui/profile/EditProfileScreen.kt | 6 ++- .../cache/DesktopBlossomServerListTest.kt | 21 ++++++++++ 9 files changed, 87 insertions(+), 40 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index df3bb92416..cd96d371e6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -93,19 +93,6 @@ object DesktopPreferences { prefs.put(KEY_PINNED_NAV_ITEMS, value) } - private const val KEY_BLOSSOM_SERVERS = "blossom_servers" - private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" - - var blossomServers: List - get() { - val raw = prefs.get(KEY_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVER) - return if (raw.isBlank()) emptyList() else raw.split(",") - } - set(value) = prefs.put(KEY_BLOSSOM_SERVERS, value.joinToString(",")) - - val preferredBlossomServer: String - get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER - private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed" var sidebarCollapsed: Boolean 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 d4c9671a2d..35692db5fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.auth.DesktopAuthCoordinator import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories @@ -1721,19 +1722,6 @@ 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() } @@ -2237,7 +2225,7 @@ fun RelaySettingsScreen( onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, namecoinPreferences: DesktopNamecoinPreferences? = null, blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, - onBlossomServersChanged: (List) -> Unit = { DesktopPreferences.blossomServers = it }, + onBlossomServersChanged: (List) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -2363,9 +2351,9 @@ fun RelaySettingsScreen( // 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 } + // The kind-10063 list is authoritative; before it loads (or when the + // user has published none) show the default server. + val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } key(effectiveBlossomServers) { MediaServerSettings( initialServers = effectiveBlossomServers, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt new file mode 100644 index 0000000000..348ef66cff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt @@ -0,0 +1,42 @@ +/* + * 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.model + +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +/** Fallback media server used when the account has published no kind-10063 list yet. */ +const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" + +/** + * The account's Blossom media servers (NIP-B7 / kind 10063), read straight from + * the cache. This is the per-account source of truth — the same event the + * account-config subscription loads and the mobile app uses — so upload sites + * read it here instead of a process-global preference. + */ +fun ICacheProvider.blossomServers(pubKeyHex: HexKey): List = + (getOrCreateAddressableNote(BlossomServersEvent.createAddress(pubKeyHex)).event as? BlossomServersEvent) + ?.servers() + .orEmpty() + +/** First declared Blossom server for [pubKeyHex], or [DEFAULT_BLOSSOM_SERVER] when none is set. */ +fun ICacheProvider.preferredBlossomServer(pubKeyHex: HexKey): String = blossomServers(pubKeyHex).firstOrNull() ?: DEFAULT_BLOSSOM_SERVER diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index b98cbada5c..4c38c8f4fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -74,10 +74,11 @@ import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.deleteOrWarn -import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.model.blossomServers import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore import com.vitorpamplona.amethyst.desktop.service.drafts.NoteDraft @@ -205,7 +206,13 @@ fun ComposeNoteDialog( val uploadTracker = remember { DesktopUploadTracker() } val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { UploadOrchestrator() } - var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) } + // Media servers come from the account's kind-10063 list (per-account, loaded + // by the account-config subscription), not a process-global preference. + val blossomServers = + remember(localCache, account) { + localCache?.blossomServers(account.pubKeyHex).orEmpty().ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } + } + var selectedServer by remember { mutableStateOf(blossomServers.first()) } var postAsPicture by remember { mutableStateOf(false) } // Scheduling — when non-null the note is stored for later publication instead of @@ -576,6 +583,7 @@ fun ComposeNoteDialog( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { ServerSelector( + servers = blossomServers, selectedServer = selectedServer, onServerSelected = { selectedServer = it }, ) @@ -826,10 +834,10 @@ private fun buildIMetaTags(results: List): List = @Composable private fun ServerSelector( + servers: List, selectedServer: String, onServerSelected: (String) -> Unit, ) { - val servers = DesktopPreferences.blossomServers if (servers.size <= 1) { // Only one server — just show label, no dropdown Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 8540b85045..2ccec9a7c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -1189,6 +1189,7 @@ fun UserProfileScreen( EditProfileDialog( account = account, relayManager = relayManager, + localCache = localCache, latestMetadata = latestMetadataEvent, latestIdentities = latestIdentitiesEvent, onDismiss = { showEditProfile = false }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index c746d64487..08ace28d94 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -88,7 +88,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel -import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow @@ -882,7 +882,7 @@ private suspend fun sendEncryptedFiles( cacheProvider: ICacheProvider, ) { val orchestrator = UploadOrchestrator() - val server = DesktopPreferences.preferredBlossomServer + val server = cacheProvider.preferredBlossomServer(account.pubKey) val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) }.map { it.toPTag() } for (file in files) { 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 7a4f369dd4..ac1d8b27fd 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 @@ -504,12 +504,10 @@ internal fun RootContent( 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. + // accounts can't sign, so this is a no-op there. if (iAccount.isWriteable()) { appScope.launch { val event = iAccount.blossomServerList.saveBlossomServersList(servers) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 883b01733a..5aba2eb7f4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -79,8 +79,9 @@ import com.vitorpamplona.amethyst.commons.profile.EditProfileFields import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator -import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -124,6 +125,7 @@ sealed class Nip05Status { fun EditProfileDialog( account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, latestMetadata: MetadataEvent?, latestIdentities: ExternalIdentitiesEvent?, onDismiss: () -> Unit, @@ -171,7 +173,7 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = DesktopPreferences.preferredBlossomServer + val serverBaseUrl = localCache.preferredBlossomServer(account.pubKeyHex) fun uploadFile( file: File, 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 index 6cdb574b23..fbdb45791d 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.desktop.cache import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER +import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -96,4 +98,23 @@ class DesktopBlossomServerListTest { assertEquals(servers, state.getBlossomServersList()?.servers()) assertEquals(servers, state.flow.first { it.isNotEmpty() }) } + + @Test + fun `preferredBlossomServer reads the account's first server from cache`() = + runTest { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + val servers = listOf("https://first.example.com", "https://second.example.com") + cache.consume(signedServerList(servers, signer), relayUrl) + + assertEquals("https://first.example.com", cache.preferredBlossomServer(signer.pubKey)) + } + + @Test + fun `preferredBlossomServer falls back to the default when the account has no list`() { + val cache = DesktopLocalCache() + val signer = NostrSignerInternal(KeyPair()) + + assertEquals(DEFAULT_BLOSSOM_SERVER, cache.preferredBlossomServer(signer.pubKey)) + } } From 2eaaa270c3b738080d1bfcf15dd93eab07b82bdd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:51:57 +0000 Subject: [PATCH 3/5] refactor(desktop): read blossom servers from the account, not the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ICacheProvider.blossomServers(pubKey) cache helper with reads straight from the account's own state holder — iAccount.blossomServerList.flow (the shared BlossomServerListState) — threaded to each upload site. This is the reactive, per-account source of truth and drops the cache+pubkey indirection entirely. - Hoist iAccount (with dmSendTracker + accountRelays) out of MainContent into the LoggedIn branch so the top-level compose dialog can read the account's blossom flow too; pass them into MainContent as params. - Thread iAccount.blossomServerList.flow into ComposeNoteDialog (reactive: the server picker updates if the list loads after the dialog opens), EditProfileDialog (via UserProfileScreen), and ChatPane (via DesktopMessagesScreen). - Reduce BlossomServers.kt to just the DEFAULT_BLOSSOM_SERVER fallback used when the account has published no kind-10063 list yet. Known gap: quote-compose opened from a feed row (NoteActionsRow) still defaults to DEFAULT_BLOSSOM_SERVER — the per-note card composables don't carry the account handle, and threading it through the whole note-render tree isn't worth it for that secondary path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../vitorpamplona/amethyst/desktop/Main.kt | 51 ++++++++++--------- .../amethyst/desktop/model/BlossomServers.kt | 23 ++------- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 22 ++++---- .../amethyst/desktop/ui/FeedScreen.kt | 1 + .../amethyst/desktop/ui/NoteActions.kt | 2 + .../amethyst/desktop/ui/UserProfileScreen.kt | 3 +- .../amethyst/desktop/ui/chats/ChatPane.kt | 8 ++- .../desktop/ui/chats/DesktopMessagesScreen.kt | 2 + .../desktop/ui/deck/DeckColumnContainer.kt | 3 ++ .../desktop/ui/profile/EditProfileScreen.kt | 8 +-- .../cache/DesktopBlossomServerListTest.kt | 21 -------- 11 files changed, 64 insertions(+), 80 deletions(-) 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 35692db5fe..b31735110f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1341,6 +1341,25 @@ private fun AppInner( val account = accountState as AccountState.LoggedIn val nwcConnection by accountManager.nwcConnection.collectAsState() + // Account state holders (relay lists, blossom servers, DMs, WoT). + // Hoisted above MainContent so the top-level compose dialog can also + // read the account's blossom server list from iAccount directly. + val dmSendTracker = remember(relayManager) { DmSendTracker(relayManager.client) } + // Created before iAccount so NIP-65 backup can be loaded. + val accountRelays = + remember(account, relayManager, scope) { + DesktopAccountRelays(account.pubKeyHex, relayManager, scope) + } + val iAccount = + remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) + } + // When iAccount is replaced (account switch), close the previous + // WoTService so its writer coroutine + ops Channel don't leak. + DisposableEffect(iAccount) { + onDispose { iAccount.wotService.close() } + } + // Lazy-load Namecoin services. The Core RPC HTTP // client is sourced from the Tor-aware DesktopHttpClient // singleton so .onion RPC URLs route through the @@ -1414,6 +1433,9 @@ private fun AppInner( localCache = localCache, accountManager = accountManager, account = account, + iAccount = iAccount, + accountRelays = accountRelays, + dmSendTracker = dmSendTracker, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, indexRelaysStore = indexRelaysStore, @@ -1456,6 +1478,7 @@ private fun AppInner( relayManager = relayManager, account = account, localCache = localCache, + blossomServers = iAccount.blossomServerList.flow, replyTo = replyToNote, draftDTag = composeEditDraftTag, draftInitialContent = composeEditContent, @@ -1544,6 +1567,9 @@ fun MainContent( localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, + iAccount: DesktopIAccount, + accountRelays: DesktopAccountRelays, + dmSendTracker: DmSendTracker, nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays, @@ -1563,31 +1589,6 @@ fun MainContent( val signerConnectionState by accountManager.signerConnectionState.collectAsState() val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState() - // DM infrastructure — hoisted here so it survives screen navigation - val dmSendTracker = - remember(relayManager) { - DmSendTracker(relayManager.client) - } - // Centralized relay state for all categories (DM, search, blocked, NIP-65 persistence) - // Created before iAccount so NIP-65 backup can be loaded - val accountRelays = - remember(account, relayManager, scope) { - DesktopAccountRelays(account.pubKeyHex, relayManager, scope) - } - - val iAccount = - remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) - } - - // When iAccount is replaced (account switch), the previous WoTService's - // internal writer coroutine + ops Channel would otherwise leak — the - // outer `scope` lives for the whole session. Close the previous - // instance on dispose so account-switch is a clean teardown. - DisposableEffect(iAccount) { - onDispose { iAccount.wotService.close() } - } - // Follow Packs state — single per-account holder for Discover + sidebar + naddr cards val followPacksState = remember(iAccount, localCache, relayManager, scope) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt index 348ef66cff..d3a319be66 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt @@ -20,23 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.model -import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent - -/** Fallback media server used when the account has published no kind-10063 list yet. */ -const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" - /** - * The account's Blossom media servers (NIP-B7 / kind 10063), read straight from - * the cache. This is the per-account source of truth — the same event the - * account-config subscription loads and the mobile app uses — so upload sites - * read it here instead of a process-global preference. + * Fallback media server used when the account has published no kind-10063 + * Blossom server list yet. The list itself is read reactively from the account's + * [com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState] + * (`iAccount.blossomServerList.flow`); this is only the empty-list default. */ -fun ICacheProvider.blossomServers(pubKeyHex: HexKey): List = - (getOrCreateAddressableNote(BlossomServersEvent.createAddress(pubKeyHex)).event as? BlossomServersEvent) - ?.servers() - .orEmpty() - -/** First declared Blossom server for [pubKeyHex], or [DEFAULT_BLOSSOM_SERVER] when none is set. */ -fun ICacheProvider.preferredBlossomServer(pubKeyHex: HexKey): String = blossomServers(pubKeyHex).firstOrNull() ?: DEFAULT_BLOSSOM_SERVER +const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 4c38c8f4fe..189437556e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount -import com.vitorpamplona.amethyst.desktop.model.blossomServers import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore import com.vitorpamplona.amethyst.desktop.service.drafts.NoteDraft @@ -119,6 +118,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.datatransfer.DataFlavor @@ -140,6 +140,7 @@ fun ComposeNoteDialog( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null, + blossomServers: StateFlow>? = null, replyTo: Event? = null, quoteOf: Event? = null, draftDTag: String? = null, @@ -206,13 +207,16 @@ fun ComposeNoteDialog( val uploadTracker = remember { DesktopUploadTracker() } val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { UploadOrchestrator() } - // Media servers come from the account's kind-10063 list (per-account, loaded - // by the account-config subscription), not a process-global preference. - val blossomServers = - remember(localCache, account) { - localCache?.blossomServers(account.pubKeyHex).orEmpty().ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } - } - var selectedServer by remember { mutableStateOf(blossomServers.first()) } + // Media servers come straight from the account's kind-10063 list holder + // (account.blossomServerList.flow), reactively — no cache-poking, no prefs. + val serverList by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + val effectiveServers = serverList.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } + var selectedServer by remember { mutableStateOf(effectiveServers.first()) } + // If the list loads (or changes) after the dialog opens and the current pick + // is no longer in it, snap to the first available server. + LaunchedEffect(effectiveServers) { + if (selectedServer !in effectiveServers) selectedServer = effectiveServers.first() + } var postAsPicture by remember { mutableStateOf(false) } // Scheduling — when non-null the note is stored for later publication instead of @@ -583,7 +587,7 @@ fun ComposeNoteDialog( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { ServerSelector( - servers = blossomServers, + servers = effectiveServers, selectedServer = selectedServer, onServerSelected = { selectedServer = it }, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 1eace52ec5..096bf03a59 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1043,6 +1043,7 @@ fun FeedScreen( relayManager = relayManager, account = account, localCache = localCache, + blossomServers = iAccount?.blossomServerList?.flow, replyTo = replyToEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 7a52fb1d08..1c51828dd5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1376,6 +1376,8 @@ fun NoteActionsRow( relayManager = relayManager, account = account, localCache = localCache, + // Quote-compose from a feed row falls back to the default media server: + // the per-note card composables don't carry the account's blossom flow. quoteOf = quoteEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 2ccec9a7c9..50274077bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -122,6 +122,7 @@ fun UserProfileScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn?, + blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, @@ -1189,7 +1190,7 @@ fun UserProfileScreen( EditProfileDialog( account = account, relayManager = relayManager, - localCache = localCache, + blossomServers = blossomServers, latestMetadata = latestMetadataEvent, latestIdentities = latestIdentitiesEvent, onDismiss = { showEditProfile = false }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 08ace28d94..091ef90023 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -88,7 +88,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel -import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow @@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.awt.datatransfer.DataFlavor import java.awt.dnd.DnDConstants @@ -135,6 +136,7 @@ fun ChatPane( cacheProvider: ICacheProvider, feedViewModel: ChatroomFeedViewModel, messageState: ChatNewMessageState, + blossomServers: StateFlow>? = null, dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, onBack: (() -> Unit)? = null, @@ -379,6 +381,7 @@ fun ChatPane( roomKey = roomKey, account = account, cacheProvider = cacheProvider, + blossomServers = blossomServers, ) attachedFiles.clear() } catch (e: Exception) { @@ -880,9 +883,10 @@ private suspend fun sendEncryptedFiles( roomKey: ChatroomKey, account: IAccount, cacheProvider: ICacheProvider, + blossomServers: StateFlow>?, ) { val orchestrator = UploadOrchestrator() - val server = cacheProvider.preferredBlossomServer(account.pubKey) + val server = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) }.map { it.toPTag() } for (file in files) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 8823c6197f..c5cd49e850 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -271,6 +271,7 @@ private fun CompactMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, + blossomServers = (account as? DesktopIAccount)?.blossomServerList?.flow, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, onBack = { listState.clearSelection() }, @@ -375,6 +376,7 @@ private fun SplitMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, + blossomServers = (account as? DesktopIAccount)?.blossomServerList?.flow, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, ) 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 ac1d8b27fd..44f61806af 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 @@ -472,6 +472,7 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + blossomServers = iAccount.blossomServerList.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = {}, @@ -553,6 +554,7 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + blossomServers = iAccount.blossomServerList.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = {}, @@ -705,6 +707,7 @@ internal fun OverlayContent( relayManager = relayManager, localCache = localCache, account = account, + blossomServers = iAccount?.blossomServerList?.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = onBack, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 5aba2eb7f4..affca46175 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -80,8 +80,7 @@ import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.desktop.account.AccountState -import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache -import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer +import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -93,6 +92,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.mapNotNull @@ -125,7 +125,7 @@ sealed class Nip05Status { fun EditProfileDialog( account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, - localCache: DesktopLocalCache, + blossomServers: StateFlow>? = null, latestMetadata: MetadataEvent?, latestIdentities: ExternalIdentitiesEvent?, onDismiss: () -> Unit, @@ -173,7 +173,7 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = localCache.preferredBlossomServer(account.pubKeyHex) + val serverBaseUrl = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, 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 index fbdb45791d..6cdb574b23 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.desktop.cache import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState -import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER -import com.vitorpamplona.amethyst.desktop.model.preferredBlossomServer import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -98,23 +96,4 @@ class DesktopBlossomServerListTest { assertEquals(servers, state.getBlossomServersList()?.servers()) assertEquals(servers, state.flow.first { it.isNotEmpty() }) } - - @Test - fun `preferredBlossomServer reads the account's first server from cache`() = - runTest { - val cache = DesktopLocalCache() - val signer = NostrSignerInternal(KeyPair()) - val servers = listOf("https://first.example.com", "https://second.example.com") - cache.consume(signedServerList(servers, signer), relayUrl) - - assertEquals("https://first.example.com", cache.preferredBlossomServer(signer.pubKey)) - } - - @Test - fun `preferredBlossomServer falls back to the default when the account has no list`() { - val cache = DesktopLocalCache() - val signer = NostrSignerInternal(KeyPair()) - - assertEquals(DEFAULT_BLOSSOM_SERVER, cache.preferredBlossomServer(signer.pubKey)) - } } From f727c226c0af178cd00bb15111129839ca20918a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:44:49 +0000 Subject: [PATCH 4/5] refactor(desktop): provide blossom servers via LocalBlossomServers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the threaded blossomServers parameters with a LocalBlossomServers CompositionLocal, provided once from the account's state holder (iAccount.blossomServerList.flow) around the logged-in UI. Upload sites read the list from context instead of receiving it down a parameter chain. - Provide LocalBlossomServers in MainContent's existing provider (covers feeds, chats, profile, settings) and around the top-level compose dialog. - ComposeNoteDialog, EditProfileDialog, ChatPane and the media-server settings section read LocalBlossomServers.current; drop the params and the prop-drilling through UserProfileScreen and DesktopMessagesScreen. - This also covers quote-compose from a feed row (NoteActionsRow), which the parameter approach couldn't reach — the per-note card composables now get the list from context, so it no longer defaults to the primal server. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../vitorpamplona/amethyst/desktop/Main.kt | 33 ++++++++++-------- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 7 ++-- .../amethyst/desktop/ui/FeedScreen.kt | 1 - .../desktop/ui/LocalBlossomServers.kt | 34 +++++++++++++++++++ .../amethyst/desktop/ui/NoteActions.kt | 2 -- .../amethyst/desktop/ui/UserProfileScreen.kt | 2 -- .../amethyst/desktop/ui/chats/ChatPane.kt | 4 ++- .../desktop/ui/chats/DesktopMessagesScreen.kt | 2 -- .../desktop/ui/deck/DeckColumnContainer.kt | 4 --- .../desktop/ui/profile/EditProfileScreen.kt | 5 ++- 10 files changed, 61 insertions(+), 33 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt 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 b31735110f..ec885a40b6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -112,6 +112,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen import com.vitorpamplona.amethyst.desktop.ui.ImportFollowListDialog +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog @@ -1471,19 +1472,23 @@ private fun AppInner( } } - // Compose dialog + // Compose dialog. Hosted outside MainContent's provider, + // so provide the account's blossom list here too. if (showComposeDialog) { - ComposeNoteDialog( - onDismiss = onDismissComposeDialog, - relayManager = relayManager, - account = account, - localCache = localCache, - blossomServers = iAccount.blossomServerList.flow, - replyTo = replyToNote, - draftDTag = composeEditDraftTag, - draftInitialContent = composeEditContent, - initialScheduledForSec = composeEditScheduledForSec, - ) + CompositionLocalProvider( + LocalBlossomServers provides iAccount.blossomServerList.flow, + ) { + ComposeNoteDialog( + onDismiss = onDismissComposeDialog, + relayManager = relayManager, + account = account, + localCache = localCache, + replyTo = replyToNote, + draftDTag = composeEditDraftTag, + draftInitialContent = composeEditContent, + initialScheduledForSec = composeEditScheduledForSec, + ) + } } // App Drawer overlay @@ -1972,6 +1977,7 @@ fun MainContent( CompositionLocalProvider( LocalRelayCategories provides relayCategories, + LocalBlossomServers provides iAccount.blossomServerList.flow, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, @@ -2225,7 +2231,6 @@ 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 = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -2351,7 +2356,7 @@ fun RelaySettingsScreen( Spacer(Modifier.height(24.dp)) // Media Server Settings (Blossom, kind 10063 — synced with mobile) - val networkBlossomServers by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + val networkBlossomServers by (LocalBlossomServers.current?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) // The kind-10063 list is authoritative; before it loads (or when the // user has published none) show the default server. val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 189437556e..9eedb59385 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -118,7 +118,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.datatransfer.DataFlavor @@ -140,7 +139,6 @@ fun ComposeNoteDialog( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null, - blossomServers: StateFlow>? = null, replyTo: Event? = null, quoteOf: Event? = null, draftDTag: String? = null, @@ -208,8 +206,9 @@ fun ComposeNoteDialog( val uploadState by uploadTracker.state.collectAsState() val orchestrator = remember { UploadOrchestrator() } // Media servers come straight from the account's kind-10063 list holder - // (account.blossomServerList.flow), reactively — no cache-poking, no prefs. - val serverList by (blossomServers?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) + // (account.blossomServerList.flow), provided via LocalBlossomServers — + // reactively, no cache-poking, no prefs. + val serverList by (LocalBlossomServers.current?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) val effectiveServers = serverList.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) } var selectedServer by remember { mutableStateOf(effectiveServers.first()) } // If the list loads (or changes) after the dialog opens and the current pick diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 096bf03a59..1eace52ec5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1043,7 +1043,6 @@ fun FeedScreen( relayManager = relayManager, account = account, localCache = localCache, - blossomServers = iAccount?.blossomServerList?.flow, replyTo = replyToEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt new file mode 100644 index 0000000000..2cb0aab846 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalBlossomServers.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.StateFlow + +/** + * The logged-in account's Blossom media server list (NIP-B7 / kind 10063), + * exposed as the reactive flow from the account's `BlossomServerListState` + * (`iAccount.blossomServerList.flow`). Provided once around the logged-in UI so + * upload composables read the list from context instead of threading it through + * every screen. `null` when no account is logged in — consumers fall back to + * [com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER]. + */ +val LocalBlossomServers = staticCompositionLocalOf>?> { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 1c51828dd5..7a52fb1d08 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1376,8 +1376,6 @@ fun NoteActionsRow( relayManager = relayManager, account = account, localCache = localCache, - // Quote-compose from a feed row falls back to the default media server: - // the per-note card composables don't carry the account's blossom flow. quoteOf = quoteEvent, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 50274077bb..8540b85045 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -122,7 +122,6 @@ fun UserProfileScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn?, - blossomServers: kotlinx.coroutines.flow.StateFlow>? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, @@ -1190,7 +1189,6 @@ fun UserProfileScreen( EditProfileDialog( account = account, relayManager = relayManager, - blossomServers = blossomServers, latestMetadata = latestMetadataEvent, latestIdentities = latestIdentitiesEvent, onDismiss = { showEditProfile = false }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 091ef90023..3720ed4a64 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow @@ -136,13 +137,14 @@ fun ChatPane( cacheProvider: ICacheProvider, feedViewModel: ChatroomFeedViewModel, messageState: ChatNewMessageState, - blossomServers: StateFlow>? = null, dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() + // Account's Blossom media servers (kind 10063), read from context. + val blossomServers = LocalBlossomServers.current val feedState by feedViewModel.feedState.feedContent.collectAsState() val messageText by messageState.message.collectAsState() val recipientsMissingRelays by messageState.recipientsMissingDmRelays.collectAsState() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index c5cd49e850..8823c6197f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -271,7 +271,6 @@ private fun CompactMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, - blossomServers = (account as? DesktopIAccount)?.blossomServerList?.flow, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, onBack = { listState.clearSelection() }, @@ -376,7 +375,6 @@ private fun SplitMessagesContent( cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, - blossomServers = (account as? DesktopIAccount)?.blossomServerList?.flow, dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, ) 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 44f61806af..9503e2e0a8 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 @@ -472,7 +472,6 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, - blossomServers = iAccount.blossomServerList.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = {}, @@ -503,7 +502,6 @@ internal fun RootContent( torSettings = torState.settings, onTorSettingsChanged = torState.onSettingsChanged, namecoinPreferences = LocalNamecoinPreferences.current, - blossomServers = iAccount.blossomServerList.flow, onBlossomServersChanged = { servers -> // Publish a kind-10063 event so the list syncs to every // Amethyst client, then consume it locally so state updates @@ -554,7 +552,6 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, - blossomServers = iAccount.blossomServerList.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = {}, @@ -707,7 +704,6 @@ internal fun OverlayContent( relayManager = relayManager, localCache = localCache, account = account, - blossomServers = iAccount?.blossomServerList?.flow, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = onBack, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index affca46175..3a9b606d44 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.model.DEFAULT_BLOSSOM_SERVER import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client @@ -92,7 +93,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.mapNotNull @@ -125,7 +125,6 @@ sealed class Nip05Status { fun EditProfileDialog( account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, - blossomServers: StateFlow>? = null, latestMetadata: MetadataEvent?, latestIdentities: ExternalIdentitiesEvent?, onDismiss: () -> Unit, @@ -173,7 +172,7 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + val serverBaseUrl = LocalBlossomServers.current?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, From 152fc76bc09b9526d057961b487f062ee306ff3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:03:38 +0000 Subject: [PATCH 5/5] fix(desktop): fetch account config (incl. blossom) from outbox relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account-config bootstrap subscription only queried the default relays, so a user's Blossom server list (kind 10063) — published to their own write relays, not the defaults — was never fetched, and the UI fell back to the default server. NIP-65 relay lists hid the same gap because they're broadcast widely and also have a local backup. Add a subscription that re-fetches the account-config kinds (10002/10050/ 10007/10006/10063) from the user's NIP-65 outbox (write + untagged relays) once it's known, routing kind 10063 / 10002 through justConsumeMyOwnEvent like the bootstrap does. This matches mobile's outbox model: the user's own data comes from their write relays. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2 --- .../vitorpamplona/amethyst/desktop/Main.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) 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 ec885a40b6..ff86c0acb4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1728,6 +1728,52 @@ fun MainContent( onDispose { relayManager.unsubscribe(bootstrapSubId) } } + // The bootstrap subscription above only reaches the default relays. A user's + // own account data — Blossom server list (kind 10063), DM/search/blocked + // relay lists — is published to their write relays, not the defaults, so it + // won't be found there. Re-fetch those kinds from the NIP-65 outbox + // (write + untagged relays) whenever it becomes known. + LaunchedEffect(iAccount) { + iAccount.nip65RelayList.outboxFlow.collect { outbox -> + if (outbox.isEmpty()) return@collect + relayManager.subscribe( + subId = "account-config-outbox", + filters = + listOf( + Filter( + kinds = + listOf( + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + BlockedRelayListEvent.KIND, + BlossomServersEvent.KIND, + ), + authors = listOf(account.pubKeyHex), + limit = 10, + ), + ), + relays = outbox, + listener = + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is AdvertisedRelayListEvent || event is BlossomServersEvent) { + scope.launch(Dispatchers.IO) { + localCache.justConsumeMyOwnEvent(event) + } + } + accountRelays.consumeIfRelevant(event) + } + }, + ) + } + } + // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() }