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/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 90c52eeffe..ff86c0acb4 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 @@ -111,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 @@ -148,6 +150,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 @@ -1339,6 +1342,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 @@ -1412,6 +1434,9 @@ private fun AppInner( localCache = localCache, accountManager = accountManager, account = account, + iAccount = iAccount, + accountRelays = accountRelays, + dmSendTracker = dmSendTracker, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, indexRelaysStore = indexRelaysStore, @@ -1447,18 +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, - 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 @@ -1542,6 +1572,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, @@ -1561,31 +1594,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) { @@ -1687,9 +1695,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 +1711,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 +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() } @@ -1966,6 +2023,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, @@ -2219,6 +2277,7 @@ fun RelaySettingsScreen( .TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF), onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, namecoinPreferences: DesktopNamecoinPreferences? = null, + onBlossomServersChanged: (List) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -2342,11 +2401,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 (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) } + 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/BlossomServers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt new file mode 100644 index 0000000000..d3a319be66 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/BlossomServers.kt @@ -0,0 +1,29 @@ +/* + * 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 + +/** + * 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. + */ +const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net" 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/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index b98cbada5c..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 @@ -74,9 +74,9 @@ 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.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore @@ -205,7 +205,17 @@ 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 straight from the account's kind-10063 list holder + // (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 + // 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 @@ -576,6 +586,7 @@ fun ComposeNoteDialog( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { ServerSelector( + servers = effectiveServers, selectedServer = selectedServer, onServerSelected = { selectedServer = it }, ) @@ -826,10 +837,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/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/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index c746d64487..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 @@ -88,7 +88,8 @@ 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.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 @@ -101,6 +102,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 @@ -141,6 +143,8 @@ fun ChatPane( 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() @@ -379,6 +383,7 @@ fun ChatPane( roomKey = roomKey, account = account, cacheProvider = cacheProvider, + blossomServers = blossomServers, ) attachedFiles.clear() } catch (e: Exception) { @@ -880,9 +885,10 @@ private suspend fun sendEncryptedFiles( roomKey: ChatroomKey, account: IAccount, cacheProvider: ICacheProvider, + blossomServers: StateFlow>?, ) { val orchestrator = UploadOrchestrator() - val server = DesktopPreferences.preferredBlossomServer + 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/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 19f2a8248e..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 @@ -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,19 @@ internal fun RootContent( torSettings = torState.settings, onTorSettingsChanged = torState.onSettingsChanged, namecoinPreferences = LocalNamecoinPreferences.current, + onBlossomServersChanged = { 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 this is a no-op there. + if (iAccount.isWriteable()) { + appScope.launch { + val event = iAccount.blossomServerList.saveBlossomServersList(servers) + relayManager.broadcastToAll(event) + localCache.justConsumeMyOwnEvent(event) + } + } + }, ) } 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..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 @@ -79,9 +79,10 @@ 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.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 @@ -171,7 +172,7 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = DesktopPreferences.preferredBlossomServer + val serverBaseUrl = LocalBlossomServers.current?.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 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() }) + } +}