feat(desktop): relay config persistence, correct counts, per-screen picker
- Persist relay list events (kinds 10050/10007/10006) as JSON to java.util.prefs.Preferences with per-account key isolation - Load persisted relay configs on startup before bootstrap subscription - Validate loaded events (kind + pubkey check), 8KB guard on writes - Fix SearchScreen relay count: "0 of 1" not "0 of 7" — uses searchRelays - Fix FeedScreen relay count: shows feed relay count, not all connected - Per-screen relay picker dialogs: Dns icon on Feed and Search screens opens AlertDialog wrapping existing editors (Nip65RelayEditor, SearchRelayEditor) — no new composable files - Fix created_at dedup: use >= for replaceable event semantics - Fix setters: use TimeUtils.now() not Long.MAX_VALUE - Add consumePublishedEvent() for local immediate update after publish - Remove stale "not loaded" warnings from Search/Blocked editors - Fix FeedHeader type: Set<NormalizedRelayUrl> not Set<Any> - Fix picker LaunchedEffect(Unit) to not overwrite user edits Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -916,17 +916,18 @@ fun MainContent(
|
||||
remember(relayManager) {
|
||||
DmSendTracker(relayManager.client)
|
||||
}
|
||||
val iAccount =
|
||||
remember(account, localCache, relayManager, dmSendTracker) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
|
||||
}
|
||||
|
||||
// Centralized relay state for all categories (DM, search, blocked)
|
||||
// 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) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays)
|
||||
}
|
||||
|
||||
// Aggregated relay categories (feed, notifications, search, DM)
|
||||
val relayCategories =
|
||||
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
|
||||
@@ -978,9 +979,14 @@ fun MainContent(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
// Route through localCache for NIP-65 sync
|
||||
localCache.consume(event, relay)
|
||||
// Route to accountRelays for kinds without cache-backed state
|
||||
// NIP-65 (kind 10002) must go through justConsumeMyOwnEvent
|
||||
// because localCache.consume() doesn't handle addressable events
|
||||
if (event is AdvertisedRelayListEvent) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
localCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
// Route to accountRelays for persistence + state updates
|
||||
accountRelays.consumeIfRelevant(event)
|
||||
}
|
||||
},
|
||||
@@ -1092,6 +1098,7 @@ fun MainContent(
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalRelayCategories provides relayCategories,
|
||||
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
|
||||
Vendored
+11
-1
@@ -545,7 +545,17 @@ class DesktopLocalCache : ICacheProvider {
|
||||
// ----- Own event consumption -----
|
||||
|
||||
override fun justConsumeMyOwnEvent(event: Event): Boolean {
|
||||
// Desktop doesn't track own events separately
|
||||
// For addressable/replaceable events, store in the addressable note cache
|
||||
// so state holders (Nip65RelayListState, etc.) pick it up via their flows
|
||||
if (event is com.vitorpamplona.quartz.nip01Core.core.AddressableEvent) {
|
||||
val address = event.address()
|
||||
val note = getOrCreateAddressableNote(address)
|
||||
val author = getOrCreateUser(event.pubKey) ?: return false
|
||||
if (note.event == null || (note.event?.createdAt ?: 0) <= event.createdAt) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+123
-16
@@ -24,14 +24,18 @@ import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* Manages relay state for a desktop account.
|
||||
@@ -54,6 +58,8 @@ class DesktopAccountRelays(
|
||||
relayManager: RelayConnectionManager,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
private val prefs = Preferences.userNodeForPackage(DesktopAccountRelays::class.java)
|
||||
|
||||
/** User-configured DM relays from kind 10050 events */
|
||||
private val _dmRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelayList.asStateFlow()
|
||||
@@ -79,10 +85,65 @@ class DesktopAccountRelays(
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
/** Routes kind 10050 to DM relay state. Use consumeIfRelevant() for external callers. */
|
||||
private fun consumeDmRelayList(event: ChatMessageRelayListEvent) {
|
||||
if (event.pubKey != userPubKeyHex) return
|
||||
_dmRelayList.value = event.relays().toSet()
|
||||
init {
|
||||
loadFromPersistence()
|
||||
}
|
||||
|
||||
private fun prefsKey(kind: Int) = "relay_${kind}_${userPubKeyHex.take(16)}"
|
||||
|
||||
private fun saveEvent(
|
||||
kind: Int,
|
||||
event: Event,
|
||||
) {
|
||||
try {
|
||||
val json = event.toJson()
|
||||
if (json.length > MAX_PREFS_VALUE_LENGTH) return // Preferences 8KB limit
|
||||
prefs.put(prefsKey(kind), json)
|
||||
} catch (_: Exception) {
|
||||
// Best-effort persistence
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_PREFS_VALUE_LENGTH = 8000 // java.util.prefs limit is 8192
|
||||
}
|
||||
|
||||
private fun loadEvent(kind: Int): Event? =
|
||||
try {
|
||||
val json = prefs.get(prefsKey(kind), null) ?: return null
|
||||
val event = Event.fromJson(json)
|
||||
if (event.kind == kind && event.pubKey == userPubKeyHex) event else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun loadFromPersistence() {
|
||||
// Load DM relays from event or URL cache
|
||||
val dmEvent = loadEvent(ChatMessageRelayListEvent.KIND)
|
||||
if (dmEvent is ChatMessageRelayListEvent) {
|
||||
_dmRelayList.value = dmEvent.relays().toSet()
|
||||
lastDmCreatedAt.set(dmEvent.createdAt)
|
||||
} else {
|
||||
loadRelayUrls("dm").let { if (it.isNotEmpty()) _dmRelayList.value = it }
|
||||
}
|
||||
|
||||
// Load search relays from event or URL cache
|
||||
val searchEvent = loadEvent(SearchRelayListEvent.KIND)
|
||||
if (searchEvent is SearchRelayListEvent) {
|
||||
val relays = searchEvent.publicRelays().toSet()
|
||||
if (relays.isNotEmpty()) {
|
||||
_searchRelayList.value = relays
|
||||
lastSearchCreatedAt.set(searchEvent.createdAt)
|
||||
} else {
|
||||
// Public tags empty — try URL cache (NIP-51 private tags can't be decrypted here)
|
||||
loadRelayUrls("search").let { if (it.isNotEmpty()) _searchRelayList.value = it }
|
||||
}
|
||||
} else {
|
||||
loadRelayUrls("search").let { if (it.isNotEmpty()) _searchRelayList.value = it }
|
||||
}
|
||||
|
||||
// Load blocked relays — always use URL cache (private tags can't be decrypted synchronously)
|
||||
loadRelayUrls("blocked").let { if (it.isNotEmpty()) _blockedRelayList.value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,26 +154,42 @@ class DesktopAccountRelays(
|
||||
fun consumeIfRelevant(event: Event): Boolean {
|
||||
if (event.pubKey != userPubKeyHex) return false
|
||||
return when (event.kind) {
|
||||
AdvertisedRelayListEvent.KIND -> {
|
||||
// Persist NIP-65 event for restart survival (state managed by Nip65RelayListState)
|
||||
saveEvent(event.kind, event)
|
||||
true
|
||||
}
|
||||
|
||||
ChatMessageRelayListEvent.KIND -> {
|
||||
if (event is ChatMessageRelayListEvent && event.createdAt > lastDmCreatedAt.get()) {
|
||||
if (event is ChatMessageRelayListEvent && event.createdAt >= lastDmCreatedAt.get()) {
|
||||
lastDmCreatedAt.set(event.createdAt)
|
||||
_dmRelayList.value = event.relays().toSet()
|
||||
val relays = event.relays().toSet()
|
||||
_dmRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
saveRelayUrls("dm", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
SearchRelayListEvent.KIND -> {
|
||||
if (event is SearchRelayListEvent && event.createdAt > lastSearchCreatedAt.get()) {
|
||||
if (event is SearchRelayListEvent && event.createdAt >= lastSearchCreatedAt.get()) {
|
||||
lastSearchCreatedAt.set(event.createdAt)
|
||||
_searchRelayList.value = event.publicRelays().toSet()
|
||||
val relays = event.publicRelays().toSet()
|
||||
_searchRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
saveRelayUrls("search", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
BlockedRelayListEvent.KIND -> {
|
||||
if (event is BlockedRelayListEvent && event.createdAt > lastBlockedCreatedAt.get()) {
|
||||
if (event is BlockedRelayListEvent && event.createdAt >= lastBlockedCreatedAt.get()) {
|
||||
lastBlockedCreatedAt.set(event.createdAt)
|
||||
_blockedRelayList.value = event.publicRelays().toSet()
|
||||
// publicRelays() may be empty for NIP-51 private-tag events
|
||||
val relays = event.publicRelays().toSet()
|
||||
_blockedRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
if (relays.isNotEmpty()) saveRelayUrls("blocked", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -123,21 +200,51 @@ class DesktopAccountRelays(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually sets DM relays (e.g., from saved preferences).
|
||||
*/
|
||||
/** Called after publishing a relay list event from the UI — updates local state + persists */
|
||||
fun consumePublishedEvent(event: Event) {
|
||||
consumeIfRelevant(event)
|
||||
}
|
||||
|
||||
/** Load persisted NIP-65 event for Nip65RelayListState backup */
|
||||
fun loadPersistedNip65Event(): AdvertisedRelayListEvent? {
|
||||
val event = loadEvent(AdvertisedRelayListEvent.KIND)
|
||||
return event as? AdvertisedRelayListEvent
|
||||
}
|
||||
|
||||
fun setDmRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastDmCreatedAt.set(Long.MAX_VALUE)
|
||||
lastDmCreatedAt.set(TimeUtils.now())
|
||||
_dmRelayList.value = relays
|
||||
saveRelayUrls("dm", relays)
|
||||
}
|
||||
|
||||
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastSearchCreatedAt.set(Long.MAX_VALUE)
|
||||
lastSearchCreatedAt.set(TimeUtils.now())
|
||||
_searchRelayList.value = relays
|
||||
saveRelayUrls("search", relays)
|
||||
}
|
||||
|
||||
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastBlockedCreatedAt.set(Long.MAX_VALUE)
|
||||
lastBlockedCreatedAt.set(TimeUtils.now())
|
||||
_blockedRelayList.value = relays
|
||||
saveRelayUrls("blocked", relays)
|
||||
}
|
||||
|
||||
private fun saveRelayUrls(
|
||||
category: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
try {
|
||||
val key = "urls_${category}_${userPubKeyHex.take(16)}"
|
||||
prefs.put(key, relays.joinToString(",") { it.url })
|
||||
prefs.flush()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRelayUrls(category: String): Set<NormalizedRelayUrl> {
|
||||
val key = "urls_${category}_${userPubKeyHex.take(16)}"
|
||||
val csv = prefs.get(key, "") ?: return emptySet()
|
||||
if (csv.isBlank()) return emptySet()
|
||||
return csv.split(",").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -70,6 +70,7 @@ class DesktopIAccount(
|
||||
private val relayManager: RelayConnectionManager,
|
||||
val dmSendTracker: DmSendTracker,
|
||||
private val scope: CoroutineScope,
|
||||
private val accountRelays: DesktopAccountRelays? = null,
|
||||
) : IAccount {
|
||||
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
||||
|
||||
@@ -98,9 +99,12 @@ class DesktopIAccount(
|
||||
localCache,
|
||||
scope,
|
||||
object : Nip65RelayListRepository {
|
||||
override val backupNIP65RelayList: AdvertisedRelayListEvent? = null
|
||||
override val backupNIP65RelayList: AdvertisedRelayListEvent? =
|
||||
accountRelays?.loadPersistedNip65Event()
|
||||
|
||||
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) { /* no persistence yet */ }
|
||||
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) {
|
||||
accountRelays?.consumePublishedEvent(event)
|
||||
}
|
||||
|
||||
override val defaultOutboxRelays = relayManager.connectedRelays.value
|
||||
override val defaultInboxRelays = relayManager.connectedRelays.value
|
||||
|
||||
+23
-16
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.desktop.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -31,65 +32,71 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Aggregates relay categories for desktop subscriptions.
|
||||
*
|
||||
* Each category combines user-configured relays with fallbacks and subtracts blocked relays.
|
||||
* Debounced to prevent subscription thrashing at startup.
|
||||
* Uses DefaultRelays.RELAYS as the stateIn initial value — NEVER empty.
|
||||
* Debounced 300ms to prevent subscription thrashing at startup.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
class DesktopRelayCategories(
|
||||
nip65State: Nip65RelayListState,
|
||||
accountRelays: DesktopAccountRelays,
|
||||
/** Reactive connected relay set — used as fallback when NIP-65 is empty */
|
||||
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
/** NIP-65 outbox (write) relays, falls back to connected relays, minus blocked */
|
||||
/** Default relays — ALWAYS populated, used as stateIn initial value */
|
||||
private val defaultRelays: Set<NormalizedRelayUrl> =
|
||||
DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
|
||||
/** Feed relays: NIP-65 outbox → connected → defaultRelays, minus blocked */
|
||||
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
nip65State.outboxFlow,
|
||||
connectedRelays,
|
||||
accountRelays.blockedRelayList,
|
||||
) { outbox, connected, blocked ->
|
||||
(outbox.ifEmpty { connected }) - blocked
|
||||
}.debounce(1.seconds)
|
||||
(outbox.ifEmpty { connected.ifEmpty { defaultRelays } }) - blocked
|
||||
}.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays) // NEVER empty
|
||||
|
||||
/** NIP-65 inbox (read) relays, falls back to connected relays, minus blocked */
|
||||
/** Notification relays: NIP-65 inbox → connected → defaultRelays, minus blocked */
|
||||
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
nip65State.inboxFlow,
|
||||
connectedRelays,
|
||||
accountRelays.blockedRelayList,
|
||||
) { inbox, connected, blocked ->
|
||||
(inbox.ifEmpty { connected }) - blocked
|
||||
}.debounce(1.seconds)
|
||||
(inbox.ifEmpty { connected.ifEmpty { defaultRelays } }) - blocked
|
||||
}.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||
|
||||
/** Search relays (kind 10007), falls back to relay.nostr.band, minus blocked */
|
||||
/** Search relays: kind 10007 → relay.nostr.band, minus blocked */
|
||||
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
accountRelays.searchRelayList,
|
||||
accountRelays.blockedRelayList,
|
||||
) { search, blocked ->
|
||||
(search.ifEmpty { DEFAULT_SEARCH_RELAYS }) - blocked
|
||||
}.debounce(1.seconds)
|
||||
}.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
|
||||
|
||||
/** DM relays — aggregated DM state minus blocked */
|
||||
/** DM relays: kind 10050 → defaultRelays, minus blocked */
|
||||
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
accountRelays.dmRelays.flow,
|
||||
accountRelays.blockedRelayList,
|
||||
) { dm, blocked -> dm - blocked }
|
||||
.debounce(1.seconds)
|
||||
) { dm, blocked ->
|
||||
(dm.ifEmpty { defaultRelays }) - blocked
|
||||
}.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, accountRelays.dmRelays.flow.value)
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||
|
||||
companion object {
|
||||
val DEFAULT_SEARCH_RELAYS =
|
||||
|
||||
@@ -38,13 +38,16 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -82,6 +85,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
@@ -89,6 +93,10 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LightboxState(
|
||||
val urls: List<String>,
|
||||
@@ -262,6 +270,7 @@ fun FeedScreen(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn? = null,
|
||||
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
|
||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
initialFeedMode: FeedMode? = null,
|
||||
@@ -284,6 +293,7 @@ fun FeedScreen(
|
||||
|
||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
var showRelayPicker by remember { mutableStateOf(false) }
|
||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
||||
|
||||
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
||||
@@ -488,7 +498,7 @@ fun FeedScreen(
|
||||
FeedHeader(
|
||||
feedMode = feedMode,
|
||||
account = account,
|
||||
connectedRelays = connectedRelays,
|
||||
feedRelays = feedRelays,
|
||||
followedUsersCount = followedUsers.size,
|
||||
onFeedModeChange = { mode ->
|
||||
feedMode = mode
|
||||
@@ -497,6 +507,7 @@ fun FeedScreen(
|
||||
onRefresh = { relayManager.connect() },
|
||||
onCompose = onCompose,
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
onOpenRelayPicker = { showRelayPicker = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -579,6 +590,33 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Feed relay picker dialog
|
||||
if (showRelayPicker && account != null && iAccount != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRelayPicker = false },
|
||||
title = { Text("Feed Relays (NIP-65)") },
|
||||
text = {
|
||||
Nip65RelayEditor(
|
||||
nip65State = iAccount.nip65RelayList,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
// Update local NIP-65 state immediately via addressable note cache
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
localCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showRelayPicker = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Lightbox overlay
|
||||
lightboxState?.let { state ->
|
||||
LightboxOverlay(
|
||||
@@ -600,12 +638,13 @@ fun FeedScreen(
|
||||
private fun FeedHeader(
|
||||
feedMode: FeedMode,
|
||||
account: AccountState.LoggedIn?,
|
||||
connectedRelays: Set<Any>,
|
||||
feedRelays: Set<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>,
|
||||
followedUsersCount: Int,
|
||||
onFeedModeChange: (FeedMode) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
onOpenRelayPicker: () -> Unit = {},
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
@@ -642,7 +681,7 @@ private fun FeedHeader(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
"${feedRelays.size} relays",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
@@ -656,6 +695,20 @@ private fun FeedHeader(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (account != null && !account.isReadOnly) {
|
||||
IconButton(
|
||||
onClick = onOpenRelayPicker,
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "Edit Feed Relays",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp),
|
||||
|
||||
+49
-1
@@ -44,12 +44,14 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -64,6 +66,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -91,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchResult
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
|
||||
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
@@ -100,7 +104,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscripti
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||
@@ -112,6 +118,7 @@ fun SearchScreen(
|
||||
localCache: DesktopLocalCache,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
account: AccountState.LoggedIn? = null,
|
||||
initialQuery: String = "",
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
@@ -119,8 +126,10 @@ fun SearchScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val accountRelays = LocalAccountRelays.current
|
||||
val state = remember { AdvancedSearchBarState(scope) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var showRelayPicker by remember { mutableStateOf(false) }
|
||||
|
||||
// Pre-fill initial query
|
||||
LaunchedEffect(initialQuery) {
|
||||
@@ -163,7 +172,7 @@ fun SearchScreen(
|
||||
LaunchedEffect(debouncedQuery) {
|
||||
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
||||
state.clearResults()
|
||||
state.initRelayStates(allRelayUrls)
|
||||
state.initRelayStates(searchRelays)
|
||||
if (shouldSearchPeople) {
|
||||
state.startSearching("people-search")
|
||||
}
|
||||
@@ -385,6 +394,15 @@ fun SearchScreen(
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
if (account != null && !account.isReadOnly) {
|
||||
IconButton(onClick = { showRelayPicker = true }) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "Search Relays",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { state.togglePanel() }) {
|
||||
Icon(
|
||||
Icons.Default.Tune,
|
||||
@@ -399,6 +417,36 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Search relay picker dialog
|
||||
if (showRelayPicker && account != null) {
|
||||
val pickerRelays =
|
||||
remember {
|
||||
mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
|
||||
it.addAll(searchRelays)
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRelayPicker = false },
|
||||
title = { Text("Search Relays") },
|
||||
text = {
|
||||
SearchRelayEditor(
|
||||
localRelays = pickerRelays,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
accountRelays?.consumePublishedEvent(event)
|
||||
accountRelays?.setSearchRelays(pickerRelays.toSet())
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showRelayPicker = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Expandable advanced panel
|
||||
AnimatedVisibility(
|
||||
visible = panelExpanded,
|
||||
|
||||
+13
@@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -91,6 +92,7 @@ fun ConversationListPane(
|
||||
selectedRoom: ChatroomKey?,
|
||||
onConversationSelected: (ChatroomKey) -> Unit,
|
||||
onNewConversation: () -> Unit = {},
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -179,6 +181,17 @@ fun ConversationListPane(
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onShowRelayPicker,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "DM Relays",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onNewConversation,
|
||||
modifier = Modifier.size(32.dp),
|
||||
|
||||
+43
@@ -85,6 +85,8 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||
var showDmRelayPicker by remember { mutableStateOf(false) }
|
||||
val listState =
|
||||
remember(account) {
|
||||
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
||||
@@ -110,6 +112,11 @@ fun DesktopMessagesScreen(
|
||||
true
|
||||
}
|
||||
|
||||
event.key == Key.R && isModifier && event.isShiftPressed -> {
|
||||
showDmRelayPicker = true
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
@@ -126,6 +133,7 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
@@ -138,6 +146,7 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
}
|
||||
@@ -154,6 +163,36 @@ fun DesktopMessagesScreen(
|
||||
onDismiss = { showNewDmDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showDmRelayPicker && accountRelays != null) {
|
||||
val pickerRelays =
|
||||
remember {
|
||||
androidx.compose.runtime.mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
|
||||
it.addAll(accountRelays.dmRelayList.value.sortedBy { r -> r.url })
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showDmRelayPicker = false },
|
||||
title = { androidx.compose.material3.Text("DM Relays") },
|
||||
text = {
|
||||
com.vitorpamplona.amethyst.desktop.ui.relay.DmRelayEditor(
|
||||
dmRelays = accountRelays.dmRelayList,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
accountRelays.consumePublishedEvent(event)
|
||||
accountRelays.setDmRelays(pickerRelays.toSet())
|
||||
},
|
||||
onDmRelaysUpdated = { accountRelays.setDmRelays(it) },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showDmRelayPicker = false }) {
|
||||
androidx.compose.material3.Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +209,7 @@ private fun CompactMessagesContent(
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
onShowNewDm: () -> Unit,
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
@@ -208,6 +248,7 @@ private fun CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
onShowRelayPicker = onShowRelayPicker,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -229,6 +270,7 @@ private fun SplitMessagesContent(
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
onShowNewDm: () -> Unit,
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
@@ -237,6 +279,7 @@ private fun SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
onShowRelayPicker = onShowRelayPicker,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.width(280.dp),
|
||||
)
|
||||
|
||||
+6
-9
@@ -217,6 +217,7 @@ internal fun RootContent(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
initialFeedMode = FeedMode.FOLLOWING,
|
||||
@@ -248,6 +249,7 @@ internal fun RootContent(
|
||||
localCache = localCache,
|
||||
relayManager = relayManager,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
account = account,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
)
|
||||
@@ -284,6 +286,7 @@ internal fun RootContent(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
initialFeedMode = FeedMode.GLOBAL,
|
||||
@@ -334,19 +337,12 @@ internal fun RootContent(
|
||||
}
|
||||
|
||||
DeckColumnType.Relays -> {
|
||||
val accountRelays =
|
||||
remember(iAccount, relayManager, scope) {
|
||||
com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays(
|
||||
iAccount.pubKey,
|
||||
relayManager,
|
||||
scope,
|
||||
)
|
||||
}
|
||||
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||
RelayDashboardScreen(
|
||||
relayManager = relayManager,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
nip65State = iAccount.nip65RelayList,
|
||||
accountRelays = accountRelays,
|
||||
accountRelays = accountRelays ?: return,
|
||||
signer = iAccount.signer,
|
||||
onPublish = { event -> relayManager.broadcastToAll(event) },
|
||||
)
|
||||
@@ -427,6 +423,7 @@ internal fun RootContent(
|
||||
localCache = localCache,
|
||||
relayManager = relayManager,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
account = account,
|
||||
initialQuery = "#${columnType.tag}",
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
|
||||
-7
@@ -80,13 +80,6 @@ fun BlockedRelayEditor(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
Text(
|
||||
"Existing blocked relay list is not loaded yet — saving will publish a new list.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Add relay input
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
+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.ui.relay
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
||||
|
||||
val LocalAccountRelays =
|
||||
staticCompositionLocalOf<DesktopAccountRelays?> {
|
||||
null
|
||||
}
|
||||
+13
-4
@@ -90,11 +90,19 @@ fun Nip65RelayEditor(
|
||||
}
|
||||
|
||||
LaunchedEffect(currentNip65Relays) {
|
||||
localRelays.clear()
|
||||
localRelays.addAll(currentNip65Relays)
|
||||
if (currentNip65Relays.isNotEmpty()) {
|
||||
localRelays.clear()
|
||||
localRelays.addAll(currentNip65Relays)
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
// Delay showing empty state to allow async cache load
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(500)
|
||||
if (!loaded) loaded = true
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
if (loaded && localRelays.isEmpty()) {
|
||||
Text(
|
||||
@@ -272,9 +280,10 @@ private fun tryAddNip65Relay(
|
||||
url: String,
|
||||
existing: MutableList<AdvertisedRelayInfo>,
|
||||
): String? {
|
||||
val error = validateRelayUrl(url)
|
||||
val input = normalizeRelayInput(url)
|
||||
val error = validateRelayUrl(input)
|
||||
if (error != null) return error
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())!!
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(input)!!
|
||||
if (existing.any { it.relayUrl.url == normalized.url }) {
|
||||
return "Relay already added"
|
||||
}
|
||||
|
||||
+22
-3
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RelayConfigTab(
|
||||
@@ -99,7 +100,16 @@ fun RelayConfigTab(
|
||||
Nip65RelayEditor(
|
||||
nip65State = nip65State,
|
||||
signer = signer,
|
||||
onPublish = onPublish,
|
||||
onPublish = { event ->
|
||||
onPublish(event)
|
||||
// Consume locally so nip65State updates immediately
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
nip65State.cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
// Also persist relay event for restart survival
|
||||
accountRelays.consumePublishedEvent(event)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,7 +138,11 @@ fun RelayConfigTab(
|
||||
SearchRelayEditor(
|
||||
localRelays = searchRelayState,
|
||||
signer = signer,
|
||||
onPublish = onPublish,
|
||||
onPublish = { event ->
|
||||
onPublish(event)
|
||||
accountRelays.consumePublishedEvent(event)
|
||||
accountRelays.setSearchRelays(searchRelayState.toSet())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,7 +156,12 @@ fun RelayConfigTab(
|
||||
BlockedRelayEditor(
|
||||
localRelays = blockedRelayState,
|
||||
signer = signer,
|
||||
onPublish = onPublish,
|
||||
onPublish = { event ->
|
||||
onPublish(event)
|
||||
// Don't call consumePublishedEvent — blocked relays use private tags,
|
||||
// publicRelays() returns empty and would overwrite the correct value
|
||||
accountRelays.setBlockedRelays(blockedRelayState.toSet())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -27,6 +27,8 @@ import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -62,8 +64,19 @@ fun RelayDashboardScreen(
|
||||
var selectedTab by remember { mutableStateOf(DashboardTab.MONITOR) }
|
||||
|
||||
// Hoisted state — survives Monitor ↔ Configure tab switches
|
||||
// Synced from accountRelays flows (persistence + bootstrap + per-screen picker)
|
||||
val searchRelayState = remember { mutableStateListOf<NormalizedRelayUrl>() }
|
||||
val blockedRelayState = remember { mutableStateListOf<NormalizedRelayUrl>() }
|
||||
val currentSearchRelays by accountRelays.searchRelayList.collectAsState()
|
||||
val currentBlockedRelays by accountRelays.blockedRelayList.collectAsState()
|
||||
LaunchedEffect(currentSearchRelays) {
|
||||
searchRelayState.clear()
|
||||
searchRelayState.addAll(currentSearchRelays.sortedBy { it.url })
|
||||
}
|
||||
LaunchedEffect(currentBlockedRelays) {
|
||||
blockedRelayState.clear()
|
||||
blockedRelayState.addAll(currentBlockedRelays.sortedBy { it.url })
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
PrimaryTabRow(selectedTabIndex = DashboardTab.entries.indexOf(selectedTab)) {
|
||||
|
||||
+4
-3
@@ -159,12 +159,13 @@ private fun tryAddRelay(
|
||||
existing: List<NormalizedRelayUrl>,
|
||||
onAdd: (String) -> NormalizedRelayUrl?,
|
||||
): String? {
|
||||
val error = validateRelayUrl(url)
|
||||
val input = normalizeRelayInput(url)
|
||||
val error = validateRelayUrl(input)
|
||||
if (error != null) return error
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) ?: return "Invalid relay URL"
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(input) ?: return "Invalid relay URL"
|
||||
if (existing.any { it.url == normalized.url }) {
|
||||
return "Relay already added"
|
||||
}
|
||||
onAdd(url.trim())
|
||||
onAdd(input)
|
||||
return null
|
||||
}
|
||||
|
||||
+26
-12
@@ -24,21 +24,34 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
/**
|
||||
* Validates and adds a relay URL to a mutable list.
|
||||
* Returns an error message string if validation fails, null on success.
|
||||
* Normalizes a relay URL input — auto-prefixes wss:// if no scheme given.
|
||||
* Returns the normalized URL string ready for validation.
|
||||
*/
|
||||
internal fun normalizeRelayInput(url: String): String {
|
||||
val trimmed = url.trim()
|
||||
return when {
|
||||
trimmed.startsWith("wss://") || trimmed.startsWith("ws://") -> trimmed
|
||||
trimmed.contains(".onion") -> "ws://$trimmed"
|
||||
trimmed.contains(".") -> "wss://$trimmed"
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a relay URL. Returns error message or null on success.
|
||||
* Auto-prefixes wss:// if no scheme given (e.g., "nos.lol" → "wss://nos.lol").
|
||||
*/
|
||||
internal fun validateRelayUrl(url: String): String? {
|
||||
val trimmed = url.trim()
|
||||
if (trimmed.isBlank()) return "Enter a relay URL"
|
||||
if (!trimmed.startsWith("wss://") && !trimmed.startsWith("ws://")) {
|
||||
return "URL must start with wss:// or ws://"
|
||||
val input = normalizeRelayInput(url)
|
||||
if (input.isBlank()) return "Enter a relay URL"
|
||||
if (!input.startsWith("wss://") && !input.startsWith("ws://")) {
|
||||
return "Invalid relay URL"
|
||||
}
|
||||
if (trimmed.startsWith("ws://") && !trimmed.contains(".onion")) {
|
||||
if (input.startsWith("ws://") && !input.contains(".onion")) {
|
||||
return "Use wss:// — unencrypted ws:// exposes traffic to observers"
|
||||
}
|
||||
// Must have a domain with at least one dot (foo is not a valid relay)
|
||||
val host =
|
||||
trimmed
|
||||
input
|
||||
.removePrefix("wss://")
|
||||
.removePrefix("ws://")
|
||||
.split("/")
|
||||
@@ -46,7 +59,7 @@ internal fun validateRelayUrl(url: String): String? {
|
||||
if (!host.contains(".")) {
|
||||
return "Invalid domain — must contain at least one dot (e.g., relay.example.com)"
|
||||
}
|
||||
if (RelayUrlNormalizer.normalizeOrNull(trimmed) == null) {
|
||||
if (RelayUrlNormalizer.normalizeOrNull(input) == null) {
|
||||
return "Invalid relay URL"
|
||||
}
|
||||
return null
|
||||
@@ -56,9 +69,10 @@ internal fun tryAddSimpleRelay(
|
||||
url: String,
|
||||
existing: MutableList<NormalizedRelayUrl>,
|
||||
): String? {
|
||||
val error = validateRelayUrl(url)
|
||||
val input = normalizeRelayInput(url)
|
||||
val error = validateRelayUrl(input)
|
||||
if (error != null) return error
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())!!
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(input)!!
|
||||
if (existing.any { it.url == normalized.url }) {
|
||||
return "Relay already added"
|
||||
}
|
||||
|
||||
+12
-7
@@ -36,6 +36,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -80,13 +81,6 @@ fun SearchRelayEditor(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
Text(
|
||||
"Existing search relay list is not loaded yet — saving will publish a new list.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Add relay input
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -194,6 +188,17 @@ fun SearchRelayEditor(
|
||||
Text("Save")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
localRelays.clear()
|
||||
com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories.DEFAULT_SEARCH_RELAYS.let {
|
||||
localRelays.addAll(it)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Reset to defaults")
|
||||
}
|
||||
|
||||
savedMessage?.let {
|
||||
Text(
|
||||
it,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
title: "feat: Relay Config Persistence, Correct Counts, Per-Screen Picker"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
origin: docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: Relay Config Persistence, Correct Counts, Per-Screen Picker
|
||||
|
||||
## Overview
|
||||
|
||||
Three fixes to make relay management work end-to-end: persist config across restarts, show correct per-category relay counts, and add inline relay editing per screen.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
1. **Config lost on restart/reopen**: Search/DM/blocked relay lists vanish — no Preferences persistence, NIP-51 private tags not decrypted
|
||||
2. **Wrong relay counts**: Search shows "0 of 7 relays responded" against all connected relays, not the 1 configured search relay
|
||||
3. **No per-screen editing**: Must navigate to full Dashboard to change which relays a feature uses
|
||||
|
||||
(see brainstorm: `docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md`)
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Design Decisions (from brainstorm)
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Persistence format | Raw event JSON per kind | Preserves `created_at` for dedup, matches Android `backupXxxRelayList` pattern |
|
||||
| Preferences keys | `relay_<kind>_<pubkey-prefix>` | Per-account isolation, avoids key collision |
|
||||
| NIP-51 decryption | Decrypt lazily when signer available, persist encrypted | Don't leak private relay info to disk |
|
||||
| Relay count source | Category-specific `searchRelays.size`, `feedRelays.size` | Not `allRelayUrls.size` |
|
||||
| Picker type | Subscribe-FROM picker (changes which relays screen uses) | Different from compose picker (publish-TO) |
|
||||
| NIP-65 picker | Read/write toggles in expandable form | Other categories are simple add/remove |
|
||||
| Publish on save | Immediately with loading/error state | User confirmed |
|
||||
| Connection dots | Yes | User confirmed |
|
||||
|
||||
### Phase 1: Persistence + NIP-51 Decryption
|
||||
|
||||
**`DesktopRelayListPersistence.kt`** (new):
|
||||
|
||||
```kotlin
|
||||
object DesktopRelayListPersistence {
|
||||
private val prefs = Preferences.userNodeForPackage(DesktopRelayListPersistence::class.java)
|
||||
|
||||
fun saveEvent(kind: Int, pubKeyHex: String, event: Event) {
|
||||
prefs.put(key(kind, pubKeyHex), event.toJson())
|
||||
}
|
||||
|
||||
fun loadEvent(kind: Int, pubKeyHex: String): Event? {
|
||||
val json = prefs.get(key(kind, pubKeyHex), "")
|
||||
if (json.isBlank()) return null
|
||||
return try { Event.fromJson(json) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun key(kind: Int, pubKeyHex: String) = "relay_${kind}_${pubKeyHex.take(8)}"
|
||||
}
|
||||
```
|
||||
|
||||
**`DesktopAccountRelays.kt`** changes:
|
||||
- Accept `NostrSigner` in constructor
|
||||
- Accept `scope: CoroutineScope` for async decryption
|
||||
- `consumeIfRelevant` becomes `suspend` — calls `event.relays(signer)` for NIP-51 kinds
|
||||
- On every state change → `DesktopRelayListPersistence.saveEvent(kind, pubKeyHex, event)`
|
||||
- `loadFromPersistence()` method — loads events, decrypts NIP-51 in coroutine
|
||||
- Call `loadFromPersistence()` in `init {}` block
|
||||
|
||||
**`Main.kt`** changes:
|
||||
- Pass `signer` to `DesktopAccountRelays` constructor
|
||||
- Bootstrap `LaunchedEffect` launches `consumeIfRelevant` in coroutine (now suspend)
|
||||
|
||||
### Phase 2: Correct Per-Screen Relay Counts
|
||||
|
||||
**`SearchScreen.kt`** changes:
|
||||
- Replace `state.initRelayStates(allRelayUrls)` with `state.initRelayStates(searchRelays)`
|
||||
- `searchRelays` already from `LocalRelayCategories.current.searchRelays.collectAsState()`
|
||||
- Banner shows "0 of 1 relays responded" when 1 search relay configured
|
||||
|
||||
**`FeedScreen.kt`** changes:
|
||||
- Replace `"${connectedRelays.size} relays connected"` with `"${feedRelays.size} feed relays"`
|
||||
- `feedRelays` already from `LocalRelayCategories.current.feedRelays.collectAsState()`
|
||||
|
||||
**`AdvancedSearchBarState.kt`** changes:
|
||||
- `initRelayStates` takes `Set<NormalizedRelayUrl>` instead of `Set<Any>`
|
||||
|
||||
### Phase 3: Per-Screen Relay Picker Dialog
|
||||
|
||||
**`RelayPickerDialog.kt`** (new):
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayPickerDialog(
|
||||
title: String, // "Search Relays", "Feed Relays", etc.
|
||||
currentRelays: List<NormalizedRelayUrl>, // or List<AdvertisedRelayInfo> for NIP-65
|
||||
connectedRelays: Set<NormalizedRelayUrl>,
|
||||
signer: NostrSigner,
|
||||
isNip65: Boolean = false, // show read/write toggles
|
||||
onSave: suspend (List<NormalizedRelayUrl>) -> Event, // returns signed event
|
||||
onPublish: (Event) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
)
|
||||
```
|
||||
|
||||
UI:
|
||||
- Modal dialog with category title
|
||||
- Relay list with connection status dots (green/gray)
|
||||
- Add relay input with validation
|
||||
- NIP-65 mode: expandable read/write/both toggles per relay
|
||||
- Save button with loading spinner + error text
|
||||
- Flow: Save → `isLoading = true` → `onSave()` → `onPublish()` → persist → `isLoading = false` → `onDismiss()`
|
||||
|
||||
**Screen integration — relay icon buttons:**
|
||||
|
||||
| Screen | Location | Category | Picker type |
|
||||
|--------|----------|----------|-------------|
|
||||
| SearchScreen | Next to search bar | Search relays | Simple add/remove |
|
||||
| FeedScreen | Next to "X feed relays" text | NIP-65 outbox | Read/write toggles |
|
||||
| DM screen | Header area | DM relays | Simple add/remove |
|
||||
|
||||
Each screen:
|
||||
```kotlin
|
||||
var showRelayPicker by remember { mutableStateOf(false) }
|
||||
// Relay icon button
|
||||
IconButton(onClick = { showRelayPicker = true }) { Icon(Icons.Default.Dns, ...) }
|
||||
// Dialog
|
||||
if (showRelayPicker) {
|
||||
RelayPickerDialog(
|
||||
title = "Search Relays",
|
||||
currentRelays = searchRelays.toList(),
|
||||
connectedRelays = connectedRelays,
|
||||
signer = signer,
|
||||
onSave = { relays -> SearchRelayListEvent.create(relays, signer) },
|
||||
onPublish = { event -> relayManager.broadcastToAll(event); accountRelays.setSearchRelays(relays) },
|
||||
onDismiss = { showRelayPicker = false },
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `DesktopRelayListPersistence.kt` | NEW: save/load event JSON per kind per account |
|
||||
| `DesktopAccountRelays.kt` | Add signer, suspend consumeIfRelevant, persistence, NIP-51 decrypt |
|
||||
| `RelayPickerDialog.kt` | NEW: modal per-screen relay editor with loading/error |
|
||||
| `SearchScreen.kt` | Fix relay count init, add relay picker icon |
|
||||
| `FeedScreen.kt` | Fix relay count text, add relay picker icon |
|
||||
| `Main.kt` | Pass signer to accountRelays, update bootstrap for suspend |
|
||||
| `AdvancedSearchBarState.kt` | `initRelayStates` takes typed Set |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Phase 1: Persistence
|
||||
- [ ] Relay configs survive app restart (save to Preferences, load on startup)
|
||||
- [ ] NIP-51 events decrypted via signer when available
|
||||
- [ ] Bootstrap overwrites persisted data only if newer (`created_at`)
|
||||
- [ ] Per-account key isolation (pubkey prefix)
|
||||
- [ ] Graceful fallback on corrupt/missing persistence data
|
||||
|
||||
### Phase 2: Relay Counts
|
||||
- [ ] SearchScreen shows "X of Y" against search relay set size, not all connected
|
||||
- [ ] FeedScreen shows feed relay count, not connected relay count
|
||||
- [ ] Counts update when relay set changes
|
||||
|
||||
### Phase 3: Per-Screen Picker
|
||||
- [ ] Relay icon on SearchScreen, FeedScreen, DM screen opens picker dialog
|
||||
- [ ] Picker shows current category relays with connection status dots
|
||||
- [ ] Add/remove with validation (domain check, wss:// required)
|
||||
- [ ] NIP-65 picker has expandable read/write/both toggles
|
||||
- [ ] Save publishes immediately with loading spinner
|
||||
- [ ] Error handling: signing failure, publish failure shown in dialog
|
||||
- [ ] Screen resubscribes after picker save
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Preferences 8KB limit per key | Relay list JSON is typically <2KB — safe. Monitor for large lists |
|
||||
| NIP-46 signer timeout on decrypt | Persist encrypted event, decrypt lazily. Show relays as "loading..." |
|
||||
| Picker save echoes back via bootstrap | Dedup by `created_at` — same or newer event is no-op |
|
||||
| `consumeIfRelevant` now suspend | Bootstrap already runs in coroutine scope |
|
||||
|
||||
## Sources
|
||||
|
||||
- **Origin brainstorm:** [docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md](docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md) — Key decisions: persist raw event JSON, decrypt NIP-51 lazily, publish immediately from picker
|
||||
- Android persistence pattern: `amethyst/LocalPreferences.kt` lines 113-128
|
||||
- Android AccountSettings: `amethyst/model/AccountSettings.kt` lines 196-202
|
||||
- Desktop Preferences pattern: `desktopApp/.../DesktopPreferences.kt`
|
||||
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: "feat: DesktopRelayConfig — Single Source of Truth for All Relay Categories"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-23
|
||||
---
|
||||
|
||||
# feat: DesktopRelayConfig — Single Source of Truth for All Relay Categories
|
||||
|
||||
## Problem
|
||||
|
||||
The current relay wiring is broken. Multiple classes (`DesktopRelayCategories`, `DesktopAccountRelays`, `RelayConnectionManager`) each hold parts of relay state, leading to race conditions, empty initial values, and screens using inconsistent relay sources.
|
||||
|
||||
**Symptoms:**
|
||||
- Feed shows "0 feed relays" on startup (race: `allConfiguredRelays` empty at `DesktopRelayCategories` construction)
|
||||
- NIP-65 relay changes don't propagate to feed subscriptions
|
||||
- Different screens use different relay sources (`relayStatuses.keys` vs `connectedRelays` vs `feedRelays` vs `allRelayUrls`)
|
||||
- No screen resubscribes when relay config changes
|
||||
|
||||
## Root Cause
|
||||
|
||||
| Issue | Why |
|
||||
|-------|-----|
|
||||
| `feedRelays` starts empty | `stateIn(Eagerly, allConfiguredRelays.value)` captures empty snapshot at construction time |
|
||||
| Screens use inconsistent sources | FeedScreen uses `feedRelays`, NotificationsScreen uses `relayStatuses.keys`, ReadsScreen uses `relayStatuses.keys` |
|
||||
| No reconnection on config change | `rememberSubscription` keys don't include relay category flows |
|
||||
| Coordinator `indexRelays` is static | Passed at construction, never updated |
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
**One class: `DesktopRelayConfig`** that:
|
||||
1. Holds all relay category sets as reactive `StateFlow`s
|
||||
2. Is initialized from `DefaultRelays.RELAYS` immediately (never empty)
|
||||
3. Updates when NIP-65/DM/search/blocked events arrive
|
||||
4. Persists to Preferences
|
||||
5. Every screen reads from this single source
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Delete/Replace
|
||||
|
||||
| Remove | Replace With |
|
||||
|--------|-------------|
|
||||
| `DesktopRelayCategories.kt` | `DesktopRelayConfig.kt` |
|
||||
| `DesktopAccountRelays.kt` | Merged into `DesktopRelayConfig` |
|
||||
| `DesktopDmRelayState.kt` | Merged into `DesktopRelayConfig` |
|
||||
| `LocalRelayCategories` CompositionLocal | `LocalRelayConfig` |
|
||||
| `LocalAccountRelays` CompositionLocal | Removed (merged into `LocalRelayConfig`) |
|
||||
|
||||
### `DesktopRelayConfig.kt` — The One Class
|
||||
|
||||
```kotlin
|
||||
class DesktopRelayConfig(
|
||||
val userPubKeyHex: HexKey,
|
||||
private val relayManager: RelayConnectionManager,
|
||||
private val nip65State: Nip65RelayListState,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val prefs = Preferences.userNodeForPackage(DesktopRelayConfig::class.java)
|
||||
|
||||
// === Raw category state (updated by events + persistence + UI) ===
|
||||
|
||||
private val _dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
private val _searchRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(DEFAULT_SEARCH_RELAYS)
|
||||
private val _blockedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
// === Derived relay sets for subscriptions ===
|
||||
|
||||
/** Default relays — always populated from DefaultRelays.RELAYS, never empty */
|
||||
private val defaultRelays: Set<NormalizedRelayUrl> =
|
||||
DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
|
||||
/** Feed relays: NIP-65 outbox → fallback to defaultRelays, minus blocked */
|
||||
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||
nip65State.outboxFlow,
|
||||
_blockedRelays,
|
||||
) { outbox, blocked ->
|
||||
(outbox.ifEmpty { defaultRelays }) - blocked
|
||||
}.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays) // NEVER empty
|
||||
|
||||
/** Notification relays: NIP-65 inbox → fallback to defaultRelays, minus blocked */
|
||||
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||
nip65State.inboxFlow,
|
||||
_blockedRelays,
|
||||
) { inbox, blocked ->
|
||||
(inbox.ifEmpty { defaultRelays }) - blocked
|
||||
}.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||
|
||||
/** Search relays: kind 10007 → fallback to relay.nostr.band, minus blocked */
|
||||
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||
_searchRelays,
|
||||
_blockedRelays,
|
||||
) { search, blocked ->
|
||||
search - blocked
|
||||
}.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
|
||||
|
||||
/** DM relays: kind 10050 → fallback to defaultRelays */
|
||||
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||
_dmRelays,
|
||||
_blockedRelays,
|
||||
) { dm, blocked ->
|
||||
(dm.ifEmpty { defaultRelays }) - blocked
|
||||
}.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||
|
||||
/** Blocked relays (public read-only) */
|
||||
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelays.asStateFlow()
|
||||
|
||||
// Also expose raw lists for editors
|
||||
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelays.asStateFlow()
|
||||
val searchRelayList: StateFlow<Set<NormalizedRelayUrl>> = _searchRelays.asStateFlow()
|
||||
val blockedRelayList: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelays.asStateFlow()
|
||||
|
||||
init { loadFromPersistence() }
|
||||
|
||||
// === Persistence (relay URLs as CSV) ===
|
||||
|
||||
fun setDmRelays(relays: Set<NormalizedRelayUrl>) { _dmRelays.value = relays; save("dm", relays) }
|
||||
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) { _searchRelays.value = relays; save("search", relays) }
|
||||
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) { _blockedRelays.value = relays; save("blocked", relays) }
|
||||
|
||||
// === Event consumption (from bootstrap + relay subscriptions) ===
|
||||
|
||||
fun consumeEvent(event: Event) { ... } // routes by kind, checks created_at
|
||||
|
||||
companion object {
|
||||
val DEFAULT_SEARCH_RELAYS = setOfNotNull(RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key design difference from current code:** `defaultRelays` is a `val` computed once from `DefaultRelays.RELAYS` — it's NEVER empty. The `stateIn` initial value is `defaultRelays`, not a snapshot of some flow that might not be populated yet.
|
||||
|
||||
### Screen Updates
|
||||
|
||||
Every screen uses `LocalRelayConfig.current` to get the right relay set:
|
||||
|
||||
| Screen | Current Source | New Source | Change |
|
||||
|--------|---------------|------------|--------|
|
||||
| **FeedScreen** (feed sub) | `feedRelays` from DesktopRelayCategories | `relayConfig.feedRelays` | Same concept, but initial value is never empty |
|
||||
| **FeedScreen** (contact list) | `allRelayUrls = relayStatuses.keys` | `relayConfig.feedRelays` | Unified source |
|
||||
| **FeedScreen** (metadata) | `allRelayUrls` | `relayConfig.feedRelays` | Unified |
|
||||
| **FeedScreen** (interactions) | `relayStatuses.value.keys` snapshot | `relayConfig.feedRelays` | Fix snapshot issue |
|
||||
| **SearchScreen** | `searchRelays` from categories | `relayConfig.searchRelays` | Same concept |
|
||||
| **NotificationsScreen** | `relayStatuses.keys` | `relayConfig.notificationRelays` | Now uses NIP-65 inbox |
|
||||
| **ReadsScreen** | `relayStatuses.keys` | `relayConfig.feedRelays` | Unified with feed |
|
||||
| **BookmarksScreen** | `relayStatuses.keys` | `relayConfig.feedRelays` | Unified |
|
||||
| **DM subscriptions** | Hardcoded empty | `relayConfig.dmRelays` | Actually works now |
|
||||
|
||||
### Subscription Reactivity
|
||||
|
||||
`rememberSubscription` already rekeys when its key params change. Each screen uses `relayConfig.feedRelays.collectAsState()` as a key — when the StateFlow emits a new set (e.g., after NIP-65 update), the subscription teardowns and recreates with the new relay set. This is already how it works; the fix is just making the initial value non-empty.
|
||||
|
||||
### Coordinator Fix
|
||||
|
||||
`DesktopRelaySubscriptionsCoordinator.indexRelays` should use `relayConfig.feedRelays.value` at construction. Or better: make it a `var` so it can be updated:
|
||||
|
||||
```kotlin
|
||||
class DesktopRelaySubscriptionsCoordinator(
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
var indexRelays: Set<NormalizedRelayUrl>, // var, not val
|
||||
private val localCache: DesktopLocalCache,
|
||||
)
|
||||
```
|
||||
|
||||
Update it when relay config changes:
|
||||
```kotlin
|
||||
LaunchedEffect(relayConfig.feedRelays) {
|
||||
relayConfig.feedRelays.collect { relays ->
|
||||
subscriptionsCoordinator.indexRelays = relays
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create `DesktopRelayConfig.kt` merging AccountRelays + DmRelayState + RelayCategories
|
||||
2. Create `LocalRelayConfig` CompositionLocal, remove `LocalRelayCategories` + `LocalAccountRelays`
|
||||
3. Provide in Main.kt, remove old classes
|
||||
4. Update every screen to use `LocalRelayConfig.current`
|
||||
5. Make coordinator `indexRelays` mutable + reactive
|
||||
6. Test: feed shows 7 relays on startup, search uses configured relays, NIP-65 changes propagate
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Feed shows relay count immediately on startup (never "0 feed relays")
|
||||
- [ ] All screens use `LocalRelayConfig.current` — no direct `relayStatuses.keys` usage
|
||||
- [ ] NIP-65 relay changes propagate to feed subscriptions (resubscribes)
|
||||
- [ ] Search relay changes propagate to search subscriptions
|
||||
- [ ] DM relay changes propagate to DM subscriptions
|
||||
- [ ] Relay config persists across restarts
|
||||
- [ ] Per-screen relay picker dialogs use `DesktopRelayConfig` setters
|
||||
- [ ] `DesktopRelaySubscriptionsCoordinator.indexRelays` updates reactively
|
||||
|
||||
## Unanswered Questions
|
||||
|
||||
1. Should `defaultRelays` also include user's connected relays, or strictly `DefaultRelays.RELAYS`?
|
||||
2. Should we delete `DesktopDmRelayState` or keep it as an internal implementation detail?
|
||||
Reference in New Issue
Block a user