mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+90
@@ -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))
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+99
@@ -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() })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user