diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 0feee551b..fa46a6d2b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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?, ) { - // 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()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index e69a55272..28b711231 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -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 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt index f79c6e94a..6a911cb6a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt @@ -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>(emptySet()) val dmRelayList: StateFlow> = _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) { - lastDmCreatedAt.set(Long.MAX_VALUE) + lastDmCreatedAt.set(TimeUtils.now()) _dmRelayList.value = relays + saveRelayUrls("dm", relays) } fun setSearchRelays(relays: Set) { - lastSearchCreatedAt.set(Long.MAX_VALUE) + lastSearchCreatedAt.set(TimeUtils.now()) _searchRelayList.value = relays + saveRelayUrls("search", relays) } fun setBlockedRelays(relays: Set) { - lastBlockedCreatedAt.set(Long.MAX_VALUE) + lastBlockedCreatedAt.set(TimeUtils.now()) _blockedRelayList.value = relays + saveRelayUrls("blocked", relays) + } + + private fun saveRelayUrls( + category: String, + relays: Set, + ) { + 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 { + 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() } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index d0815562c..a76b64b18 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt index c574e763e..a31f47355 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt @@ -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>, 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 = + DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + /** Feed relays: NIP-65 outbox → connected → defaultRelays, minus blocked */ val feedRelays: StateFlow> = 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> = 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> = 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> = 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 = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 14b70abf0..bf40fdb00 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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, @@ -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(null) } var lightboxState by remember { mutableStateOf(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, + feedRelays: Set, 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), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 1163580ba..b09930c50 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -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().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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 8f6bb36dd..7aa4db00e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -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), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index fa8da9e2e..8897ce41a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -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().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), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 64cdfcdbf..c870c2a8c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/BlockedRelayEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/BlockedRelayEditor.kt index 8342f60c9..31b3531f4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/BlockedRelayEditor.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/BlockedRelayEditor.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/LocalAccountRelays.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/LocalAccountRelays.kt new file mode 100644 index 000000000..3f002e3ec --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/LocalAccountRelays.kt @@ -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 { + null + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/Nip65RelayEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/Nip65RelayEditor.kt index 9e9d912d3..57119a556 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/Nip65RelayEditor.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/Nip65RelayEditor.kt @@ -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, ): 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" } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt index 9f24082ba..d8969f2bd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt @@ -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()) + }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt index efc29c045..fdff5c1a7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt @@ -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() } val blockedRelayState = remember { mutableStateListOf() } + 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)) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayListEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayListEditor.kt index 822446198..8cc6fe5b2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayListEditor.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayListEditor.kt @@ -159,12 +159,13 @@ private fun tryAddRelay( existing: List, 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 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayValidation.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayValidation.kt index fc1a5924f..0a4a2af64 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayValidation.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayValidation.kt @@ -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, ): 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" } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt index 253a7f907..160354357 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt @@ -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, diff --git a/docs/plans/2026-04-22-feat-relay-persistence-counts-per-screen-picker-plan.md b/docs/plans/2026-04-22-feat-relay-persistence-counts-per-screen-picker-plan.md new file mode 100644 index 000000000..d54730a13 --- /dev/null +++ b/docs/plans/2026-04-22-feat-relay-persistence-counts-per-screen-picker-plan.md @@ -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__` | 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` instead of `Set` + +### Phase 3: Per-Screen Relay Picker Dialog + +**`RelayPickerDialog.kt`** (new): + +```kotlin +@Composable +fun RelayPickerDialog( + title: String, // "Search Relays", "Feed Relays", etc. + currentRelays: List, // or List for NIP-65 + connectedRelays: Set, + signer: NostrSigner, + isNip65: Boolean = false, // show read/write toggles + onSave: suspend (List) -> 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` diff --git a/docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md b/docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md new file mode 100644 index 000000000..32fca819f --- /dev/null +++ b/docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md @@ -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>(emptySet()) + private val _searchRelays = MutableStateFlow>(DEFAULT_SEARCH_RELAYS) + private val _blockedRelays = MutableStateFlow>(emptySet()) + + // === Derived relay sets for subscriptions === + + /** Default relays — always populated from DefaultRelays.RELAYS, never empty */ + private val defaultRelays: Set = + DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + /** Feed relays: NIP-65 outbox → fallback to defaultRelays, minus blocked */ + val feedRelays: StateFlow> = 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> = 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> = 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> = combine( + _dmRelays, + _blockedRelays, + ) { dm, blocked -> + (dm.ifEmpty { defaultRelays }) - blocked + }.distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, defaultRelays) + + /** Blocked relays (public read-only) */ + val blockedRelays: StateFlow> = _blockedRelays.asStateFlow() + + // Also expose raw lists for editors + val dmRelayList: StateFlow> = _dmRelays.asStateFlow() + val searchRelayList: StateFlow> = _searchRelays.asStateFlow() + val blockedRelayList: StateFlow> = _blockedRelays.asStateFlow() + + init { loadFromPersistence() } + + // === Persistence (relay URLs as CSV) === + + fun setDmRelays(relays: Set) { _dmRelays.value = relays; save("dm", relays) } + fun setSearchRelays(relays: Set) { _searchRelays.value = relays; save("search", relays) } + fun setBlockedRelays(relays: Set) { _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, // 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?