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) {
|
remember(relayManager) {
|
||||||
DmSendTracker(relayManager.client)
|
DmSendTracker(relayManager.client)
|
||||||
}
|
}
|
||||||
val iAccount =
|
// Centralized relay state for all categories (DM, search, blocked, NIP-65 persistence)
|
||||||
remember(account, localCache, relayManager, dmSendTracker) {
|
// Created before iAccount so NIP-65 backup can be loaded
|
||||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Centralized relay state for all categories (DM, search, blocked)
|
|
||||||
val accountRelays =
|
val accountRelays =
|
||||||
remember(account, relayManager, scope) {
|
remember(account, relayManager, scope) {
|
||||||
DesktopAccountRelays(account.pubKeyHex, 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)
|
// Aggregated relay categories (feed, notifications, search, DM)
|
||||||
val relayCategories =
|
val relayCategories =
|
||||||
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
|
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
|
||||||
@@ -978,9 +979,14 @@ fun MainContent(
|
|||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
forFilters: List<Filter>?,
|
forFilters: List<Filter>?,
|
||||||
) {
|
) {
|
||||||
// Route through localCache for NIP-65 sync
|
// NIP-65 (kind 10002) must go through justConsumeMyOwnEvent
|
||||||
localCache.consume(event, relay)
|
// because localCache.consume() doesn't handle addressable events
|
||||||
// Route to accountRelays for kinds without cache-backed state
|
if (event is AdvertisedRelayListEvent) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
localCache.justConsumeMyOwnEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Route to accountRelays for persistence + state updates
|
||||||
accountRelays.consumeIfRelevant(event)
|
accountRelays.consumeIfRelevant(event)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1092,6 +1098,7 @@ fun MainContent(
|
|||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalRelayCategories provides relayCategories,
|
LocalRelayCategories provides relayCategories,
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
|||||||
Vendored
+11
-1
@@ -545,7 +545,17 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
// ----- Own event consumption -----
|
// ----- Own event consumption -----
|
||||||
|
|
||||||
override fun justConsumeMyOwnEvent(event: Event): Boolean {
|
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
|
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.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
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.nip17Dm.settings.ChatMessageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages relay state for a desktop account.
|
* Manages relay state for a desktop account.
|
||||||
@@ -54,6 +58,8 @@ class DesktopAccountRelays(
|
|||||||
relayManager: RelayConnectionManager,
|
relayManager: RelayConnectionManager,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
|
private val prefs = Preferences.userNodeForPackage(DesktopAccountRelays::class.java)
|
||||||
|
|
||||||
/** User-configured DM relays from kind 10050 events */
|
/** User-configured DM relays from kind 10050 events */
|
||||||
private val _dmRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
private val _dmRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||||
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelayList.asStateFlow()
|
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelayList.asStateFlow()
|
||||||
@@ -79,10 +85,65 @@ class DesktopAccountRelays(
|
|||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Routes kind 10050 to DM relay state. Use consumeIfRelevant() for external callers. */
|
init {
|
||||||
private fun consumeDmRelayList(event: ChatMessageRelayListEvent) {
|
loadFromPersistence()
|
||||||
if (event.pubKey != userPubKeyHex) return
|
}
|
||||||
_dmRelayList.value = event.relays().toSet()
|
|
||||||
|
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 {
|
fun consumeIfRelevant(event: Event): Boolean {
|
||||||
if (event.pubKey != userPubKeyHex) return false
|
if (event.pubKey != userPubKeyHex) return false
|
||||||
return when (event.kind) {
|
return when (event.kind) {
|
||||||
|
AdvertisedRelayListEvent.KIND -> {
|
||||||
|
// Persist NIP-65 event for restart survival (state managed by Nip65RelayListState)
|
||||||
|
saveEvent(event.kind, event)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
ChatMessageRelayListEvent.KIND -> {
|
ChatMessageRelayListEvent.KIND -> {
|
||||||
if (event is ChatMessageRelayListEvent && event.createdAt > lastDmCreatedAt.get()) {
|
if (event is ChatMessageRelayListEvent && event.createdAt >= lastDmCreatedAt.get()) {
|
||||||
lastDmCreatedAt.set(event.createdAt)
|
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
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchRelayListEvent.KIND -> {
|
SearchRelayListEvent.KIND -> {
|
||||||
if (event is SearchRelayListEvent && event.createdAt > lastSearchCreatedAt.get()) {
|
if (event is SearchRelayListEvent && event.createdAt >= lastSearchCreatedAt.get()) {
|
||||||
lastSearchCreatedAt.set(event.createdAt)
|
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
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
BlockedRelayListEvent.KIND -> {
|
BlockedRelayListEvent.KIND -> {
|
||||||
if (event is BlockedRelayListEvent && event.createdAt > lastBlockedCreatedAt.get()) {
|
if (event is BlockedRelayListEvent && event.createdAt >= lastBlockedCreatedAt.get()) {
|
||||||
lastBlockedCreatedAt.set(event.createdAt)
|
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
|
true
|
||||||
}
|
}
|
||||||
@@ -123,21 +200,51 @@ class DesktopAccountRelays(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Called after publishing a relay list event from the UI — updates local state + persists */
|
||||||
* Manually sets DM relays (e.g., from saved preferences).
|
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>) {
|
fun setDmRelays(relays: Set<NormalizedRelayUrl>) {
|
||||||
lastDmCreatedAt.set(Long.MAX_VALUE)
|
lastDmCreatedAt.set(TimeUtils.now())
|
||||||
_dmRelayList.value = relays
|
_dmRelayList.value = relays
|
||||||
|
saveRelayUrls("dm", relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) {
|
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) {
|
||||||
lastSearchCreatedAt.set(Long.MAX_VALUE)
|
lastSearchCreatedAt.set(TimeUtils.now())
|
||||||
_searchRelayList.value = relays
|
_searchRelayList.value = relays
|
||||||
|
saveRelayUrls("search", relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) {
|
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) {
|
||||||
lastBlockedCreatedAt.set(Long.MAX_VALUE)
|
lastBlockedCreatedAt.set(TimeUtils.now())
|
||||||
_blockedRelayList.value = relays
|
_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,
|
private val relayManager: RelayConnectionManager,
|
||||||
val dmSendTracker: DmSendTracker,
|
val dmSendTracker: DmSendTracker,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
private val accountRelays: DesktopAccountRelays? = null,
|
||||||
) : IAccount {
|
) : IAccount {
|
||||||
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
||||||
|
|
||||||
@@ -98,9 +99,12 @@ class DesktopIAccount(
|
|||||||
localCache,
|
localCache,
|
||||||
scope,
|
scope,
|
||||||
object : Nip65RelayListRepository {
|
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 defaultOutboxRelays = relayManager.connectedRelays.value
|
||||||
override val defaultInboxRelays = relayManager.connectedRelays.value
|
override val defaultInboxRelays = relayManager.connectedRelays.value
|
||||||
|
|||||||
+23
-16
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.desktop.model
|
package com.vitorpamplona.amethyst.desktop.model
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
|
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.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -31,65 +32,71 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregates relay categories for desktop subscriptions.
|
* Aggregates relay categories for desktop subscriptions.
|
||||||
*
|
*
|
||||||
* Each category combines user-configured relays with fallbacks and subtracts blocked relays.
|
* 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)
|
@OptIn(FlowPreview::class)
|
||||||
class DesktopRelayCategories(
|
class DesktopRelayCategories(
|
||||||
nip65State: Nip65RelayListState,
|
nip65State: Nip65RelayListState,
|
||||||
accountRelays: DesktopAccountRelays,
|
accountRelays: DesktopAccountRelays,
|
||||||
|
/** Reactive connected relay set — used as fallback when NIP-65 is empty */
|
||||||
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||||
scope: CoroutineScope,
|
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>> =
|
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||||
combine(
|
combine(
|
||||||
nip65State.outboxFlow,
|
nip65State.outboxFlow,
|
||||||
connectedRelays,
|
connectedRelays,
|
||||||
accountRelays.blockedRelayList,
|
accountRelays.blockedRelayList,
|
||||||
) { outbox, connected, blocked ->
|
) { outbox, connected, blocked ->
|
||||||
(outbox.ifEmpty { connected }) - blocked
|
(outbox.ifEmpty { connected.ifEmpty { defaultRelays } }) - blocked
|
||||||
}.debounce(1.seconds)
|
}.debounce(300)
|
||||||
.distinctUntilChanged()
|
.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>> =
|
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||||
combine(
|
combine(
|
||||||
nip65State.inboxFlow,
|
nip65State.inboxFlow,
|
||||||
connectedRelays,
|
connectedRelays,
|
||||||
accountRelays.blockedRelayList,
|
accountRelays.blockedRelayList,
|
||||||
) { inbox, connected, blocked ->
|
) { inbox, connected, blocked ->
|
||||||
(inbox.ifEmpty { connected }) - blocked
|
(inbox.ifEmpty { connected.ifEmpty { defaultRelays } }) - blocked
|
||||||
}.debounce(1.seconds)
|
}.debounce(300)
|
||||||
.distinctUntilChanged()
|
.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>> =
|
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||||
combine(
|
combine(
|
||||||
accountRelays.searchRelayList,
|
accountRelays.searchRelayList,
|
||||||
accountRelays.blockedRelayList,
|
accountRelays.blockedRelayList,
|
||||||
) { search, blocked ->
|
) { search, blocked ->
|
||||||
(search.ifEmpty { DEFAULT_SEARCH_RELAYS }) - blocked
|
(search.ifEmpty { DEFAULT_SEARCH_RELAYS }) - blocked
|
||||||
}.debounce(1.seconds)
|
}.debounce(300)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
|
.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>> =
|
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||||
combine(
|
combine(
|
||||||
accountRelays.dmRelays.flow,
|
accountRelays.dmRelays.flow,
|
||||||
accountRelays.blockedRelayList,
|
accountRelays.blockedRelayList,
|
||||||
) { dm, blocked -> dm - blocked }
|
) { dm, blocked ->
|
||||||
.debounce(1.seconds)
|
(dm.ifEmpty { defaultRelays }) - blocked
|
||||||
|
}.debounce(300)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.stateIn(scope, SharingStarted.Eagerly, accountRelays.dmRelays.flow.value)
|
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val DEFAULT_SEARCH_RELAYS =
|
val DEFAULT_SEARCH_RELAYS =
|
||||||
|
|||||||
@@ -38,13 +38,16 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Dns
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.media.LightboxOverlay
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
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.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
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.Nip19Parser
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
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(
|
data class LightboxState(
|
||||||
val urls: List<String>,
|
val urls: List<String>,
|
||||||
@@ -262,6 +270,7 @@ fun FeedScreen(
|
|||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
account: AccountState.LoggedIn? = null,
|
account: AccountState.LoggedIn? = null,
|
||||||
|
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
|
||||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
initialFeedMode: FeedMode? = null,
|
initialFeedMode: FeedMode? = null,
|
||||||
@@ -284,6 +293,7 @@ fun FeedScreen(
|
|||||||
|
|
||||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||||
|
var showRelayPicker by remember { mutableStateOf(false) }
|
||||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
||||||
|
|
||||||
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
||||||
@@ -488,7 +498,7 @@ fun FeedScreen(
|
|||||||
FeedHeader(
|
FeedHeader(
|
||||||
feedMode = feedMode,
|
feedMode = feedMode,
|
||||||
account = account,
|
account = account,
|
||||||
connectedRelays = connectedRelays,
|
feedRelays = feedRelays,
|
||||||
followedUsersCount = followedUsers.size,
|
followedUsersCount = followedUsers.size,
|
||||||
onFeedModeChange = { mode ->
|
onFeedModeChange = { mode ->
|
||||||
feedMode = mode
|
feedMode = mode
|
||||||
@@ -497,6 +507,7 @@ fun FeedScreen(
|
|||||||
onRefresh = { relayManager.connect() },
|
onRefresh = { relayManager.connect() },
|
||||||
onCompose = onCompose,
|
onCompose = onCompose,
|
||||||
onNavigateToRelays = onNavigateToRelays,
|
onNavigateToRelays = onNavigateToRelays,
|
||||||
|
onOpenRelayPicker = { showRelayPicker = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
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
|
// Lightbox overlay
|
||||||
lightboxState?.let { state ->
|
lightboxState?.let { state ->
|
||||||
LightboxOverlay(
|
LightboxOverlay(
|
||||||
@@ -600,12 +638,13 @@ fun FeedScreen(
|
|||||||
private fun FeedHeader(
|
private fun FeedHeader(
|
||||||
feedMode: FeedMode,
|
feedMode: FeedMode,
|
||||||
account: AccountState.LoggedIn?,
|
account: AccountState.LoggedIn?,
|
||||||
connectedRelays: Set<Any>,
|
feedRelays: Set<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>,
|
||||||
followedUsersCount: Int,
|
followedUsersCount: Int,
|
||||||
onFeedModeChange: (FeedMode) -> Unit,
|
onFeedModeChange: (FeedMode) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
onNavigateToRelays: () -> Unit = {},
|
onNavigateToRelays: () -> Unit = {},
|
||||||
|
onOpenRelayPicker: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
@@ -642,7 +681,7 @@ private fun FeedHeader(
|
|||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
"${connectedRelays.size} relays connected",
|
"${feedRelays.size} relays",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier =
|
modifier =
|
||||||
@@ -656,6 +695,20 @@ private fun FeedHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
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(
|
IconButton(
|
||||||
onClick = onRefresh,
|
onClick = onRefresh,
|
||||||
modifier = Modifier.size(24.dp),
|
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.Clear
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Description
|
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.History
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.filled.Tag
|
import androidx.compose.material.icons.filled.Tag
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -64,6 +66,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
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.SearchResultFilter
|
||||||
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||||
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
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.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
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.createSearchPeopleSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
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.LocalRelayCategories
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||||
@@ -112,6 +118,7 @@ fun SearchScreen(
|
|||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
|
account: AccountState.LoggedIn? = null,
|
||||||
initialQuery: String = "",
|
initialQuery: String = "",
|
||||||
onNavigateToProfile: (String) -> Unit,
|
onNavigateToProfile: (String) -> Unit,
|
||||||
onNavigateToThread: (String) -> Unit,
|
onNavigateToThread: (String) -> Unit,
|
||||||
@@ -119,8 +126,10 @@ fun SearchScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val accountRelays = LocalAccountRelays.current
|
||||||
val state = remember { AdvancedSearchBarState(scope) }
|
val state = remember { AdvancedSearchBarState(scope) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
var showRelayPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pre-fill initial query
|
// Pre-fill initial query
|
||||||
LaunchedEffect(initialQuery) {
|
LaunchedEffect(initialQuery) {
|
||||||
@@ -163,7 +172,7 @@ fun SearchScreen(
|
|||||||
LaunchedEffect(debouncedQuery) {
|
LaunchedEffect(debouncedQuery) {
|
||||||
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
||||||
state.clearResults()
|
state.clearResults()
|
||||||
state.initRelayStates(allRelayUrls)
|
state.initRelayStates(searchRelays)
|
||||||
if (shouldSearchPeople) {
|
if (shouldSearchPeople) {
|
||||||
state.startSearching("people-search")
|
state.startSearching("people-search")
|
||||||
}
|
}
|
||||||
@@ -385,6 +394,15 @@ fun SearchScreen(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
shape = RoundedCornerShape(12.dp),
|
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() }) {
|
IconButton(onClick = { state.togglePanel() }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Tune,
|
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
|
// Expandable advanced panel
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = panelExpanded,
|
visible = panelExpanded,
|
||||||
|
|||||||
+13
@@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Dns
|
||||||
import androidx.compose.material.icons.filled.Group
|
import androidx.compose.material.icons.filled.Group
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -91,6 +92,7 @@ fun ConversationListPane(
|
|||||||
selectedRoom: ChatroomKey?,
|
selectedRoom: ChatroomKey?,
|
||||||
onConversationSelected: (ChatroomKey) -> Unit,
|
onConversationSelected: (ChatroomKey) -> Unit,
|
||||||
onNewConversation: () -> Unit = {},
|
onNewConversation: () -> Unit = {},
|
||||||
|
onShowRelayPicker: () -> Unit = {},
|
||||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -179,6 +181,17 @@ fun ConversationListPane(
|
|||||||
color = MaterialTheme.colorScheme.onBackground,
|
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(
|
IconButton(
|
||||||
onClick = onNewConversation,
|
onClick = onNewConversation,
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
|
|||||||
+43
@@ -85,6 +85,8 @@ fun DesktopMessagesScreen(
|
|||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||||
|
var showDmRelayPicker by remember { mutableStateOf(false) }
|
||||||
val listState =
|
val listState =
|
||||||
remember(account) {
|
remember(account) {
|
||||||
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
||||||
@@ -110,6 +112,11 @@ fun DesktopMessagesScreen(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
event.key == Key.R && isModifier && event.isShiftPressed -> {
|
||||||
|
showDmRelayPicker = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -126,6 +133,7 @@ fun DesktopMessagesScreen(
|
|||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
listFocusRequester = listFocusRequester,
|
listFocusRequester = listFocusRequester,
|
||||||
onShowNewDm = { showNewDmDialog = true },
|
onShowNewDm = { showNewDmDialog = true },
|
||||||
|
onShowRelayPicker = { showDmRelayPicker = true },
|
||||||
keyHandler = keyHandler,
|
keyHandler = keyHandler,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -138,6 +146,7 @@ fun DesktopMessagesScreen(
|
|||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
listFocusRequester = listFocusRequester,
|
listFocusRequester = listFocusRequester,
|
||||||
onShowNewDm = { showNewDmDialog = true },
|
onShowNewDm = { showNewDmDialog = true },
|
||||||
|
onShowRelayPicker = { showDmRelayPicker = true },
|
||||||
keyHandler = keyHandler,
|
keyHandler = keyHandler,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -154,6 +163,36 @@ fun DesktopMessagesScreen(
|
|||||||
onDismiss = { showNewDmDialog = false },
|
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,
|
onNavigateToProfile: (String) -> Unit,
|
||||||
listFocusRequester: FocusRequester,
|
listFocusRequester: FocusRequester,
|
||||||
onShowNewDm: () -> Unit,
|
onShowNewDm: () -> Unit,
|
||||||
|
onShowRelayPicker: () -> Unit = {},
|
||||||
keyHandler: Modifier,
|
keyHandler: Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||||
@@ -208,6 +248,7 @@ private fun CompactMessagesContent(
|
|||||||
selectedRoom = selectedRoom,
|
selectedRoom = selectedRoom,
|
||||||
onConversationSelected = { listState.selectRoom(it) },
|
onConversationSelected = { listState.selectRoom(it) },
|
||||||
onNewConversation = onShowNewDm,
|
onNewConversation = onShowNewDm,
|
||||||
|
onShowRelayPicker = onShowRelayPicker,
|
||||||
focusRequester = listFocusRequester,
|
focusRequester = listFocusRequester,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -229,6 +270,7 @@ private fun SplitMessagesContent(
|
|||||||
onNavigateToProfile: (String) -> Unit,
|
onNavigateToProfile: (String) -> Unit,
|
||||||
listFocusRequester: FocusRequester,
|
listFocusRequester: FocusRequester,
|
||||||
onShowNewDm: () -> Unit,
|
onShowNewDm: () -> Unit,
|
||||||
|
onShowRelayPicker: () -> Unit = {},
|
||||||
keyHandler: Modifier,
|
keyHandler: Modifier,
|
||||||
) {
|
) {
|
||||||
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||||
@@ -237,6 +279,7 @@ private fun SplitMessagesContent(
|
|||||||
selectedRoom = selectedRoom,
|
selectedRoom = selectedRoom,
|
||||||
onConversationSelected = { listState.selectRoom(it) },
|
onConversationSelected = { listState.selectRoom(it) },
|
||||||
onNewConversation = onShowNewDm,
|
onNewConversation = onShowNewDm,
|
||||||
|
onShowRelayPicker = onShowRelayPicker,
|
||||||
focusRequester = listFocusRequester,
|
focusRequester = listFocusRequester,
|
||||||
modifier = Modifier.width(280.dp),
|
modifier = Modifier.width(280.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-9
@@ -217,6 +217,7 @@ internal fun RootContent(
|
|||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
|
iAccount = iAccount,
|
||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
initialFeedMode = FeedMode.FOLLOWING,
|
initialFeedMode = FeedMode.FOLLOWING,
|
||||||
@@ -248,6 +249,7 @@ internal fun RootContent(
|
|||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
account = account,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
)
|
)
|
||||||
@@ -284,6 +286,7 @@ internal fun RootContent(
|
|||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
|
iAccount = iAccount,
|
||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
initialFeedMode = FeedMode.GLOBAL,
|
initialFeedMode = FeedMode.GLOBAL,
|
||||||
@@ -334,19 +337,12 @@ internal fun RootContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
DeckColumnType.Relays -> {
|
DeckColumnType.Relays -> {
|
||||||
val accountRelays =
|
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||||
remember(iAccount, relayManager, scope) {
|
|
||||||
com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays(
|
|
||||||
iAccount.pubKey,
|
|
||||||
relayManager,
|
|
||||||
scope,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RelayDashboardScreen(
|
RelayDashboardScreen(
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
nip11Fetcher = nip11Fetcher,
|
nip11Fetcher = nip11Fetcher,
|
||||||
nip65State = iAccount.nip65RelayList,
|
nip65State = iAccount.nip65RelayList,
|
||||||
accountRelays = accountRelays,
|
accountRelays = accountRelays ?: return,
|
||||||
signer = iAccount.signer,
|
signer = iAccount.signer,
|
||||||
onPublish = { event -> relayManager.broadcastToAll(event) },
|
onPublish = { event -> relayManager.broadcastToAll(event) },
|
||||||
)
|
)
|
||||||
@@ -427,6 +423,7 @@ internal fun RootContent(
|
|||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
account = account,
|
||||||
initialQuery = "#${columnType.tag}",
|
initialQuery = "#${columnType.tag}",
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
|||||||
-7
@@ -80,13 +80,6 @@ fun BlockedRelayEditor(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
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
|
// Add relay input
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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
|
||||||
|
}
|
||||||
+11
-2
@@ -90,11 +90,19 @@ fun Nip65RelayEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(currentNip65Relays) {
|
LaunchedEffect(currentNip65Relays) {
|
||||||
|
if (currentNip65Relays.isNotEmpty()) {
|
||||||
localRelays.clear()
|
localRelays.clear()
|
||||||
localRelays.addAll(currentNip65Relays)
|
localRelays.addAll(currentNip65Relays)
|
||||||
|
}
|
||||||
loaded = true
|
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()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
if (loaded && localRelays.isEmpty()) {
|
if (loaded && localRelays.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -272,9 +280,10 @@ private fun tryAddNip65Relay(
|
|||||||
url: String,
|
url: String,
|
||||||
existing: MutableList<AdvertisedRelayInfo>,
|
existing: MutableList<AdvertisedRelayInfo>,
|
||||||
): String? {
|
): String? {
|
||||||
val error = validateRelayUrl(url)
|
val input = normalizeRelayInput(url)
|
||||||
|
val error = validateRelayUrl(input)
|
||||||
if (error != null) return error
|
if (error != null) return error
|
||||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())!!
|
val normalized = RelayUrlNormalizer.normalizeOrNull(input)!!
|
||||||
if (existing.any { it.relayUrl.url == normalized.url }) {
|
if (existing.any { it.relayUrl.url == normalized.url }) {
|
||||||
return "Relay already added"
|
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.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayConfigTab(
|
fun RelayConfigTab(
|
||||||
@@ -99,7 +100,16 @@ fun RelayConfigTab(
|
|||||||
Nip65RelayEditor(
|
Nip65RelayEditor(
|
||||||
nip65State = nip65State,
|
nip65State = nip65State,
|
||||||
signer = signer,
|
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(
|
SearchRelayEditor(
|
||||||
localRelays = searchRelayState,
|
localRelays = searchRelayState,
|
||||||
signer = signer,
|
signer = signer,
|
||||||
onPublish = onPublish,
|
onPublish = { event ->
|
||||||
|
onPublish(event)
|
||||||
|
accountRelays.consumePublishedEvent(event)
|
||||||
|
accountRelays.setSearchRelays(searchRelayState.toSet())
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +156,12 @@ fun RelayConfigTab(
|
|||||||
BlockedRelayEditor(
|
BlockedRelayEditor(
|
||||||
localRelays = blockedRelayState,
|
localRelays = blockedRelayState,
|
||||||
signer = signer,
|
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.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -62,8 +64,19 @@ fun RelayDashboardScreen(
|
|||||||
var selectedTab by remember { mutableStateOf(DashboardTab.MONITOR) }
|
var selectedTab by remember { mutableStateOf(DashboardTab.MONITOR) }
|
||||||
|
|
||||||
// Hoisted state — survives Monitor ↔ Configure tab switches
|
// Hoisted state — survives Monitor ↔ Configure tab switches
|
||||||
|
// Synced from accountRelays flows (persistence + bootstrap + per-screen picker)
|
||||||
val searchRelayState = remember { mutableStateListOf<NormalizedRelayUrl>() }
|
val searchRelayState = remember { mutableStateListOf<NormalizedRelayUrl>() }
|
||||||
val blockedRelayState = 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()) {
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
PrimaryTabRow(selectedTabIndex = DashboardTab.entries.indexOf(selectedTab)) {
|
PrimaryTabRow(selectedTabIndex = DashboardTab.entries.indexOf(selectedTab)) {
|
||||||
|
|||||||
+4
-3
@@ -159,12 +159,13 @@ private fun tryAddRelay(
|
|||||||
existing: List<NormalizedRelayUrl>,
|
existing: List<NormalizedRelayUrl>,
|
||||||
onAdd: (String) -> NormalizedRelayUrl?,
|
onAdd: (String) -> NormalizedRelayUrl?,
|
||||||
): String? {
|
): String? {
|
||||||
val error = validateRelayUrl(url)
|
val input = normalizeRelayInput(url)
|
||||||
|
val error = validateRelayUrl(input)
|
||||||
if (error != null) return error
|
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 }) {
|
if (existing.any { it.url == normalized.url }) {
|
||||||
return "Relay already added"
|
return "Relay already added"
|
||||||
}
|
}
|
||||||
onAdd(url.trim())
|
onAdd(input)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-12
@@ -24,21 +24,34 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and adds a relay URL to a mutable list.
|
* Normalizes a relay URL input — auto-prefixes wss:// if no scheme given.
|
||||||
* Returns an error message string if validation fails, null on success.
|
* 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? {
|
internal fun validateRelayUrl(url: String): String? {
|
||||||
val trimmed = url.trim()
|
val input = normalizeRelayInput(url)
|
||||||
if (trimmed.isBlank()) return "Enter a relay URL"
|
if (input.isBlank()) return "Enter a relay URL"
|
||||||
if (!trimmed.startsWith("wss://") && !trimmed.startsWith("ws://")) {
|
if (!input.startsWith("wss://") && !input.startsWith("ws://")) {
|
||||||
return "URL must start with wss:// or 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"
|
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 =
|
val host =
|
||||||
trimmed
|
input
|
||||||
.removePrefix("wss://")
|
.removePrefix("wss://")
|
||||||
.removePrefix("ws://")
|
.removePrefix("ws://")
|
||||||
.split("/")
|
.split("/")
|
||||||
@@ -46,7 +59,7 @@ internal fun validateRelayUrl(url: String): String? {
|
|||||||
if (!host.contains(".")) {
|
if (!host.contains(".")) {
|
||||||
return "Invalid domain — must contain at least one dot (e.g., relay.example.com)"
|
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 "Invalid relay URL"
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -56,9 +69,10 @@ internal fun tryAddSimpleRelay(
|
|||||||
url: String,
|
url: String,
|
||||||
existing: MutableList<NormalizedRelayUrl>,
|
existing: MutableList<NormalizedRelayUrl>,
|
||||||
): String? {
|
): String? {
|
||||||
val error = validateRelayUrl(url)
|
val input = normalizeRelayInput(url)
|
||||||
|
val error = validateRelayUrl(input)
|
||||||
if (error != null) return error
|
if (error != null) return error
|
||||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())!!
|
val normalized = RelayUrlNormalizer.normalizeOrNull(input)!!
|
||||||
if (existing.any { it.url == normalized.url }) {
|
if (existing.any { it.url == normalized.url }) {
|
||||||
return "Relay already added"
|
return "Relay already added"
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-7
@@ -36,6 +36,7 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -80,13 +81,6 @@ fun SearchRelayEditor(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
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
|
// Add relay input
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -194,6 +188,17 @@ fun SearchRelayEditor(
|
|||||||
Text("Save")
|
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 {
|
savedMessage?.let {
|
||||||
Text(
|
Text(
|
||||||
it,
|
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