Merge pull request #2519 from nrobi144/feat/relay-power-tools

feat(desktop): Relay Power Tools — Dashboard, Config Editors, Subscription Wiring
This commit is contained in:
Vitor Pamplona
2026-04-23 08:41:22 -04:00
committed by GitHub
39 changed files with 4124 additions and 135 deletions
@@ -74,10 +74,12 @@ import androidx.compose.ui.window.rememberWindowState
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
@@ -103,10 +105,18 @@ import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
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.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.CoroutineScope
@@ -116,6 +126,8 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -225,6 +237,8 @@ fun main() {
},
)
}
// Callback set by App() for single pane navigation from MenuBar
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
Window(
onCloseRequest = ::exitApplication,
@@ -351,6 +365,26 @@ fun main() {
},
onClick = { showAppDrawer = !showAppDrawer },
)
Item(
"Relay Dashboard",
shortcut =
if (isMacOS) {
KeyShortcut(Key.R, meta = true, shift = true)
} else {
KeyShortcut(Key.R, ctrl = true, shift = true)
},
onClick = {
if (layoutMode == LayoutMode.DECK) {
if (deckState.hasColumnOfType(DeckColumnType.Relays)) {
deckState.focusExistingColumn(DeckColumnType.Relays)
} else {
deckState.addColumn(DeckColumnType.Relays)
}
} else {
navigateToScreen?.invoke(DeckColumnType.Relays)
}
},
)
Separator()
Item(
if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
@@ -467,6 +501,7 @@ fun main() {
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) })
Item("Relays", onClick = { deckState.addColumn(DeckColumnType.Relays) })
}
}
}
@@ -511,6 +546,7 @@ fun main() {
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
initialTorSettings = torSettings,
onNavigateToScreen = { navigateToScreen = it },
)
}
}
@@ -538,10 +574,16 @@ fun App(
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit = {},
) {
val singlePaneState = remember { SinglePaneState() }
val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } }
// Register single pane navigation callback for MenuBar shortcuts
LaunchedEffect(singlePaneState) {
onNavigateToScreen { screen -> singlePaneState.navigate(screen) }
}
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings
var torSettings by remember {
mutableStateOf(
@@ -631,6 +673,12 @@ fun App(
}
val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) }
val nip11Fetcher = remember { Nip11Fetcher() }
// Start 1Hz metrics snapshot for relay dashboard
LaunchedEffect(relayManager) {
relayManager.startMetricsSnapshot(this)
}
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
@@ -755,6 +803,7 @@ fun App(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
@@ -850,6 +899,7 @@ fun MainContent(
account: AccountState.LoggedIn,
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
nip11Fetcher: Nip11Fetcher,
appScope: CoroutineScope,
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
onShowComposeDialog: () -> Unit,
@@ -866,9 +916,27 @@ fun MainContent(
remember(relayManager) {
DmSendTracker(relayManager.client)
}
// 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) {
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
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) {
DesktopRelayCategories(
nip65State = iAccount.nip65RelayList,
accountRelays = accountRelays,
connectedRelays = relayManager.connectedRelays,
scope = scope,
)
}
val highlightStore = remember { DesktopHighlightStore(appScope) }
@@ -878,19 +946,63 @@ fun MainContent(
.DesktopDraftStore(appScope)
}
// Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006)
// Uses DisposableEffect to clean up subscription on account change
DisposableEffect(accountRelays) {
val bootstrapSubId = "bootstrap-relay-config"
scope.launch {
val connected =
withTimeoutOrNull(30.seconds) {
relayManager.connectedRelays.first { it.isNotEmpty() }
}
if (connected != null) {
val filter =
Filter(
kinds =
listOf(
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
BlockedRelayListEvent.KIND,
),
authors = listOf(account.pubKeyHex),
limit = 4,
)
relayManager.subscribe(
subId = bootstrapSubId,
filters = listOf(filter),
listener =
object : SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// 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)
}
},
)
}
}
onDispose { relayManager.unsubscribe(bootstrapSubId) }
}
// Subscribe to incoming DMs and process into chatroomList
LaunchedEffect(account) {
relayManager.connectedRelays.first { it.isNotEmpty() }
val dmRelayState =
DesktopDmRelayState(
dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()),
connectedRelays = relayManager.connectedRelays,
scope = scope,
)
subscriptionsCoordinator.subscribeToDms(
userPubKeyHex = account.pubKeyHex,
dmRelayState = dmRelayState,
dmRelayState = accountRelays.dmRelays,
onDmEvent = { event, relay ->
// Store raw event in cache
val note = localCache.getOrCreateNote(event.id)
@@ -984,91 +1096,105 @@ fun MainContent(
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize().weight(1f)) {
when (layoutMode) {
LayoutMode.SINGLE_PANE -> {
val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState()
SinglePaneLayout(
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
onOpenAppDrawer = onShowAppDrawer,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
lastRelayEventAt = lastRelayEvent,
modifier = Modifier.weight(1f),
)
}
LayoutMode.DECK -> {
if (!isImmersive) {
DeckSidebar(
onAddColumn = onShowAppDrawer,
onOpenSettings = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
CompositionLocalProvider(
LocalRelayCategories provides relayCategories,
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize().weight(1f)) {
when (layoutMode) {
LayoutMode.SINGLE_PANE -> {
val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState()
SinglePaneLayout(
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
nip11Fetcher = nip11Fetcher,
appScope = appScope,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
onOpenAppDrawer = onShowAppDrawer,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
torStatus = torStatus,
lastRelayEventAt = lastRelayEvent,
modifier = Modifier.weight(1f),
)
VerticalDivider()
}
DeckLayout(
deckState = deckState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
modifier = Modifier.weight(1f),
)
LayoutMode.DECK -> {
if (!isImmersive) {
DeckSidebar(
onAddColumn = onShowAppDrawer,
onOpenSettings = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
torStatus = torStatus,
)
VerticalDivider()
}
DeckLayout(
deckState = deckState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
nip11Fetcher = nip11Fetcher,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToRelays = {
if (deckState.hasColumnOfType(DeckColumnType.Relays)) {
deckState.focusExistingColumn(DeckColumnType.Relays)
} else {
deckState.addColumn(DeckColumnType.Relays)
}
},
modifier = Modifier.weight(1f),
)
}
}
}
} // end Row
} // end Row
// Persistent media control bar
// Persistent media control bar
com.vitorpamplona.amethyst.desktop.ui.media
.NowPlayingBar()
} // end Column
// Global fullscreen video overlay
com.vitorpamplona.amethyst.desktop.ui.media
.NowPlayingBar()
} // end Column
.GlobalFullscreenOverlay()
// Global fullscreen video overlay
com.vitorpamplona.amethyst.desktop.ui.media
.GlobalFullscreenOverlay()
// Snackbar for zap feedback
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
)
}
// Snackbar for zap feedback
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
)
}
} // end CompositionLocalProvider
}
@Composable
@@ -545,7 +545,17 @@ class DesktopLocalCache : ICacheProvider {
// ----- Own event consumption -----
override fun justConsumeMyOwnEvent(event: Event): Boolean {
// Desktop doesn't track own events separately
// For addressable/replaceable events, store in the addressable note cache
// so state holders (Nip65RelayListState, etc.) pick it up via their flows
if (event is com.vitorpamplona.quartz.nip01Core.core.AddressableEvent) {
val address = event.address()
val note = getOrCreateAddressableNote(address)
val author = getOrCreateUser(event.pubKey) ?: return false
if (note.event == null || (note.event?.createdAt ?: 0) <= event.createdAt) {
note.loadEvent(event, author, emptyList())
return true
}
}
return false
}
@@ -24,11 +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.
@@ -51,10 +58,25 @@ 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()
/** User-configured search relays from kind 10007 events */
private val _searchRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val searchRelayList: StateFlow<Set<NormalizedRelayUrl>> = _searchRelayList.asStateFlow()
/** User-configured blocked relays from kind 10006 events */
private val _blockedRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val blockedRelayList: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelayList.asStateFlow()
// Track created_at to prevent stale overwrites (thread-safe)
private val lastDmCreatedAt = AtomicLong(0L)
private val lastSearchCreatedAt = AtomicLong(0L)
private val lastBlockedCreatedAt = AtomicLong(0L)
/** Aggregated DM relay state (DM relays + fallback to connected relays) */
val dmRelays =
DesktopDmRelayState(
@@ -63,32 +85,166 @@ class DesktopAccountRelays(
scope = scope,
)
/**
* Processes a ChatMessageRelayListEvent (kind 10050) to update DM relays.
* Call this when receiving kind 10050 events from relay subscriptions.
*/
fun consumeDmRelayList(event: ChatMessageRelayListEvent) {
if (event.pubKey != userPubKeyHex) return
val relays = event.relays().toSet()
_dmRelayList.value = relays
init {
loadFromPersistence()
}
/**
* Processes any event that might be a DM relay list.
* Returns true if the event was consumed as a DM relay list.
*/
fun consumeIfDmRelayList(event: Event): Boolean {
if (event.kind == ChatMessageRelayListEvent.KIND && event is ChatMessageRelayListEvent) {
consumeDmRelayList(event)
return true
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
}
return false
}
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 }
}
/**
* Manually sets DM relays (e.g., from saved preferences).
* Routes relay config events (kinds 10050, 10007, 10006) to the appropriate handler.
* Returns true if the event was consumed.
* Uses created_at checking to prevent stale overwrites.
*/
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()) {
lastDmCreatedAt.set(event.createdAt)
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()) {
lastSearchCreatedAt.set(event.createdAt)
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()) {
lastBlockedCreatedAt.set(event.createdAt)
// 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
}
else -> {
false
}
}
}
/** 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(TimeUtils.now())
_dmRelayList.value = relays
saveRelayUrls("dm", relays)
}
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) {
lastSearchCreatedAt.set(TimeUtils.now())
_searchRelayList.value = relays
saveRelayUrls("search", relays)
}
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) {
lastBlockedCreatedAt.set(TimeUtils.now())
_blockedRelayList.value = relays
saveRelayUrls("blocked", relays)
}
private fun saveRelayUrls(
category: String,
relays: Set<NormalizedRelayUrl>,
) {
try {
val key = "urls_${category}_${userPubKeyHex.take(16)}"
prefs.put(key, relays.joinToString(",") { it.url })
prefs.flush()
} catch (_: Exception) {
}
}
private fun loadRelayUrls(category: String): Set<NormalizedRelayUrl> {
val key = "urls_${category}_${userPubKeyHex.take(16)}"
val csv = prefs.get(key, "") ?: return emptySet()
if (csv.isBlank()) return emptySet()
return csv.split(",").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }.toSet()
}
}
@@ -70,6 +70,7 @@ class DesktopIAccount(
private val relayManager: RelayConnectionManager,
val dmSendTracker: DmSendTracker,
private val scope: CoroutineScope,
private val accountRelays: DesktopAccountRelays? = null,
) : IAccount {
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
@@ -98,9 +99,12 @@ class DesktopIAccount(
localCache,
scope,
object : Nip65RelayListRepository {
override val backupNIP65RelayList: AdvertisedRelayListEvent? = null
override val backupNIP65RelayList: AdvertisedRelayListEvent? =
accountRelays?.loadPersistedNip65Event()
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) { /* no persistence yet */ }
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) {
accountRelays?.consumePublishedEvent(event)
}
override val defaultOutboxRelays = relayManager.connectedRelays.value
override val defaultInboxRelays = relayManager.connectedRelays.value
@@ -0,0 +1,105 @@
/*
* 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.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
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
/**
* Aggregates relay categories for desktop subscriptions.
*
* Each category combines user-configured relays with fallbacks and subtracts blocked relays.
* 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,
) {
/** 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.ifEmpty { defaultRelays } }) - blocked
}.debounce(300)
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, defaultRelays) // NEVER empty
/** 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.ifEmpty { defaultRelays } }) - blocked
}.debounce(300)
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
/** 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(300)
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
/** DM relays: kind 10050 → defaultRelays, minus blocked */
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> =
combine(
accountRelays.dmRelays.flow,
accountRelays.blockedRelayList,
) { dm, blocked ->
(dm.ifEmpty { defaultRelays }) - blocked
}.debounce(300)
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
companion object {
val DEFAULT_SEARCH_RELAYS =
setOfNotNull(RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band"))
}
}
@@ -0,0 +1,101 @@
/*
* 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.network
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
/**
* Fetches and caches NIP-11 relay information documents.
* Session-only cache (no disk persistence).
*
* Uses fail-closed HTTP client to prevent IP leaks during Tor bootstrap.
* Limits response body to 256KB to prevent DoS from malicious relays.
* Deduplicates concurrent fetches via per-URL Mutex.
*/
class Nip11Fetcher {
private val cache = ConcurrentHashMap<NormalizedRelayUrl, Nip11RelayInformation>()
private val locks = ConcurrentHashMap<NormalizedRelayUrl, Mutex>()
companion object {
private const val MAX_RESPONSE_BYTES = 256 * 1024L
private val SEMAPHORE = Semaphore(5)
}
suspend fun fetch(url: NormalizedRelayUrl): Nip11RelayInformation? {
cache[url]?.let { return it }
val mutex = locks.getOrPut(url) { Mutex() }
return mutex.withLock {
cache[url]?.let { return it } // double-check after lock
SEMAPHORE
.withPermit {
withContext(Dispatchers.IO) {
fetchFromNetwork(url)
}
}?.also { info ->
cache[url] = info
}
}
}
private fun fetchFromNetwork(url: NormalizedRelayUrl): Nip11RelayInformation? {
// FAIL-CLOSED: use currentClient() not getHttpClient()
val client = DesktopHttpClient.currentClient()
val httpUrl = url.toHttp()
val request =
Request
.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val source = response.body.source()
source.request(MAX_RESPONSE_BYTES) // buffer up to limit
val body = source.readUtf8()
Nip11RelayInformation.fromJson(body)
} else {
null
}
}
} catch (_: Exception) {
null
}
}
fun getCached(url: NormalizedRelayUrl): Nip11RelayInformation? = cache[url]
fun clearCache() {
cache.clear()
}
}
@@ -25,15 +25,27 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
data class RelayMetrics(
val eventCount: Long = 0,
val lastEventAt: Long? = null,
)
/**
* Manages Nostr relay connections, subscriptions, and status tracking.
@@ -55,10 +67,30 @@ open class RelayConnectionManager(
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.availableRelaysFlow()
// Relays explicitly removed by user — suppress status updates from pool callbacks
private val removedRelays = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
// Hot metrics — written on every event, no StateFlow emission
private val rawMetricsMap = ConcurrentHashMap<NormalizedRelayUrl, RelayMetrics>()
// Throttled snapshot — 1Hz emission for UI
private val _relayMetrics = MutableStateFlow<Map<NormalizedRelayUrl, RelayMetrics>>(emptyMap())
val relayMetrics: StateFlow<Map<NormalizedRelayUrl, RelayMetrics>> = _relayMetrics.asStateFlow()
init {
_client.addConnectionListener(this)
}
/** Start 1Hz metrics snapshot. Call once after connect(). */
fun startMetricsSnapshot(scope: CoroutineScope) {
scope.launch {
while (isActive) {
delay(1_000)
_relayMetrics.value = HashMap(rawMetricsMap)
}
}
}
fun connect() {
_client.connect()
}
@@ -69,12 +101,14 @@ open class RelayConnectionManager(
fun addRelay(url: String): NormalizedRelayUrl? {
val normalized = RelayUrlNormalizer.Companion.normalizeOrNull(url) ?: return null
removedRelays.remove(normalized)
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
return normalized
}
fun removeRelay(url: NormalizedRelayUrl) {
_relayStatuses.value = _relayStatuses.value - url
removedRelays.add(url)
_relayStatuses.update { it - url }
}
fun addDefaultRelays() {
@@ -167,13 +201,13 @@ open class RelayConnectionManager(
private fun updateRelayStatus(
url: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus,
transform: (RelayStatus) -> RelayStatus,
) {
_relayStatuses.value =
_relayStatuses.value.toMutableMap().apply {
val current = this[url] ?: RelayStatus(url, connected = false)
this[url] = update(current)
}
if (url in removedRelays) return
_relayStatuses.update { current ->
val existing = current[url] ?: RelayStatus(url, connected = false)
current + (url to transform(existing))
}
}
override fun onConnecting(relay: IRelayClient) {
@@ -212,7 +246,14 @@ open class RelayConnectionManager(
msgStr: String,
msg: Message,
) {
// Events are handled by subscription listeners
// Only count EVENT messages, not EOSE/OK/NOTICE/AUTH
if (msg !is EventMessage) return
rawMetricsMap.compute(relay.url) { _, m ->
RelayMetrics(
eventCount = (m?.eventCount ?: 0) + 1,
lastEventAt = System.currentTimeMillis(),
)
}
}
override fun onSent(
@@ -57,10 +57,13 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker
import com.vitorpamplona.amethyst.desktop.service.upload.UploadResult
import com.vitorpamplona.amethyst.desktop.ui.compose.ComposeRelayPicker
import com.vitorpamplona.amethyst.desktop.ui.compose.RelayPickerState
import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
@@ -104,6 +107,15 @@ fun ComposeNoteDialog(
var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) }
var postAsPicture by remember { mutableStateOf(false) }
// Relay picker state
val connectedRelays by relayManager.connectedRelays.collectAsState()
val allRelays by relayManager.availableRelays.collectAsState()
val pickerState =
remember(allRelays, connectedRelays) {
RelayPickerState(allRelays = allRelays, connectedRelays = connectedRelays)
}
var selectedRelays by remember(connectedRelays) { mutableStateOf(connectedRelays) }
// Drag-and-drop state
var isDragOver by remember { mutableStateOf(false) }
val dropTarget =
@@ -255,7 +267,22 @@ fun ComposeNoteDialog(
)
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(8.dp))
ComposeRelayPicker(
pickerState = pickerState,
selectedRelays = selectedRelays,
onToggleRelay = { url ->
selectedRelays =
if (url in selectedRelays) {
selectedRelays - url
} else {
selectedRelays + url
}
},
)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
@@ -316,6 +343,7 @@ fun ComposeNoteDialog(
images = pictureMetas,
account = account,
relayManager = relayManager,
relays = selectedRelays,
)
} else {
val imetaTags = buildIMetaTags(uploadResults)
@@ -325,6 +353,7 @@ fun ComposeNoteDialog(
relayManager = relayManager,
replyTo = replyTo,
imetaTags = imetaTags,
relays = selectedRelays,
)
}
onDismiss()
@@ -470,6 +499,7 @@ private suspend fun publishPicture(
images: List<com.vitorpamplona.quartz.nip68Picture.PictureMeta>,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
relays: Set<NormalizedRelayUrl>,
) {
withContext(Dispatchers.IO) {
if (account.isReadOnly) {
@@ -485,7 +515,7 @@ private suspend fun publishPicture(
}
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
relayManager.publish(signedEvent, relays)
}
}
@@ -495,6 +525,7 @@ private suspend fun publishNote(
relayManager: DesktopRelayConnectionManager,
replyTo: Event?,
imetaTags: List<IMetaTag> = emptyList(),
relays: Set<NormalizedRelayUrl>,
) {
withContext(Dispatchers.IO) {
if (account.isReadOnly) {
@@ -518,6 +549,6 @@ private suspend fun publishNote(
}
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
relayManager.publish(signedEvent, relays)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -37,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
@@ -80,6 +84,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
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
@@ -87,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>,
@@ -260,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,
@@ -267,6 +278,7 @@ fun FeedScreen(
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
onNavigateToRelays: () -> Unit = {},
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
@@ -275,8 +287,13 @@ fun FeedScreen(
// Available relay URLs — subscribe triggers connection on-demand
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
// Feed relays from relay categories (NIP-65 outbox, minus blocked, with fallback)
val relayCategories = LocalRelayCategories.current
val feedRelays by relayCategories.feedRelays.collectAsState()
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
@@ -295,13 +312,13 @@ fun FeedScreen(
}
// Subscribe to feed events (kind 1) — populates cache via coordinator
rememberSubscription(allRelayUrls, feedMode, followedUsers, relayManager = relayManager) {
if (allRelayUrls.isEmpty()) return@rememberSubscription null
rememberSubscription(feedRelays, feedMode, followedUsers, relayManager = relayManager) {
if (feedRelays.isEmpty()) return@rememberSubscription null
when (feedMode) {
FeedMode.GLOBAL -> {
createGlobalFeedSubscription(
relays = allRelayUrls,
relays = feedRelays,
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
},
@@ -312,7 +329,7 @@ fun FeedScreen(
val follows = followedUsers.toList()
if (follows.isNotEmpty()) {
createFollowingFeedSubscription(
relays = allRelayUrls,
relays = feedRelays,
followedUsers = follows,
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
@@ -481,7 +498,7 @@ fun FeedScreen(
FeedHeader(
feedMode = feedMode,
account = account,
connectedRelays = connectedRelays,
feedRelays = feedRelays,
followedUsersCount = followedUsers.size,
onFeedModeChange = { mode ->
feedMode = mode
@@ -489,6 +506,8 @@ fun FeedScreen(
},
onRefresh = { relayManager.connect() },
onCompose = onCompose,
onNavigateToRelays = onNavigateToRelays,
onOpenRelayPicker = { showRelayPicker = true },
)
Spacer(Modifier.height(8.dp))
@@ -571,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(
@@ -592,11 +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),
@@ -633,9 +681,11 @@ 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.onSurfaceVariant,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier.clickable { onNavigateToRelays() },
)
if (feedMode == FeedMode.FOLLOWING) {
Text(
@@ -645,6 +695,20 @@ private fun FeedHeader(
)
}
Spacer(Modifier.width(8.dp))
if (account != null && !account.isReadOnly) {
IconButton(
onClick = onOpenRelayPicker,
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Dns,
contentDescription = "Edit Feed Relays",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
Spacer(Modifier.width(4.dp))
}
IconButton(
onClick = onRefresh,
modifier = Modifier.size(24.dp),
@@ -44,12 +44,14 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
@@ -64,6 +66,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -91,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchResult
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -100,6 +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
@@ -111,6 +118,7 @@ fun SearchScreen(
localCache: DesktopLocalCache,
relayManager: DesktopRelayConnectionManager,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
account: AccountState.LoggedIn? = null,
initialQuery: String = "",
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
@@ -118,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) {
@@ -131,6 +141,8 @@ fun SearchScreen(
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
val relayCategories = LocalRelayCategories.current
val searchRelays by relayCategories.searchRelays.collectAsState()
val displayText by state.displayText.collectAsState()
// Track TextFieldValue locally to preserve cursor position
var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) }
@@ -160,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")
}
@@ -171,9 +183,9 @@ fun SearchScreen(
}
}
// NIP-50 people search subscription (use allRelayUrls — subscribe will connect)
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
// NIP-50 people search subscription (use searchRelays from relay categories)
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
if (searchRelays.isEmpty() || debouncedQuery.isEmpty) {
return@rememberSubscription null
}
if (bech32Results.isNotEmpty()) return@rememberSubscription null
@@ -183,7 +195,7 @@ fun SearchScreen(
}
createSearchPeopleSubscription(
relays = allRelayUrls,
relays = searchRelays,
searchQuery =
debouncedQuery.text.ifBlank {
QuerySerializer.serialize(debouncedQuery)
@@ -212,9 +224,9 @@ fun SearchScreen(
)
}
// NIP-50 advanced note search subscription (use allRelayUrls)
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
// NIP-50 advanced note search subscription (use searchRelays from relay categories)
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
if (searchRelays.isEmpty() || debouncedQuery.isEmpty) {
return@rememberSubscription null
}
if (bech32Results.isNotEmpty()) return@rememberSubscription null
@@ -225,7 +237,7 @@ fun SearchScreen(
SubscriptionConfig(
subId = generateSubId("adv-search"),
filters = filters,
relays = allRelayUrls,
relays = searchRelays,
onEvent = { event, _, relay, _ ->
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
if (state.trackRelayEvent(relay.url, event.id)) {
@@ -382,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,
@@ -396,6 +417,36 @@ fun SearchScreen(
}
}
// Search relay picker dialog
if (showRelayPicker && account != null) {
val pickerRelays =
remember {
mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
it.addAll(searchRelays)
}
}
AlertDialog(
onDismissRequest = { showRelayPicker = false },
title = { Text("Search Relays") },
text = {
SearchRelayEditor(
localRelays = pickerRelays,
signer = account.signer,
onPublish = { event ->
relayManager.broadcastToAll(event)
accountRelays?.consumePublishedEvent(event)
accountRelays?.setSearchRelays(pickerRelays.toSet())
},
)
},
confirmButton = {
TextButton(onClick = { showRelayPicker = false }) {
Text("Close")
}
},
)
}
// Expandable advanced panel
AnimatedVisibility(
visible = panelExpanded,
@@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Group
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
@@ -91,6 +92,7 @@ fun ConversationListPane(
selectedRoom: ChatroomKey?,
onConversationSelected: (ChatroomKey) -> Unit,
onNewConversation: () -> Unit = {},
onShowRelayPicker: () -> Unit = {},
focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier,
) {
@@ -179,6 +181,17 @@ fun ConversationListPane(
color = MaterialTheme.colorScheme.onBackground,
)
IconButton(
onClick = onShowRelayPicker,
modifier = Modifier.size(32.dp),
) {
Icon(
Icons.Default.Dns,
contentDescription = "DM Relays",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
IconButton(
onClick = onNewConversation,
modifier = Modifier.size(32.dp),
@@ -85,6 +85,8 @@ fun DesktopMessagesScreen(
onNavigateToProfile: (String) -> Unit = {},
) {
val scope = rememberCoroutineScope()
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
var showDmRelayPicker by remember { mutableStateOf(false) }
val listState =
remember(account) {
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
@@ -110,6 +112,11 @@ fun DesktopMessagesScreen(
true
}
event.key == Key.R && isModifier && event.isShiftPressed -> {
showDmRelayPicker = true
true
}
else -> {
false
}
@@ -126,6 +133,7 @@ fun DesktopMessagesScreen(
onNavigateToProfile = onNavigateToProfile,
listFocusRequester = listFocusRequester,
onShowNewDm = { showNewDmDialog = true },
onShowRelayPicker = { showDmRelayPicker = true },
keyHandler = keyHandler,
)
} else {
@@ -138,6 +146,7 @@ fun DesktopMessagesScreen(
onNavigateToProfile = onNavigateToProfile,
listFocusRequester = listFocusRequester,
onShowNewDm = { showNewDmDialog = true },
onShowRelayPicker = { showDmRelayPicker = true },
keyHandler = keyHandler,
)
}
@@ -154,6 +163,36 @@ fun DesktopMessagesScreen(
onDismiss = { showNewDmDialog = false },
)
}
if (showDmRelayPicker && accountRelays != null) {
val pickerRelays =
remember {
androidx.compose.runtime.mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
it.addAll(accountRelays.dmRelayList.value.sortedBy { r -> r.url })
}
}
androidx.compose.material3.AlertDialog(
onDismissRequest = { showDmRelayPicker = false },
title = { androidx.compose.material3.Text("DM Relays") },
text = {
com.vitorpamplona.amethyst.desktop.ui.relay.DmRelayEditor(
dmRelays = accountRelays.dmRelayList,
signer = account.signer,
onPublish = { event ->
relayManager.broadcastToAll(event)
accountRelays.consumePublishedEvent(event)
accountRelays.setDmRelays(pickerRelays.toSet())
},
onDmRelaysUpdated = { accountRelays.setDmRelays(it) },
)
},
confirmButton = {
androidx.compose.material3.TextButton(onClick = { showDmRelayPicker = false }) {
androidx.compose.material3.Text("Close")
}
},
)
}
}
/**
@@ -170,6 +209,7 @@ private fun CompactMessagesContent(
onNavigateToProfile: (String) -> Unit,
listFocusRequester: FocusRequester,
onShowNewDm: () -> Unit,
onShowRelayPicker: () -> Unit = {},
keyHandler: Modifier,
) {
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
@@ -208,6 +248,7 @@ private fun CompactMessagesContent(
selectedRoom = selectedRoom,
onConversationSelected = { listState.selectRoom(it) },
onNewConversation = onShowNewDm,
onShowRelayPicker = onShowRelayPicker,
focusRequester = listFocusRequester,
modifier = Modifier.fillMaxWidth(),
)
@@ -229,6 +270,7 @@ private fun SplitMessagesContent(
onNavigateToProfile: (String) -> Unit,
listFocusRequester: FocusRequester,
onShowNewDm: () -> Unit,
onShowRelayPicker: () -> Unit = {},
keyHandler: Modifier,
) {
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
@@ -237,6 +279,7 @@ private fun SplitMessagesContent(
selectedRoom = selectedRoom,
onConversationSelected = { listState.selectRoom(it) },
onNewConversation = onShowNewDm,
onShowRelayPicker = onShowRelayPicker,
focusRequester = listFocusRequester,
modifier = Modifier.width(280.dp),
)
@@ -0,0 +1,146 @@
/*
* 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.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@Immutable
data class RelayPickerState(
val allRelays: Set<NormalizedRelayUrl>,
val connectedRelays: Set<NormalizedRelayUrl>,
) {
companion object {
val EMPTY = RelayPickerState(emptySet(), emptySet())
}
}
@Composable
fun ComposeRelayPicker(
pickerState: RelayPickerState,
selectedRelays: Set<NormalizedRelayUrl>,
onToggleRelay: (NormalizedRelayUrl) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
// Collapsed header
Row(
modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (expanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
contentDescription = null,
)
Spacer(Modifier.width(4.dp))
Text(
"Relays (${selectedRelays.size} of ${pickerState.allRelays.size})",
style = MaterialTheme.typography.bodyMedium,
)
}
// Collapsed: show chips
if (!expanded) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(start = 28.dp),
) {
items(selectedRelays.toList().sortedBy { it.url }, key = { it.url }) { url ->
AssistChip(
onClick = {},
label = { Text(url.displayUrl(), style = MaterialTheme.typography.labelSmall) },
)
}
}
}
// Expanded: scrollable checkboxes
AnimatedVisibility(expanded) {
val sortedRelays =
remember(pickerState.allRelays) {
pickerState.allRelays.sortedBy { it.url }
}
LazyColumn(
modifier = Modifier.padding(start = 28.dp).heightIn(max = 200.dp),
) {
items(sortedRelays, key = { it.url }) { url ->
val connected = url in pickerState.connectedRelays
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = url in selectedRelays,
onCheckedChange = { onToggleRelay(url) },
enabled = connected,
)
Text(
url.displayUrl(),
color =
if (connected) {
LocalContentColor.current
} else {
MaterialTheme.colorScheme.outline
},
)
if (!connected) {
Text(
" (disconnected)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
}
}
}
}
@@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Groups
@@ -114,6 +115,7 @@ enum class ScreenCategory(
DISCOVERY("Discovery", Icons.Default.Explore),
IDENTITY("Identity", Icons.Default.Person),
PLAY("Play", Icons.Default.SportsEsports),
NETWORK("Network", Icons.Default.Dns),
}
// -- Extensions on DeckColumnType --
@@ -143,6 +145,8 @@ fun DeckColumnType.category(): ScreenCategory =
DeckColumnType.Chess -> ScreenCategory.PLAY
DeckColumnType.Relays -> ScreenCategory.NETWORK
// Deep-link types — not in LAUNCHABLE_SCREENS but need a category for exhaustiveness
is DeckColumnType.Profile,
is DeckColumnType.Thread,
@@ -181,6 +185,7 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
DeckColumnType.Bookmarks,
DeckColumnType.MyProfile,
DeckColumnType.Settings,
DeckColumnType.Relays,
DeckColumnType.Chess,
)
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Home
@@ -132,6 +133,7 @@ fun DeckColumnType.icon(): ImageVector =
DeckColumnType.MyProfile -> Icons.Default.Person
DeckColumnType.Chess -> Icons.Default.Extension
DeckColumnType.Settings -> Icons.Default.Settings
DeckColumnType.Relays -> Icons.Default.Dns
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -99,10 +101,12 @@ fun DeckColumnContainer(
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: DesktopDraftStore,
nip11Fetcher: Nip11Fetcher,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToRelays: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val navState = remember(column.id) { ColumnNavigationState() }
@@ -141,6 +145,7 @@ fun DeckColumnContainer(
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
nip11Fetcher = nip11Fetcher,
appScope = appScope,
compactMode = true,
onShowComposeDialog = onShowComposeDialog,
@@ -150,6 +155,7 @@ fun DeckColumnContainer(
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
onNavigateToRelays = onNavigateToRelays,
)
if (currentOverlay != null) {
Surface(
@@ -191,6 +197,7 @@ internal fun RootContent(
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
nip11Fetcher: Nip11Fetcher,
appScope: CoroutineScope,
compactMode: Boolean = false,
onShowComposeDialog: () -> Unit,
@@ -200,6 +207,7 @@ internal fun RootContent(
onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {},
onNavigateToRelays: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
@@ -209,6 +217,7 @@ internal fun RootContent(
relayManager = relayManager,
localCache = localCache,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
initialFeedMode = FeedMode.FOLLOWING,
@@ -216,6 +225,7 @@ internal fun RootContent(
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
onNavigateToRelays = onNavigateToRelays,
)
}
@@ -239,6 +249,7 @@ internal fun RootContent(
localCache = localCache,
relayManager = relayManager,
subscriptionsCoordinator = subscriptionsCoordinator,
account = account,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
)
@@ -275,6 +286,7 @@ internal fun RootContent(
relayManager = relayManager,
localCache = localCache,
account = account,
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
initialFeedMode = FeedMode.GLOBAL,
@@ -282,6 +294,7 @@ internal fun RootContent(
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
onNavigateToRelays = onNavigateToRelays,
)
}
@@ -323,6 +336,18 @@ internal fun RootContent(
)
}
DeckColumnType.Relays -> {
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
RelayDashboardScreen(
relayManager = relayManager,
nip11Fetcher = nip11Fetcher,
nip65State = iAccount.nip65RelayList,
accountRelays = accountRelays ?: return,
signer = iAccount.signer,
onPublish = { event -> relayManager.broadcastToAll(event) },
)
}
is DeckColumnType.Profile -> {
UserProfileScreen(
pubKeyHex = columnType.pubKeyHex,
@@ -398,6 +423,7 @@ internal fun RootContent(
localCache = localCache,
relayManager = relayManager,
subscriptionsCoordinator = subscriptionsCoordinator,
account = account,
initialQuery = "#${columnType.tag}",
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
@@ -43,6 +43,8 @@ sealed class DeckColumnType {
object Settings : DeckColumnType()
object Relays : DeckColumnType()
data class Profile(
val pubKeyHex: String,
) : DeckColumnType()
@@ -79,6 +81,7 @@ sealed class DeckColumnType {
MyProfile -> "Profile"
Chess -> "Chess"
Settings -> "Settings"
Relays -> "Relays"
is Article -> "Article"
is Editor -> "New Article"
Drafts -> "Drafts"
@@ -100,6 +103,7 @@ sealed class DeckColumnType {
MyProfile -> "my_profile"
Chess -> "chess"
Settings -> "settings"
Relays -> "relays"
is Article -> "article"
is Editor -> "editor"
Drafts -> "drafts"
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
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.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
@@ -64,10 +65,12 @@ fun DeckLayout(
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
nip11Fetcher: Nip11Fetcher,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToRelays: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val columns by deckState.columns.collectAsState()
@@ -118,10 +121,12 @@ fun DeckLayout(
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
nip11Fetcher = nip11Fetcher,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToRelays = onNavigateToRelays,
)
}
}
@@ -298,6 +298,7 @@ class DeckState(
"my_profile" -> DeckColumnType.MyProfile
"chess" -> DeckColumnType.Chess
"settings" -> DeckColumnType.Settings
"relays" -> DeckColumnType.Relays
"drafts" -> DeckColumnType.Drafts
"highlights" -> DeckColumnType.MyHighlights
"editor" -> DeckColumnType.Editor(param)
@@ -102,6 +102,7 @@ class PinnedNavBarState(
DeckColumnType.Notifications,
DeckColumnType.MyProfile,
DeckColumnType.Chess,
DeckColumnType.Relays,
DeckColumnType.Settings,
)
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
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.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
@@ -73,6 +74,7 @@ fun SinglePaneLayout(
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
nip11Fetcher: Nip11Fetcher,
appScope: CoroutineScope,
singlePaneState: SinglePaneState,
pinnedNavBarState: PinnedNavBarState,
@@ -190,6 +192,7 @@ fun SinglePaneLayout(
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
nip11Fetcher = nip11Fetcher,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -198,6 +201,7 @@ fun SinglePaneLayout(
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
)
if (currentOverlay != null) {
Surface(
@@ -0,0 +1,199 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun BlockedRelayEditor(
localRelays: SnapshotStateList<NormalizedRelayUrl>,
signer: NostrSigner,
onPublish: (Event) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
var newRelayUrl by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var savedMessage by remember { mutableStateOf<String?>(null) }
Column(modifier = modifier.fillMaxWidth()) {
Text(
"Relays you want to avoid connecting to.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
// Add relay input
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = newRelayUrl,
onValueChange = {
newRelayUrl = it
error = null
},
label = { Text("wss://relay.example.com") },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
modifier =
Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) {
error = tryAddSimpleRelay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
true
} else {
false
}
},
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
error = tryAddSimpleRelay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
},
) {
Icon(Icons.Default.Add, contentDescription = "Add relay")
}
}
// Relay list
if (localRelays.isNotEmpty()) {
Text(
"${localRelays.size} relay(s) configured",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
localRelays.toList().forEach { url ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
url.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { localRelays.remove(url) }, modifier = Modifier.size(28.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
)
}
}
}
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
// Auto-add pending input
if (newRelayUrl.isNotBlank()) {
val addError = tryAddSimpleRelay(newRelayUrl, localRelays)
if (addError != null) {
error = addError
return@Button
}
newRelayUrl = ""
}
if (localRelays.isEmpty()) {
error = "Add at least one relay before saving"
return@Button
}
scope.launch {
try {
val event = BlockedRelayListEvent.create(localRelays.toList(), signer)
onPublish(event)
savedMessage = "Published ${localRelays.size} relay(s)"
} catch (e: Exception) {
savedMessage = "Failed: ${e.message}"
}
delay(3000)
savedMessage = null
}
},
) {
Text("Save")
}
savedMessage?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -0,0 +1,210 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@Composable
fun DmRelayEditor(
dmRelays: StateFlow<Set<NormalizedRelayUrl>>,
signer: NostrSigner,
onPublish: (Event) -> Unit,
onDmRelaysUpdated: (Set<NormalizedRelayUrl>) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val currentDmRelays by dmRelays.collectAsState()
val localRelays = remember { mutableStateListOf<NormalizedRelayUrl>() }
var newRelayUrl by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var savedMessage by remember { mutableStateOf<String?>(null) }
// Sync local state when upstream changes
LaunchedEffect(currentDmRelays) {
localRelays.clear()
localRelays.addAll(currentDmRelays.sortedBy { it.url })
}
Column(modifier = modifier.fillMaxWidth()) {
Text(
"These relays receive your encrypted direct messages.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
if (localRelays.isEmpty()) {
Text(
"No DM relays configured — DMs use all connected relays as fallback.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 8.dp),
)
}
// Add relay input
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = newRelayUrl,
onValueChange = {
newRelayUrl = it
error = null
},
label = { Text("wss://relay.example.com") },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
modifier =
Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) {
error = tryAddSimpleRelay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
true
} else {
false
}
},
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
error = tryAddSimpleRelay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
},
) {
Icon(Icons.Default.Add, contentDescription = "Add relay")
}
}
// Relay list
localRelays.toList().forEach { url ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
url.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { localRelays.remove(url) }, modifier = Modifier.size(28.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
)
}
}
}
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
// Auto-add pending input
if (newRelayUrl.isNotBlank()) {
val addError = tryAddSimpleRelay(newRelayUrl, localRelays)
if (addError != null) {
error = addError
return@Button
}
newRelayUrl = ""
}
scope.launch {
try {
val event = ChatMessageRelayListEvent.create(localRelays.toList(), signer)
onPublish(event)
onDmRelaysUpdated(localRelays.toSet())
savedMessage = "Published ${localRelays.size} relay(s)"
} catch (e: Exception) {
savedMessage = "Failed: ${e.message}"
}
delay(3000)
savedMessage = null
}
},
) {
Text("Save")
}
savedMessage?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -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
}
@@ -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.DesktopRelayCategories
val LocalRelayCategories =
staticCompositionLocalOf<DesktopRelayCategories> {
error("No DesktopRelayCategories provided")
}
@@ -0,0 +1,292 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun Nip65RelayEditor(
nip65State: Nip65RelayListState,
signer: NostrSigner,
onPublish: (Event) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val localRelays = remember { mutableStateListOf<AdvertisedRelayInfo>() }
var loaded by remember { mutableStateOf(false) }
var newRelayUrl by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var savedMessage by remember { mutableStateOf<String?>(null) }
// React to NIP-65 flow changes (external updates)
val nip65NoteState by nip65State.getNIP65RelayListFlow().collectAsState()
val currentNip65Relays =
remember(nip65NoteState) {
nip65State.getNIP65RelayList()?.relays() ?: emptyList()
}
LaunchedEffect(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(
"No NIP-65 relay list published yet.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
OutlinedButton(
onClick = {
localRelays.clear()
DefaultRelays.RELAYS.forEach { url ->
RelayUrlNormalizer.normalizeOrNull(url)?.let { normalized ->
localRelays.add(AdvertisedRelayInfo(normalized, AdvertisedRelayType.BOTH))
}
}
},
) {
Text("Populate from defaults")
}
Spacer(Modifier.height(8.dp))
}
// Add relay input
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = newRelayUrl,
onValueChange = {
newRelayUrl = it
error = null
},
label = { Text("wss://relay.example.com") },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
modifier =
Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) {
error = tryAddNip65Relay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
true
} else {
false
}
},
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
error = tryAddNip65Relay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
},
) {
Icon(Icons.Default.Add, contentDescription = "Add relay")
}
}
// Relay list
localRelays.toList().forEachIndexed { index, relay ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
relay.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = relay.type == AdvertisedRelayType.READ,
onClick = {
localRelays[index] = AdvertisedRelayInfo(relay.relayUrl, AdvertisedRelayType.READ)
},
label = { Text("Read", style = MaterialTheme.typography.labelSmall) },
)
FilterChip(
selected = relay.type == AdvertisedRelayType.WRITE,
onClick = {
localRelays[index] = AdvertisedRelayInfo(relay.relayUrl, AdvertisedRelayType.WRITE)
},
label = { Text("Write", style = MaterialTheme.typography.labelSmall) },
)
FilterChip(
selected = relay.type == AdvertisedRelayType.BOTH,
onClick = {
localRelays[index] = AdvertisedRelayInfo(relay.relayUrl, AdvertisedRelayType.BOTH)
},
label = { Text("Both", style = MaterialTheme.typography.labelSmall) },
)
}
IconButton(onClick = { localRelays.remove(relay) }, modifier = Modifier.size(28.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
)
}
}
}
Spacer(Modifier.height(8.dp))
// Action buttons
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
// Auto-add pending input
if (newRelayUrl.isNotBlank()) {
val addError = tryAddNip65Relay(newRelayUrl, localRelays)
if (addError != null) {
error = addError
return@Button
}
newRelayUrl = ""
}
if (localRelays.isEmpty()) {
error = "Add at least one relay before saving"
return@Button
}
scope.launch {
try {
val event = nip65State.saveRelayList(localRelays.toList())
onPublish(event)
savedMessage = "Published ${localRelays.size} relay(s)"
} catch (e: Exception) {
savedMessage = "Failed: ${e.message}"
}
delay(3000)
savedMessage = null
}
},
) {
Text("Save")
}
OutlinedButton(
onClick = {
localRelays.clear()
DefaultRelays.RELAYS.forEach { url ->
RelayUrlNormalizer.normalizeOrNull(url)?.let { normalized ->
localRelays.add(AdvertisedRelayInfo(normalized, AdvertisedRelayType.BOTH))
}
}
},
) {
Text("Reset to defaults")
}
savedMessage?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
private fun tryAddNip65Relay(
url: String,
existing: MutableList<AdvertisedRelayInfo>,
): String? {
val input = normalizeRelayInput(url)
val error = validateRelayUrl(input)
if (error != null) return error
val normalized = RelayUrlNormalizer.normalizeOrNull(input)!!
if (existing.any { it.relayUrl.url == normalized.url }) {
return "Relay already added"
}
existing.add(AdvertisedRelayInfo(normalized, AdvertisedRelayType.BOTH))
return null
}
@@ -0,0 +1,207 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
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(
relayManager: DesktopRelayConnectionManager,
nip65State: Nip65RelayListState,
accountRelays: DesktopAccountRelays,
signer: NostrSigner,
onPublish: (Event) -> Unit,
searchRelayState: SnapshotStateList<NormalizedRelayUrl>,
blockedRelayState: SnapshotStateList<NormalizedRelayUrl>,
modifier: Modifier = Modifier,
) {
val statuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
Column(
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 8.dp),
) {
// 1. Connected Relays (collapsed by default to show other sections)
CollapsibleSection(
title = "Connected Relays",
description = "Relays your client connects to — ${connectedRelays.size} of ${statuses.size} connected",
initiallyExpanded = false,
) {
RelayListEditor(
relays = statuses.keys.sortedBy { it.url },
connectedRelays = connectedRelays,
onAdd = { url -> relayManager.addRelay(url) },
onRemove = { url -> relayManager.removeRelay(url) },
)
}
Spacer(Modifier.height(16.dp))
// 2. NIP-65 Inbox/Outbox
CollapsibleSection(
title = "NIP-65 Inbox/Outbox",
description = "Relay list metadata (kind 10002) — tells other clients where to find your notes",
) {
Nip65RelayEditor(
nip65State = nip65State,
signer = signer,
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)
},
)
}
Spacer(Modifier.height(16.dp))
// 3. DM Relays
CollapsibleSection(
title = "DM Relays",
description = "Kind 10050 — where others send you encrypted messages",
) {
DmRelayEditor(
dmRelays = accountRelays.dmRelayList,
signer = signer,
onPublish = onPublish,
onDmRelaysUpdated = { accountRelays.setDmRelays(it) },
)
}
Spacer(Modifier.height(16.dp))
// 4. Search Relays
CollapsibleSection(
title = "Search Relays",
description = "Kind 10007 — relays used for NIP-50 full-text search",
) {
SearchRelayEditor(
localRelays = searchRelayState,
signer = signer,
onPublish = { event ->
onPublish(event)
accountRelays.consumePublishedEvent(event)
accountRelays.setSearchRelays(searchRelayState.toSet())
},
)
}
Spacer(Modifier.height(16.dp))
// 5. Blocked Relays
CollapsibleSection(
title = "Blocked Relays",
description = "Kind 10006 — relays you want to avoid",
) {
BlockedRelayEditor(
localRelays = blockedRelayState,
signer = signer,
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())
},
)
}
}
}
@Composable
private fun CollapsibleSection(
title: String,
description: String,
initiallyExpanded: Boolean = true,
content: @Composable () -> Unit,
) {
var expanded by remember { mutableStateOf(initiallyExpanded) }
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (expanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.padding(start = 8.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
AnimatedVisibility(expanded) {
Column(modifier = Modifier.padding(start = 8.dp, top = 8.dp)) {
content()
}
}
}
@@ -0,0 +1,110 @@
/*
* 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.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
enum class DashboardTab(
val label: String,
) {
MONITOR("Monitor"),
CONFIGURE("Configure"),
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RelayDashboardScreen(
relayManager: DesktopRelayConnectionManager,
nip11Fetcher: Nip11Fetcher,
nip65State: Nip65RelayListState,
accountRelays: DesktopAccountRelays,
signer: NostrSigner,
onPublish: (Event) -> Unit,
modifier: Modifier = Modifier,
) {
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)) {
DashboardTab.entries.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { selectedTab = tab },
text = { Text(tab.label) },
)
}
}
when (selectedTab) {
DashboardTab.MONITOR -> {
RelayMetricsTab(relayManager, nip11Fetcher)
}
DashboardTab.CONFIGURE -> {
RelayConfigTab(
relayManager = relayManager,
nip65State = nip65State,
accountRelays = accountRelays,
signer = signer,
onPublish = onPublish,
searchRelayState = searchRelayState,
blockedRelayState = blockedRelayState,
)
}
}
}
}
@@ -0,0 +1,91 @@
/*
* 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.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
@Composable
fun RelayDetailPanel(
nip11: Nip11RelayInformation?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(12.dp),
) {
HorizontalDivider(modifier = Modifier.padding(bottom = 8.dp))
if (nip11 == null) {
Text(
"Relay info unavailable",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
// Description
nip11.description?.let { desc ->
Text(desc, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
}
// Software + version
nip11.software?.let { sw ->
val version = nip11.version?.let { " v$it" } ?: ""
DetailRow("Software", "$sw$version")
}
// Supported NIPs
nip11.supported_nips?.let { nips ->
if (nips.isNotEmpty()) {
DetailRow("NIPs", nips.joinToString(", "))
}
}
// Payment status
val paymentRequired = nip11.limitation?.payment_required == true
DetailRow("Payment", if (paymentRequired) "Paid" else "Free")
}
}
@Composable
private fun DetailRow(
label: String,
value: String,
) {
Text(
"$label: $value",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 2.dp),
)
}
@@ -0,0 +1,171 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
@Composable
fun RelayListEditor(
relays: List<NormalizedRelayUrl>,
connectedRelays: Set<NormalizedRelayUrl>,
onAdd: (String) -> NormalizedRelayUrl?,
onRemove: (NormalizedRelayUrl) -> Unit,
modifier: Modifier = Modifier,
) {
var newRelayUrl by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Column(modifier = modifier.fillMaxWidth()) {
// Add relay input
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = newRelayUrl,
onValueChange = {
newRelayUrl = it
error = null
},
label = { Text("wss://relay.example.com") },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
modifier =
Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) {
error = tryAddRelay(newRelayUrl, relays, onAdd)
if (error == null) newRelayUrl = ""
true
} else {
false
}
},
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
error = tryAddRelay(newRelayUrl, relays, onAdd)
if (error == null) newRelayUrl = ""
},
) {
Icon(Icons.Default.Add, contentDescription = "Add relay")
}
}
// Relay list
relays.forEach { url ->
val isConnected = url in connectedRelays
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Circle,
contentDescription = null,
modifier = Modifier.size(8.dp),
tint =
if (isConnected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
)
Spacer(Modifier.width(8.dp))
Text(
url.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
)
if (url.isOnion()) {
Spacer(Modifier.width(4.dp))
Text(
".onion",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
IconButton(onClick = { onRemove(url) }, modifier = Modifier.size(28.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
private fun tryAddRelay(
url: String,
existing: List<NormalizedRelayUrl>,
onAdd: (String) -> NormalizedRelayUrl?,
): String? {
val input = normalizeRelayInput(url)
val error = validateRelayUrl(input)
if (error != null) return error
val normalized = RelayUrlNormalizer.normalizeOrNull(input) ?: return "Invalid relay URL"
if (existing.any { it.url == normalized.url }) {
return "Relay already added"
}
onAdd(input)
return null
}
@@ -0,0 +1,176 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.network.RelayMetrics
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
@Composable
fun RelayMetricCard(
status: RelayStatus,
metrics: RelayMetrics?,
isExpanded: Boolean,
onToggleExpand: () -> Unit,
nip11Fetcher: Nip11Fetcher,
modifier: Modifier = Modifier,
) {
// NIP-11 fetched per-card to avoid parent recomposition
val nip11 by produceState<Nip11RelayInformation?>(null, status.url) {
value = nip11Fetcher.fetch(status.url)
}
Card(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.clickable { onToggleExpand() }.padding(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Status indicator
Icon(
Icons.Default.Circle,
contentDescription = if (status.connected) "Connected" else "Disconnected",
modifier = Modifier.size(10.dp),
tint =
if (status.connected) {
MaterialTheme.colorScheme.primary
} else if (status.error != null) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.outline
},
)
Spacer(Modifier.width(8.dp))
Column {
Text(
nip11?.name ?: status.url.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
)
if (nip11?.name != null) {
Text(
status.url.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Ping + Tor badge
if (status.pingMs != null) {
Text(
"${status.pingMs}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (status.url.isOnion()) {
Text(
".onion",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
// Event count
if (metrics != null && metrics.eventCount > 0) {
Text(
"${metrics.eventCount} events",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Last event relative time
if (metrics?.lastEventAt != null) {
val ago = formatRelativeTime(metrics.lastEventAt)
Text(
ago,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Error message
if (status.error != null) {
Text(
status.error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp),
)
}
}
AnimatedVisibility(isExpanded) {
RelayDetailPanel(nip11)
}
}
}
fun formatRelativeTime(epochMs: Long): String {
val diffMs = System.currentTimeMillis() - epochMs
val seconds = diffMs / 1000
return when {
seconds < 5 -> "just now"
seconds < 60 -> "${seconds}s ago"
seconds < 3600 -> "${seconds / 60}m ago"
seconds < 86400 -> "${seconds / 3600}h ago"
else -> "${seconds / 86400}d ago"
}
}
@@ -0,0 +1,110 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@Composable
fun RelayMetricsTab(
relayManager: DesktopRelayConnectionManager,
nip11Fetcher: Nip11Fetcher,
modifier: Modifier = Modifier,
) {
val statuses by remember(relayManager) {
relayManager.relayStatuses
.map { it.values.toList().sortedBy { s -> s.url.url } }
.distinctUntilChanged()
}.collectAsState(emptyList())
val metrics by relayManager.relayMetrics.collectAsState()
val connectedCount = statuses.count { it.connected }
var expandedUrl by remember { mutableStateOf<NormalizedRelayUrl?>(null) }
Column(modifier = modifier.fillMaxSize().padding(top = 8.dp)) {
// Summary header
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"$connectedCount of ${statuses.size} connected",
style = MaterialTheme.typography.titleSmall,
)
IconButton(onClick = {
relayManager.disconnect()
relayManager.connect()
}) {
Icon(Icons.Default.Refresh, contentDescription = "Reconnect all")
}
}
Spacer(Modifier.height(4.dp))
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(statuses, key = { it.url.url }) { status ->
RelayMetricCard(
status = status,
metrics = metrics[status.url],
isExpanded = expandedUrl == status.url,
onToggleExpand = {
expandedUrl = if (expandedUrl == status.url) null else status.url
},
nip11Fetcher = nip11Fetcher,
)
}
}
}
}
@@ -0,0 +1,81 @@
/*
* 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 com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* 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 input = normalizeRelayInput(url)
if (input.isBlank()) return "Enter a relay URL"
if (!input.startsWith("wss://") && !input.startsWith("ws://")) {
return "Invalid relay URL"
}
if (input.startsWith("ws://") && !input.contains(".onion")) {
return "Use wss:// — unencrypted ws:// exposes traffic to observers"
}
val host =
input
.removePrefix("wss://")
.removePrefix("ws://")
.split("/")
.first()
if (!host.contains(".")) {
return "Invalid domain — must contain at least one dot (e.g., relay.example.com)"
}
if (RelayUrlNormalizer.normalizeOrNull(input) == null) {
return "Invalid relay URL"
}
return null
}
internal fun tryAddSimpleRelay(
url: String,
existing: MutableList<NormalizedRelayUrl>,
): String? {
val input = normalizeRelayInput(url)
val error = validateRelayUrl(input)
if (error != null) return error
val normalized = RelayUrlNormalizer.normalizeOrNull(input)!!
if (existing.any { it.url == normalized.url }) {
return "Relay already added"
}
existing.add(normalized)
return null
}
@@ -0,0 +1,211 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun SearchRelayEditor(
localRelays: SnapshotStateList<NormalizedRelayUrl>,
signer: NostrSigner,
onPublish: (Event) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
var newRelayUrl by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var savedMessage by remember { mutableStateOf<String?>(null) }
Column(modifier = modifier.fillMaxWidth()) {
Text(
"Add relays that support NIP-50 full-text search (e.g., relay.nostr.band).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
// Add relay input
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = newRelayUrl,
onValueChange = {
newRelayUrl = it
error = null
},
label = { Text("wss://relay.example.com") },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
modifier =
Modifier
.weight(1f)
.onPreviewKeyEvent { event ->
if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) {
error = tryAddSimpleRelay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
true
} else {
false
}
},
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
error = tryAddSimpleRelay(newRelayUrl, localRelays)
if (error == null) newRelayUrl = ""
},
) {
Icon(Icons.Default.Add, contentDescription = "Add relay")
}
}
// Relay list
if (localRelays.isNotEmpty()) {
Text(
"${localRelays.size} relay(s) configured",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
localRelays.toList().forEach { url ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
url.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { localRelays.remove(url) }, modifier = Modifier.size(28.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
)
}
}
}
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
// Auto-add pending input
if (newRelayUrl.isNotBlank()) {
val addError = tryAddSimpleRelay(newRelayUrl, localRelays)
if (addError != null) {
error = addError
return@Button
}
newRelayUrl = ""
}
if (localRelays.isEmpty()) {
error = "Add at least one relay before saving"
return@Button
}
scope.launch {
try {
val event = SearchRelayListEvent.create(localRelays.toList(), signer)
onPublish(event)
savedMessage = "Published ${localRelays.size} relay(s)"
} catch (e: Exception) {
savedMessage = "Failed: ${e.message}"
}
delay(3000)
savedMessage = null
}
},
) {
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,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}