feat(desktop): relay config persistence, correct counts, per-screen picker
- Persist relay list events (kinds 10050/10007/10006) as JSON to java.util.prefs.Preferences with per-account key isolation - Load persisted relay configs on startup before bootstrap subscription - Validate loaded events (kind + pubkey check), 8KB guard on writes - Fix SearchScreen relay count: "0 of 1" not "0 of 7" — uses searchRelays - Fix FeedScreen relay count: shows feed relay count, not all connected - Per-screen relay picker dialogs: Dns icon on Feed and Search screens opens AlertDialog wrapping existing editors (Nip65RelayEditor, SearchRelayEditor) — no new composable files - Fix created_at dedup: use >= for replaceable event semantics - Fix setters: use TimeUtils.now() not Long.MAX_VALUE - Add consumePublishedEvent() for local immediate update after publish - Remove stale "not loaded" warnings from Search/Blocked editors - Fix FeedHeader type: Set<NormalizedRelayUrl> not Set<Any> - Fix picker LaunchedEffect(Unit) to not overwrite user edits Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -916,17 +916,18 @@ fun MainContent(
|
||||
remember(relayManager) {
|
||||
DmSendTracker(relayManager.client)
|
||||
}
|
||||
val iAccount =
|
||||
remember(account, localCache, relayManager, dmSendTracker) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
|
||||
}
|
||||
|
||||
// Centralized relay state for all categories (DM, search, blocked)
|
||||
// Centralized relay state for all categories (DM, search, blocked, NIP-65 persistence)
|
||||
// Created before iAccount so NIP-65 backup can be loaded
|
||||
val accountRelays =
|
||||
remember(account, relayManager, scope) {
|
||||
DesktopAccountRelays(account.pubKeyHex, relayManager, scope)
|
||||
}
|
||||
|
||||
val iAccount =
|
||||
remember(account, localCache, relayManager, dmSendTracker, accountRelays) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays)
|
||||
}
|
||||
|
||||
// Aggregated relay categories (feed, notifications, search, DM)
|
||||
val relayCategories =
|
||||
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
|
||||
@@ -978,9 +979,14 @@ fun MainContent(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
// Route through localCache for NIP-65 sync
|
||||
localCache.consume(event, relay)
|
||||
// Route to accountRelays for kinds without cache-backed state
|
||||
// NIP-65 (kind 10002) must go through justConsumeMyOwnEvent
|
||||
// because localCache.consume() doesn't handle addressable events
|
||||
if (event is AdvertisedRelayListEvent) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
localCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
// Route to accountRelays for persistence + state updates
|
||||
accountRelays.consumeIfRelevant(event)
|
||||
}
|
||||
},
|
||||
@@ -1092,6 +1098,7 @@ fun MainContent(
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalRelayCategories provides relayCategories,
|
||||
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
|
||||
Vendored
+11
-1
@@ -545,7 +545,17 @@ class DesktopLocalCache : ICacheProvider {
|
||||
// ----- Own event consumption -----
|
||||
|
||||
override fun justConsumeMyOwnEvent(event: Event): Boolean {
|
||||
// Desktop doesn't track own events separately
|
||||
// For addressable/replaceable events, store in the addressable note cache
|
||||
// so state holders (Nip65RelayListState, etc.) pick it up via their flows
|
||||
if (event is com.vitorpamplona.quartz.nip01Core.core.AddressableEvent) {
|
||||
val address = event.address()
|
||||
val note = getOrCreateAddressableNote(address)
|
||||
val author = getOrCreateUser(event.pubKey) ?: return false
|
||||
if (note.event == null || (note.event?.createdAt ?: 0) <= event.createdAt) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+123
-16
@@ -24,14 +24,18 @@ import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* Manages relay state for a desktop account.
|
||||
@@ -54,6 +58,8 @@ class DesktopAccountRelays(
|
||||
relayManager: RelayConnectionManager,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
private val prefs = Preferences.userNodeForPackage(DesktopAccountRelays::class.java)
|
||||
|
||||
/** User-configured DM relays from kind 10050 events */
|
||||
private val _dmRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelayList.asStateFlow()
|
||||
@@ -79,10 +85,65 @@ class DesktopAccountRelays(
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
/** Routes kind 10050 to DM relay state. Use consumeIfRelevant() for external callers. */
|
||||
private fun consumeDmRelayList(event: ChatMessageRelayListEvent) {
|
||||
if (event.pubKey != userPubKeyHex) return
|
||||
_dmRelayList.value = event.relays().toSet()
|
||||
init {
|
||||
loadFromPersistence()
|
||||
}
|
||||
|
||||
private fun prefsKey(kind: Int) = "relay_${kind}_${userPubKeyHex.take(16)}"
|
||||
|
||||
private fun saveEvent(
|
||||
kind: Int,
|
||||
event: Event,
|
||||
) {
|
||||
try {
|
||||
val json = event.toJson()
|
||||
if (json.length > MAX_PREFS_VALUE_LENGTH) return // Preferences 8KB limit
|
||||
prefs.put(prefsKey(kind), json)
|
||||
} catch (_: Exception) {
|
||||
// Best-effort persistence
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_PREFS_VALUE_LENGTH = 8000 // java.util.prefs limit is 8192
|
||||
}
|
||||
|
||||
private fun loadEvent(kind: Int): Event? =
|
||||
try {
|
||||
val json = prefs.get(prefsKey(kind), null) ?: return null
|
||||
val event = Event.fromJson(json)
|
||||
if (event.kind == kind && event.pubKey == userPubKeyHex) event else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun loadFromPersistence() {
|
||||
// Load DM relays from event or URL cache
|
||||
val dmEvent = loadEvent(ChatMessageRelayListEvent.KIND)
|
||||
if (dmEvent is ChatMessageRelayListEvent) {
|
||||
_dmRelayList.value = dmEvent.relays().toSet()
|
||||
lastDmCreatedAt.set(dmEvent.createdAt)
|
||||
} else {
|
||||
loadRelayUrls("dm").let { if (it.isNotEmpty()) _dmRelayList.value = it }
|
||||
}
|
||||
|
||||
// Load search relays from event or URL cache
|
||||
val searchEvent = loadEvent(SearchRelayListEvent.KIND)
|
||||
if (searchEvent is SearchRelayListEvent) {
|
||||
val relays = searchEvent.publicRelays().toSet()
|
||||
if (relays.isNotEmpty()) {
|
||||
_searchRelayList.value = relays
|
||||
lastSearchCreatedAt.set(searchEvent.createdAt)
|
||||
} else {
|
||||
// Public tags empty — try URL cache (NIP-51 private tags can't be decrypted here)
|
||||
loadRelayUrls("search").let { if (it.isNotEmpty()) _searchRelayList.value = it }
|
||||
}
|
||||
} else {
|
||||
loadRelayUrls("search").let { if (it.isNotEmpty()) _searchRelayList.value = it }
|
||||
}
|
||||
|
||||
// Load blocked relays — always use URL cache (private tags can't be decrypted synchronously)
|
||||
loadRelayUrls("blocked").let { if (it.isNotEmpty()) _blockedRelayList.value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,26 +154,42 @@ class DesktopAccountRelays(
|
||||
fun consumeIfRelevant(event: Event): Boolean {
|
||||
if (event.pubKey != userPubKeyHex) return false
|
||||
return when (event.kind) {
|
||||
AdvertisedRelayListEvent.KIND -> {
|
||||
// Persist NIP-65 event for restart survival (state managed by Nip65RelayListState)
|
||||
saveEvent(event.kind, event)
|
||||
true
|
||||
}
|
||||
|
||||
ChatMessageRelayListEvent.KIND -> {
|
||||
if (event is ChatMessageRelayListEvent && event.createdAt > lastDmCreatedAt.get()) {
|
||||
if (event is ChatMessageRelayListEvent && event.createdAt >= lastDmCreatedAt.get()) {
|
||||
lastDmCreatedAt.set(event.createdAt)
|
||||
_dmRelayList.value = event.relays().toSet()
|
||||
val relays = event.relays().toSet()
|
||||
_dmRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
saveRelayUrls("dm", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
SearchRelayListEvent.KIND -> {
|
||||
if (event is SearchRelayListEvent && event.createdAt > lastSearchCreatedAt.get()) {
|
||||
if (event is SearchRelayListEvent && event.createdAt >= lastSearchCreatedAt.get()) {
|
||||
lastSearchCreatedAt.set(event.createdAt)
|
||||
_searchRelayList.value = event.publicRelays().toSet()
|
||||
val relays = event.publicRelays().toSet()
|
||||
_searchRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
saveRelayUrls("search", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
BlockedRelayListEvent.KIND -> {
|
||||
if (event is BlockedRelayListEvent && event.createdAt > lastBlockedCreatedAt.get()) {
|
||||
if (event is BlockedRelayListEvent && event.createdAt >= lastBlockedCreatedAt.get()) {
|
||||
lastBlockedCreatedAt.set(event.createdAt)
|
||||
_blockedRelayList.value = event.publicRelays().toSet()
|
||||
// publicRelays() may be empty for NIP-51 private-tag events
|
||||
val relays = event.publicRelays().toSet()
|
||||
_blockedRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
if (relays.isNotEmpty()) saveRelayUrls("blocked", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -123,21 +200,51 @@ class DesktopAccountRelays(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually sets DM relays (e.g., from saved preferences).
|
||||
*/
|
||||
/** Called after publishing a relay list event from the UI — updates local state + persists */
|
||||
fun consumePublishedEvent(event: Event) {
|
||||
consumeIfRelevant(event)
|
||||
}
|
||||
|
||||
/** Load persisted NIP-65 event for Nip65RelayListState backup */
|
||||
fun loadPersistedNip65Event(): AdvertisedRelayListEvent? {
|
||||
val event = loadEvent(AdvertisedRelayListEvent.KIND)
|
||||
return event as? AdvertisedRelayListEvent
|
||||
}
|
||||
|
||||
fun setDmRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastDmCreatedAt.set(Long.MAX_VALUE)
|
||||
lastDmCreatedAt.set(TimeUtils.now())
|
||||
_dmRelayList.value = relays
|
||||
saveRelayUrls("dm", relays)
|
||||
}
|
||||
|
||||
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastSearchCreatedAt.set(Long.MAX_VALUE)
|
||||
lastSearchCreatedAt.set(TimeUtils.now())
|
||||
_searchRelayList.value = relays
|
||||
saveRelayUrls("search", relays)
|
||||
}
|
||||
|
||||
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastBlockedCreatedAt.set(Long.MAX_VALUE)
|
||||
lastBlockedCreatedAt.set(TimeUtils.now())
|
||||
_blockedRelayList.value = relays
|
||||
saveRelayUrls("blocked", relays)
|
||||
}
|
||||
|
||||
private fun saveRelayUrls(
|
||||
category: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
try {
|
||||
val key = "urls_${category}_${userPubKeyHex.take(16)}"
|
||||
prefs.put(key, relays.joinToString(",") { it.url })
|
||||
prefs.flush()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRelayUrls(category: String): Set<NormalizedRelayUrl> {
|
||||
val key = "urls_${category}_${userPubKeyHex.take(16)}"
|
||||
val csv = prefs.get(key, "") ?: return emptySet()
|
||||
if (csv.isBlank()) return emptySet()
|
||||
return csv.split(",").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -70,6 +70,7 @@ class DesktopIAccount(
|
||||
private val relayManager: RelayConnectionManager,
|
||||
val dmSendTracker: DmSendTracker,
|
||||
private val scope: CoroutineScope,
|
||||
private val accountRelays: DesktopAccountRelays? = null,
|
||||
) : IAccount {
|
||||
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
||||
|
||||
@@ -98,9 +99,12 @@ class DesktopIAccount(
|
||||
localCache,
|
||||
scope,
|
||||
object : Nip65RelayListRepository {
|
||||
override val backupNIP65RelayList: AdvertisedRelayListEvent? = null
|
||||
override val backupNIP65RelayList: AdvertisedRelayListEvent? =
|
||||
accountRelays?.loadPersistedNip65Event()
|
||||
|
||||
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) { /* no persistence yet */ }
|
||||
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) {
|
||||
accountRelays?.consumePublishedEvent(event)
|
||||
}
|
||||
|
||||
override val defaultOutboxRelays = relayManager.connectedRelays.value
|
||||
override val defaultInboxRelays = relayManager.connectedRelays.value
|
||||
|
||||
+23
-16
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.desktop.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -31,65 +32,71 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Aggregates relay categories for desktop subscriptions.
|
||||
*
|
||||
* Each category combines user-configured relays with fallbacks and subtracts blocked relays.
|
||||
* Debounced to prevent subscription thrashing at startup.
|
||||
* Uses DefaultRelays.RELAYS as the stateIn initial value — NEVER empty.
|
||||
* Debounced 300ms to prevent subscription thrashing at startup.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
class DesktopRelayCategories(
|
||||
nip65State: Nip65RelayListState,
|
||||
accountRelays: DesktopAccountRelays,
|
||||
/** Reactive connected relay set — used as fallback when NIP-65 is empty */
|
||||
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
/** NIP-65 outbox (write) relays, falls back to connected relays, minus blocked */
|
||||
/** Default relays — ALWAYS populated, used as stateIn initial value */
|
||||
private val defaultRelays: Set<NormalizedRelayUrl> =
|
||||
DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
|
||||
/** Feed relays: NIP-65 outbox → connected → defaultRelays, minus blocked */
|
||||
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
nip65State.outboxFlow,
|
||||
connectedRelays,
|
||||
accountRelays.blockedRelayList,
|
||||
) { outbox, connected, blocked ->
|
||||
(outbox.ifEmpty { connected }) - blocked
|
||||
}.debounce(1.seconds)
|
||||
(outbox.ifEmpty { connected.ifEmpty { defaultRelays } }) - blocked
|
||||
}.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays) // NEVER empty
|
||||
|
||||
/** NIP-65 inbox (read) relays, falls back to connected relays, minus blocked */
|
||||
/** Notification relays: NIP-65 inbox → connected → defaultRelays, minus blocked */
|
||||
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
nip65State.inboxFlow,
|
||||
connectedRelays,
|
||||
accountRelays.blockedRelayList,
|
||||
) { inbox, connected, blocked ->
|
||||
(inbox.ifEmpty { connected }) - blocked
|
||||
}.debounce(1.seconds)
|
||||
(inbox.ifEmpty { connected.ifEmpty { defaultRelays } }) - blocked
|
||||
}.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||
|
||||
/** Search relays (kind 10007), falls back to relay.nostr.band, minus blocked */
|
||||
/** Search relays: kind 10007 → relay.nostr.band, minus blocked */
|
||||
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
accountRelays.searchRelayList,
|
||||
accountRelays.blockedRelayList,
|
||||
) { search, blocked ->
|
||||
(search.ifEmpty { DEFAULT_SEARCH_RELAYS }) - blocked
|
||||
}.debounce(1.seconds)
|
||||
}.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
|
||||
|
||||
/** DM relays — aggregated DM state minus blocked */
|
||||
/** DM relays: kind 10050 → defaultRelays, minus blocked */
|
||||
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
accountRelays.dmRelays.flow,
|
||||
accountRelays.blockedRelayList,
|
||||
) { dm, blocked -> dm - blocked }
|
||||
.debounce(1.seconds)
|
||||
) { dm, blocked ->
|
||||
(dm.ifEmpty { defaultRelays }) - blocked
|
||||
}.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(scope, SharingStarted.Eagerly, accountRelays.dmRelays.flow.value)
|
||||
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||
|
||||
companion object {
|
||||
val DEFAULT_SEARCH_RELAYS =
|
||||
|
||||
@@ -38,13 +38,16 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -82,6 +85,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
@@ -89,6 +93,10 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LightboxState(
|
||||
val urls: List<String>,
|
||||
@@ -262,6 +270,7 @@ fun FeedScreen(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn? = null,
|
||||
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
|
||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
initialFeedMode: FeedMode? = null,
|
||||
@@ -284,6 +293,7 @@ fun FeedScreen(
|
||||
|
||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
var showRelayPicker by remember { mutableStateOf(false) }
|
||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
||||
|
||||
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
||||
@@ -488,7 +498,7 @@ fun FeedScreen(
|
||||
FeedHeader(
|
||||
feedMode = feedMode,
|
||||
account = account,
|
||||
connectedRelays = connectedRelays,
|
||||
feedRelays = feedRelays,
|
||||
followedUsersCount = followedUsers.size,
|
||||
onFeedModeChange = { mode ->
|
||||
feedMode = mode
|
||||
@@ -497,6 +507,7 @@ fun FeedScreen(
|
||||
onRefresh = { relayManager.connect() },
|
||||
onCompose = onCompose,
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
onOpenRelayPicker = { showRelayPicker = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -579,6 +590,33 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Feed relay picker dialog
|
||||
if (showRelayPicker && account != null && iAccount != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRelayPicker = false },
|
||||
title = { Text("Feed Relays (NIP-65)") },
|
||||
text = {
|
||||
Nip65RelayEditor(
|
||||
nip65State = iAccount.nip65RelayList,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
// Update local NIP-65 state immediately via addressable note cache
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
localCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showRelayPicker = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Lightbox overlay
|
||||
lightboxState?.let { state ->
|
||||
LightboxOverlay(
|
||||
@@ -600,12 +638,13 @@ fun FeedScreen(
|
||||
private fun FeedHeader(
|
||||
feedMode: FeedMode,
|
||||
account: AccountState.LoggedIn?,
|
||||
connectedRelays: Set<Any>,
|
||||
feedRelays: Set<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>,
|
||||
followedUsersCount: Int,
|
||||
onFeedModeChange: (FeedMode) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
onOpenRelayPicker: () -> Unit = {},
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
@@ -642,7 +681,7 @@ private fun FeedHeader(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
"${feedRelays.size} relays",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
@@ -656,6 +695,20 @@ private fun FeedHeader(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (account != null && !account.isReadOnly) {
|
||||
IconButton(
|
||||
onClick = onOpenRelayPicker,
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "Edit Feed Relays",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp),
|
||||
|
||||
+49
-1
@@ -44,12 +44,14 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -64,6 +66,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -91,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchResult
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
|
||||
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
@@ -100,7 +104,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscripti
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||
@@ -112,6 +118,7 @@ fun SearchScreen(
|
||||
localCache: DesktopLocalCache,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
account: AccountState.LoggedIn? = null,
|
||||
initialQuery: String = "",
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
@@ -119,8 +126,10 @@ fun SearchScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val accountRelays = LocalAccountRelays.current
|
||||
val state = remember { AdvancedSearchBarState(scope) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var showRelayPicker by remember { mutableStateOf(false) }
|
||||
|
||||
// Pre-fill initial query
|
||||
LaunchedEffect(initialQuery) {
|
||||
@@ -163,7 +172,7 @@ fun SearchScreen(
|
||||
LaunchedEffect(debouncedQuery) {
|
||||
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
||||
state.clearResults()
|
||||
state.initRelayStates(allRelayUrls)
|
||||
state.initRelayStates(searchRelays)
|
||||
if (shouldSearchPeople) {
|
||||
state.startSearching("people-search")
|
||||
}
|
||||
@@ -385,6 +394,15 @@ fun SearchScreen(
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
if (account != null && !account.isReadOnly) {
|
||||
IconButton(onClick = { showRelayPicker = true }) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "Search Relays",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { state.togglePanel() }) {
|
||||
Icon(
|
||||
Icons.Default.Tune,
|
||||
@@ -399,6 +417,36 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Search relay picker dialog
|
||||
if (showRelayPicker && account != null) {
|
||||
val pickerRelays =
|
||||
remember {
|
||||
mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
|
||||
it.addAll(searchRelays)
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRelayPicker = false },
|
||||
title = { Text("Search Relays") },
|
||||
text = {
|
||||
SearchRelayEditor(
|
||||
localRelays = pickerRelays,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
accountRelays?.consumePublishedEvent(event)
|
||||
accountRelays?.setSearchRelays(pickerRelays.toSet())
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showRelayPicker = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Expandable advanced panel
|
||||
AnimatedVisibility(
|
||||
visible = panelExpanded,
|
||||
|
||||
+13
@@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -91,6 +92,7 @@ fun ConversationListPane(
|
||||
selectedRoom: ChatroomKey?,
|
||||
onConversationSelected: (ChatroomKey) -> Unit,
|
||||
onNewConversation: () -> Unit = {},
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -179,6 +181,17 @@ fun ConversationListPane(
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onShowRelayPicker,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "DM Relays",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onNewConversation,
|
||||
modifier = Modifier.size(32.dp),
|
||||
|
||||
+43
@@ -85,6 +85,8 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||
var showDmRelayPicker by remember { mutableStateOf(false) }
|
||||
val listState =
|
||||
remember(account) {
|
||||
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
||||
@@ -110,6 +112,11 @@ fun DesktopMessagesScreen(
|
||||
true
|
||||
}
|
||||
|
||||
event.key == Key.R && isModifier && event.isShiftPressed -> {
|
||||
showDmRelayPicker = true
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
@@ -126,6 +133,7 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
@@ -138,6 +146,7 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
}
|
||||
@@ -154,6 +163,36 @@ fun DesktopMessagesScreen(
|
||||
onDismiss = { showNewDmDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showDmRelayPicker && accountRelays != null) {
|
||||
val pickerRelays =
|
||||
remember {
|
||||
androidx.compose.runtime.mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
|
||||
it.addAll(accountRelays.dmRelayList.value.sortedBy { r -> r.url })
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showDmRelayPicker = false },
|
||||
title = { androidx.compose.material3.Text("DM Relays") },
|
||||
text = {
|
||||
com.vitorpamplona.amethyst.desktop.ui.relay.DmRelayEditor(
|
||||
dmRelays = accountRelays.dmRelayList,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
accountRelays.consumePublishedEvent(event)
|
||||
accountRelays.setDmRelays(pickerRelays.toSet())
|
||||
},
|
||||
onDmRelaysUpdated = { accountRelays.setDmRelays(it) },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showDmRelayPicker = false }) {
|
||||
androidx.compose.material3.Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +209,7 @@ private fun CompactMessagesContent(
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
onShowNewDm: () -> Unit,
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
@@ -208,6 +248,7 @@ private fun CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
onShowRelayPicker = onShowRelayPicker,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -229,6 +270,7 @@ private fun SplitMessagesContent(
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
onShowNewDm: () -> Unit,
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
@@ -237,6 +279,7 @@ private fun SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
onShowRelayPicker = onShowRelayPicker,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.width(280.dp),
|
||||
)
|
||||
|
||||
+6
-9
@@ -217,6 +217,7 @@ internal fun RootContent(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
initialFeedMode = FeedMode.FOLLOWING,
|
||||
@@ -248,6 +249,7 @@ internal fun RootContent(
|
||||
localCache = localCache,
|
||||
relayManager = relayManager,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
account = account,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
)
|
||||
@@ -284,6 +286,7 @@ internal fun RootContent(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
initialFeedMode = FeedMode.GLOBAL,
|
||||
@@ -334,19 +337,12 @@ internal fun RootContent(
|
||||
}
|
||||
|
||||
DeckColumnType.Relays -> {
|
||||
val accountRelays =
|
||||
remember(iAccount, relayManager, scope) {
|
||||
com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays(
|
||||
iAccount.pubKey,
|
||||
relayManager,
|
||||
scope,
|
||||
)
|
||||
}
|
||||
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||
RelayDashboardScreen(
|
||||
relayManager = relayManager,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
nip65State = iAccount.nip65RelayList,
|
||||
accountRelays = accountRelays,
|
||||
accountRelays = accountRelays ?: return,
|
||||
signer = iAccount.signer,
|
||||
onPublish = { event -> relayManager.broadcastToAll(event) },
|
||||
)
|
||||
@@ -427,6 +423,7 @@ internal fun RootContent(
|
||||
localCache = localCache,
|
||||
relayManager = relayManager,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
account = account,
|
||||
initialQuery = "#${columnType.tag}",
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
|
||||
-7
@@ -80,13 +80,6 @@ fun BlockedRelayEditor(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
Text(
|
||||
"Existing blocked relay list is not loaded yet — saving will publish a new list.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Add relay input
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.relay
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
||||
|
||||
val LocalAccountRelays =
|
||||
staticCompositionLocalOf<DesktopAccountRelays?> {
|
||||
null
|
||||
}
|
||||
+13
-4
@@ -90,11 +90,19 @@ fun Nip65RelayEditor(
|
||||
}
|
||||
|
||||
LaunchedEffect(currentNip65Relays) {
|
||||
localRelays.clear()
|
||||
localRelays.addAll(currentNip65Relays)
|
||||
if (currentNip65Relays.isNotEmpty()) {
|
||||
localRelays.clear()
|
||||
localRelays.addAll(currentNip65Relays)
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
// Delay showing empty state to allow async cache load
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(500)
|
||||
if (!loaded) loaded = true
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
if (loaded && localRelays.isEmpty()) {
|
||||
Text(
|
||||
@@ -272,9 +280,10 @@ private fun tryAddNip65Relay(
|
||||
url: String,
|
||||
existing: MutableList<AdvertisedRelayInfo>,
|
||||
): String? {
|
||||
val error = validateRelayUrl(url)
|
||||
val input = normalizeRelayInput(url)
|
||||
val error = validateRelayUrl(input)
|
||||
if (error != null) return error
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())!!
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(input)!!
|
||||
if (existing.any { it.relayUrl.url == normalized.url }) {
|
||||
return "Relay already added"
|
||||
}
|
||||
|
||||
+22
-3
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RelayConfigTab(
|
||||
@@ -99,7 +100,16 @@ fun RelayConfigTab(
|
||||
Nip65RelayEditor(
|
||||
nip65State = nip65State,
|
||||
signer = signer,
|
||||
onPublish = onPublish,
|
||||
onPublish = { event ->
|
||||
onPublish(event)
|
||||
// Consume locally so nip65State updates immediately
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
nip65State.cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
// Also persist relay event for restart survival
|
||||
accountRelays.consumePublishedEvent(event)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,7 +138,11 @@ fun RelayConfigTab(
|
||||
SearchRelayEditor(
|
||||
localRelays = searchRelayState,
|
||||
signer = signer,
|
||||
onPublish = onPublish,
|
||||
onPublish = { event ->
|
||||
onPublish(event)
|
||||
accountRelays.consumePublishedEvent(event)
|
||||
accountRelays.setSearchRelays(searchRelayState.toSet())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,7 +156,12 @@ fun RelayConfigTab(
|
||||
BlockedRelayEditor(
|
||||
localRelays = blockedRelayState,
|
||||
signer = signer,
|
||||
onPublish = onPublish,
|
||||
onPublish = { event ->
|
||||
onPublish(event)
|
||||
// Don't call consumePublishedEvent — blocked relays use private tags,
|
||||
// publicRelays() returns empty and would overwrite the correct value
|
||||
accountRelays.setBlockedRelays(blockedRelayState.toSet())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -27,6 +27,8 @@ import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -62,8 +64,19 @@ fun RelayDashboardScreen(
|
||||
var selectedTab by remember { mutableStateOf(DashboardTab.MONITOR) }
|
||||
|
||||
// Hoisted state — survives Monitor ↔ Configure tab switches
|
||||
// Synced from accountRelays flows (persistence + bootstrap + per-screen picker)
|
||||
val searchRelayState = remember { mutableStateListOf<NormalizedRelayUrl>() }
|
||||
val blockedRelayState = remember { mutableStateListOf<NormalizedRelayUrl>() }
|
||||
val currentSearchRelays by accountRelays.searchRelayList.collectAsState()
|
||||
val currentBlockedRelays by accountRelays.blockedRelayList.collectAsState()
|
||||
LaunchedEffect(currentSearchRelays) {
|
||||
searchRelayState.clear()
|
||||
searchRelayState.addAll(currentSearchRelays.sortedBy { it.url })
|
||||
}
|
||||
LaunchedEffect(currentBlockedRelays) {
|
||||
blockedRelayState.clear()
|
||||
blockedRelayState.addAll(currentBlockedRelays.sortedBy { it.url })
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
PrimaryTabRow(selectedTabIndex = DashboardTab.entries.indexOf(selectedTab)) {
|
||||
|
||||
+4
-3
@@ -159,12 +159,13 @@ private fun tryAddRelay(
|
||||
existing: List<NormalizedRelayUrl>,
|
||||
onAdd: (String) -> NormalizedRelayUrl?,
|
||||
): String? {
|
||||
val error = validateRelayUrl(url)
|
||||
val input = normalizeRelayInput(url)
|
||||
val error = validateRelayUrl(input)
|
||||
if (error != null) return error
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) ?: return "Invalid relay URL"
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(input) ?: return "Invalid relay URL"
|
||||
if (existing.any { it.url == normalized.url }) {
|
||||
return "Relay already added"
|
||||
}
|
||||
onAdd(url.trim())
|
||||
onAdd(input)
|
||||
return null
|
||||
}
|
||||
|
||||
+26
-12
@@ -24,21 +24,34 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
/**
|
||||
* Validates and adds a relay URL to a mutable list.
|
||||
* Returns an error message string if validation fails, null on success.
|
||||
* Normalizes a relay URL input — auto-prefixes wss:// if no scheme given.
|
||||
* Returns the normalized URL string ready for validation.
|
||||
*/
|
||||
internal fun normalizeRelayInput(url: String): String {
|
||||
val trimmed = url.trim()
|
||||
return when {
|
||||
trimmed.startsWith("wss://") || trimmed.startsWith("ws://") -> trimmed
|
||||
trimmed.contains(".onion") -> "ws://$trimmed"
|
||||
trimmed.contains(".") -> "wss://$trimmed"
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a relay URL. Returns error message or null on success.
|
||||
* Auto-prefixes wss:// if no scheme given (e.g., "nos.lol" → "wss://nos.lol").
|
||||
*/
|
||||
internal fun validateRelayUrl(url: String): String? {
|
||||
val trimmed = url.trim()
|
||||
if (trimmed.isBlank()) return "Enter a relay URL"
|
||||
if (!trimmed.startsWith("wss://") && !trimmed.startsWith("ws://")) {
|
||||
return "URL must start with wss:// or ws://"
|
||||
val input = normalizeRelayInput(url)
|
||||
if (input.isBlank()) return "Enter a relay URL"
|
||||
if (!input.startsWith("wss://") && !input.startsWith("ws://")) {
|
||||
return "Invalid relay URL"
|
||||
}
|
||||
if (trimmed.startsWith("ws://") && !trimmed.contains(".onion")) {
|
||||
if (input.startsWith("ws://") && !input.contains(".onion")) {
|
||||
return "Use wss:// — unencrypted ws:// exposes traffic to observers"
|
||||
}
|
||||
// Must have a domain with at least one dot (foo is not a valid relay)
|
||||
val host =
|
||||
trimmed
|
||||
input
|
||||
.removePrefix("wss://")
|
||||
.removePrefix("ws://")
|
||||
.split("/")
|
||||
@@ -46,7 +59,7 @@ internal fun validateRelayUrl(url: String): String? {
|
||||
if (!host.contains(".")) {
|
||||
return "Invalid domain — must contain at least one dot (e.g., relay.example.com)"
|
||||
}
|
||||
if (RelayUrlNormalizer.normalizeOrNull(trimmed) == null) {
|
||||
if (RelayUrlNormalizer.normalizeOrNull(input) == null) {
|
||||
return "Invalid relay URL"
|
||||
}
|
||||
return null
|
||||
@@ -56,9 +69,10 @@ internal fun tryAddSimpleRelay(
|
||||
url: String,
|
||||
existing: MutableList<NormalizedRelayUrl>,
|
||||
): String? {
|
||||
val error = validateRelayUrl(url)
|
||||
val input = normalizeRelayInput(url)
|
||||
val error = validateRelayUrl(input)
|
||||
if (error != null) return error
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim())!!
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(input)!!
|
||||
if (existing.any { it.url == normalized.url }) {
|
||||
return "Relay already added"
|
||||
}
|
||||
|
||||
+12
-7
@@ -36,6 +36,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -80,13 +81,6 @@ fun SearchRelayEditor(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
Text(
|
||||
"Existing search relay list is not loaded yet — saving will publish a new list.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Add relay input
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -194,6 +188,17 @@ fun SearchRelayEditor(
|
||||
Text("Save")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
localRelays.clear()
|
||||
com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories.DEFAULT_SEARCH_RELAYS.let {
|
||||
localRelays.addAll(it)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Reset to defaults")
|
||||
}
|
||||
|
||||
savedMessage?.let {
|
||||
Text(
|
||||
it,
|
||||
|
||||
Reference in New Issue
Block a user