feat(desktop): load Blossom servers from kind 10063 like mobile

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<List<String>> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
This commit is contained in:
Claude
2026-07-15 20:56:57 +00:00
parent 000d01c44e
commit e322c939cc
6 changed files with 275 additions and 9 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,
)
}
}
}
@@ -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<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 +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<List<String>>? = null,
onBlossomServersChanged: (List<String>) -> 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<String>()) })
// 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))
@@ -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
@@ -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,
@@ -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,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)
}
}
},
)
}
@@ -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() })
}
}