Merge pull request #3583 from vitorpamplona/claude/blossom-server-loading-sync-n1l3sw

Sync Blossom media server list (kind 10063) across clients
This commit is contained in:
Vitor Pamplona
2026-07-15 20:11:48 -04:00
committed by GitHub
12 changed files with 436 additions and 65 deletions
@@ -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<NoteState> = blossomListNote.flow().metadata.stateFlow
fun getBlossomServersList(): BlossomServersEvent? = blossomListNote.event as? BlossomServersEvent
fun normalizeServers(note: Note): List<String> = (note.event as? BlossomServersEvent)?.servers() ?: emptyList()
val flow: StateFlow<List<String>> =
getBlossomServersListFlow()
.map { normalizeServers(it.note) }
.onStart { emit(normalizeServers(blossomListNote)) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun saveBlossomServersList(servers: List<String>): 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,
)
}
}
}
@@ -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<String>
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
@@ -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<Filter>?,
) {
// 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<Filter>?,
) {
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<String>) -> 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<String>()) })
// 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))
@@ -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
@@ -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"
@@ -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,
@@ -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<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
@@ -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<UploadResult>): List<IMetaTag> =
@Composable
private fun ServerSelector(
servers: List<String>,
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) {
@@ -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<StateFlow<List<String>>?> { null }
@@ -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<List<String>>?,
) {
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) {
@@ -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<DesktopScreen>()
@@ -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)
}
}
},
)
}
@@ -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,
@@ -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<String>,
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() })
}
}