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:
nrobi144
2026-04-22 12:02:11 +03:00
parent 66962d360d
commit 835947d611
20 changed files with 855 additions and 93 deletions
@@ -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()) {
@@ -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
}
@@ -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()
}
}
@@ -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
@@ -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),
@@ -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,
@@ -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),
@@ -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),
)
@@ -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,
@@ -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,
@@ -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
}
@@ -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"
}
@@ -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())
},
)
}
}
@@ -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)) {
@@ -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
}
@@ -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"
}
@@ -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,