mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
+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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-13
@@ -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))
|
||||
|
||||
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
|
||||
|
||||
+29
@@ -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"
|
||||
+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,
|
||||
|
||||
+14
-3
@@ -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) {
|
||||
|
||||
+34
@@ -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 }
|
||||
+8
-2
@@ -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) {
|
||||
|
||||
+14
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+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