refactor(desktop): read blossom servers from the account, not the cache

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
This commit is contained in:
Claude
2026-07-15 22:51:57 +00:00
parent 1896d77bd9
commit 2eaaa270c3
11 changed files with 64 additions and 80 deletions
@@ -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) {
@@ -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<String> =
(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"
@@ -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<List<String>>? = 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<String>()) })
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 },
)
@@ -1043,6 +1043,7 @@ fun FeedScreen(
relayManager = relayManager,
account = account,
localCache = localCache,
blossomServers = iAccount?.blossomServerList?.flow,
replyTo = replyToEvent,
)
}
@@ -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,
)
}
@@ -122,6 +122,7 @@ fun UserProfileScreen(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
blossomServers: kotlinx.coroutines.flow.StateFlow<List<String>>? = 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 },
@@ -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<List<String>>? = 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<List<String>>?,
) {
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) {
@@ -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,
)
@@ -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,
@@ -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<List<String>>? = 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,
@@ -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))
}
}