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:
@@ -74,10 +74,12 @@ import androidx.compose.ui.window.rememberWindowState
|
|||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
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.DefaultRelays
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
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.highlights.DesktopHighlightStore
|
||||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
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.LocalIsImmersiveFullscreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
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.relay.RelayStatusCard
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
|
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.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
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.Log
|
||||||
import com.vitorpamplona.quartz.utils.LogLevel
|
import com.vitorpamplona.quartz.utils.LogLevel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -116,6 +126,8 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
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(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
@@ -351,6 +365,26 @@ fun main() {
|
|||||||
},
|
},
|
||||||
onClick = { showAppDrawer = !showAppDrawer },
|
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()
|
Separator()
|
||||||
Item(
|
Item(
|
||||||
if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
|
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("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
|
||||||
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
|
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
|
||||||
Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) })
|
Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) })
|
||||||
|
Item("Relays", onClick = { deckState.addColumn(DeckColumnType.Relays) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,6 +546,7 @@ fun main() {
|
|||||||
torTypeFlow = torTypeFlow,
|
torTypeFlow = torTypeFlow,
|
||||||
externalPortFlow = externalPortFlow,
|
externalPortFlow = externalPortFlow,
|
||||||
initialTorSettings = torSettings,
|
initialTorSettings = torSettings,
|
||||||
|
onNavigateToScreen = { navigateToScreen = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,10 +574,16 @@ fun App(
|
|||||||
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
|
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
|
||||||
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
||||||
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
|
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
|
||||||
|
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val singlePaneState = remember { SinglePaneState() }
|
val singlePaneState = remember { SinglePaneState() }
|
||||||
val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } }
|
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
|
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings
|
||||||
var torSettings by remember {
|
var torSettings by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -631,6 +673,12 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) }
|
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.
|
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
|
||||||
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
|
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
|
||||||
@@ -755,6 +803,7 @@ fun App(
|
|||||||
account = account,
|
account = account,
|
||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
nip11Fetcher = nip11Fetcher,
|
||||||
appScope = scope,
|
appScope = scope,
|
||||||
torStatus = currentTorStatus,
|
torStatus = currentTorStatus,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
@@ -850,6 +899,7 @@ fun MainContent(
|
|||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
nip11Fetcher: Nip11Fetcher,
|
||||||
appScope: CoroutineScope,
|
appScope: CoroutineScope,
|
||||||
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
@@ -866,9 +916,27 @@ fun MainContent(
|
|||||||
remember(relayManager) {
|
remember(relayManager) {
|
||||||
DmSendTracker(relayManager.client)
|
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 =
|
val iAccount =
|
||||||
remember(account, localCache, relayManager, dmSendTracker) {
|
remember(account, localCache, relayManager, dmSendTracker, accountRelays) {
|
||||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
|
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) }
|
val highlightStore = remember { DesktopHighlightStore(appScope) }
|
||||||
@@ -878,19 +946,63 @@ fun MainContent(
|
|||||||
.DesktopDraftStore(appScope)
|
.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
|
// Subscribe to incoming DMs and process into chatroomList
|
||||||
LaunchedEffect(account) {
|
LaunchedEffect(account) {
|
||||||
relayManager.connectedRelays.first { it.isNotEmpty() }
|
relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||||
|
|
||||||
val dmRelayState =
|
|
||||||
DesktopDmRelayState(
|
|
||||||
dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()),
|
|
||||||
connectedRelays = relayManager.connectedRelays,
|
|
||||||
scope = scope,
|
|
||||||
)
|
|
||||||
subscriptionsCoordinator.subscribeToDms(
|
subscriptionsCoordinator.subscribeToDms(
|
||||||
userPubKeyHex = account.pubKeyHex,
|
userPubKeyHex = account.pubKeyHex,
|
||||||
dmRelayState = dmRelayState,
|
dmRelayState = accountRelays.dmRelays,
|
||||||
onDmEvent = { event, relay ->
|
onDmEvent = { event, relay ->
|
||||||
// Store raw event in cache
|
// Store raw event in cache
|
||||||
val note = localCache.getOrCreateNote(event.id)
|
val note = localCache.getOrCreateNote(event.id)
|
||||||
@@ -984,6 +1096,10 @@ fun MainContent(
|
|||||||
|
|
||||||
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
|
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalRelayCategories provides relayCategories,
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
||||||
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(Modifier.fillMaxSize().weight(1f)) {
|
Row(Modifier.fillMaxSize().weight(1f)) {
|
||||||
@@ -1000,6 +1116,7 @@ fun MainContent(
|
|||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
highlightStore = highlightStore,
|
highlightStore = highlightStore,
|
||||||
draftStore = draftStore,
|
draftStore = draftStore,
|
||||||
|
nip11Fetcher = nip11Fetcher,
|
||||||
appScope = appScope,
|
appScope = appScope,
|
||||||
singlePaneState = singlePaneState,
|
singlePaneState = singlePaneState,
|
||||||
pinnedNavBarState = pinnedNavBarState,
|
pinnedNavBarState = pinnedNavBarState,
|
||||||
@@ -1044,10 +1161,18 @@ fun MainContent(
|
|||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
highlightStore = highlightStore,
|
highlightStore = highlightStore,
|
||||||
draftStore = draftStore,
|
draftStore = draftStore,
|
||||||
|
nip11Fetcher = nip11Fetcher,
|
||||||
appScope = appScope,
|
appScope = appScope,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToRelays = {
|
||||||
|
if (deckState.hasColumnOfType(DeckColumnType.Relays)) {
|
||||||
|
deckState.focusExistingColumn(DeckColumnType.Relays)
|
||||||
|
} else {
|
||||||
|
deckState.addColumn(DeckColumnType.Relays)
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1069,6 +1194,7 @@ fun MainContent(
|
|||||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} // end CompositionLocalProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Vendored
+11
-1
@@ -545,7 +545,17 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
// ----- Own event consumption -----
|
// ----- Own event consumption -----
|
||||||
|
|
||||||
override fun justConsumeMyOwnEvent(event: Event): Boolean {
|
override fun justConsumeMyOwnEvent(event: Event): Boolean {
|
||||||
// Desktop doesn't track own events separately
|
// For addressable/replaceable events, store in the addressable note cache
|
||||||
|
// so state holders (Nip65RelayListState, etc.) pick it up via their flows
|
||||||
|
if (event is com.vitorpamplona.quartz.nip01Core.core.AddressableEvent) {
|
||||||
|
val address = event.address()
|
||||||
|
val note = getOrCreateAddressableNote(address)
|
||||||
|
val author = getOrCreateUser(event.pubKey) ?: return false
|
||||||
|
if (note.event == null || (note.event?.createdAt ?: 0) <= event.createdAt) {
|
||||||
|
note.loadEvent(event, author, emptyList())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+172
-16
@@ -24,11 +24,18 @@ import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages relay state for a desktop account.
|
* Manages relay state for a desktop account.
|
||||||
@@ -51,10 +58,25 @@ class DesktopAccountRelays(
|
|||||||
relayManager: RelayConnectionManager,
|
relayManager: RelayConnectionManager,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
|
private val prefs = Preferences.userNodeForPackage(DesktopAccountRelays::class.java)
|
||||||
|
|
||||||
/** User-configured DM relays from kind 10050 events */
|
/** User-configured DM relays from kind 10050 events */
|
||||||
private val _dmRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
private val _dmRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||||
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelayList.asStateFlow()
|
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelayList.asStateFlow()
|
||||||
|
|
||||||
|
/** 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) */
|
/** Aggregated DM relay state (DM relays + fallback to connected relays) */
|
||||||
val dmRelays =
|
val dmRelays =
|
||||||
DesktopDmRelayState(
|
DesktopDmRelayState(
|
||||||
@@ -63,32 +85,166 @@ class DesktopAccountRelays(
|
|||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadFromPersistence()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefsKey(kind: Int) = "relay_${kind}_${userPubKeyHex.take(16)}"
|
||||||
|
|
||||||
|
private fun saveEvent(
|
||||||
|
kind: Int,
|
||||||
|
event: Event,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val json = event.toJson()
|
||||||
|
if (json.length > MAX_PREFS_VALUE_LENGTH) return // Preferences 8KB limit
|
||||||
|
prefs.put(prefsKey(kind), json)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Best-effort persistence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_PREFS_VALUE_LENGTH = 8000 // java.util.prefs limit is 8192
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadEvent(kind: Int): Event? =
|
||||||
|
try {
|
||||||
|
val json = prefs.get(prefsKey(kind), null) ?: return null
|
||||||
|
val event = Event.fromJson(json)
|
||||||
|
if (event.kind == kind && event.pubKey == userPubKeyHex) event else null
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFromPersistence() {
|
||||||
|
// Load DM relays from event or URL cache
|
||||||
|
val dmEvent = loadEvent(ChatMessageRelayListEvent.KIND)
|
||||||
|
if (dmEvent is ChatMessageRelayListEvent) {
|
||||||
|
_dmRelayList.value = dmEvent.relays().toSet()
|
||||||
|
lastDmCreatedAt.set(dmEvent.createdAt)
|
||||||
|
} else {
|
||||||
|
loadRelayUrls("dm").let { if (it.isNotEmpty()) _dmRelayList.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load search relays from event or URL cache
|
||||||
|
val searchEvent = loadEvent(SearchRelayListEvent.KIND)
|
||||||
|
if (searchEvent is SearchRelayListEvent) {
|
||||||
|
val relays = searchEvent.publicRelays().toSet()
|
||||||
|
if (relays.isNotEmpty()) {
|
||||||
|
_searchRelayList.value = relays
|
||||||
|
lastSearchCreatedAt.set(searchEvent.createdAt)
|
||||||
|
} else {
|
||||||
|
// Public tags empty — try URL cache (NIP-51 private tags can't be decrypted here)
|
||||||
|
loadRelayUrls("search").let { if (it.isNotEmpty()) _searchRelayList.value = it }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadRelayUrls("search").let { if (it.isNotEmpty()) _searchRelayList.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load blocked relays — always use URL cache (private tags can't be decrypted synchronously)
|
||||||
|
loadRelayUrls("blocked").let { if (it.isNotEmpty()) _blockedRelayList.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a ChatMessageRelayListEvent (kind 10050) to update DM relays.
|
* Routes relay config events (kinds 10050, 10007, 10006) to the appropriate handler.
|
||||||
* Call this when receiving kind 10050 events from relay subscriptions.
|
* Returns true if the event was consumed.
|
||||||
|
* Uses created_at checking to prevent stale overwrites.
|
||||||
*/
|
*/
|
||||||
fun consumeDmRelayList(event: ChatMessageRelayListEvent) {
|
fun consumeIfRelevant(event: Event): Boolean {
|
||||||
if (event.pubKey != userPubKeyHex) return
|
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()
|
val relays = event.relays().toSet()
|
||||||
_dmRelayList.value = relays
|
_dmRelayList.value = relays
|
||||||
|
saveEvent(event.kind, event)
|
||||||
|
saveRelayUrls("dm", relays)
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
SearchRelayListEvent.KIND -> {
|
||||||
* Processes any event that might be a DM relay list.
|
if (event is SearchRelayListEvent && event.createdAt >= lastSearchCreatedAt.get()) {
|
||||||
* Returns true if the event was consumed as a DM relay list.
|
lastSearchCreatedAt.set(event.createdAt)
|
||||||
*/
|
val relays = event.publicRelays().toSet()
|
||||||
fun consumeIfDmRelayList(event: Event): Boolean {
|
_searchRelayList.value = relays
|
||||||
if (event.kind == ChatMessageRelayListEvent.KIND && event is ChatMessageRelayListEvent) {
|
saveEvent(event.kind, event)
|
||||||
consumeDmRelayList(event)
|
saveRelayUrls("search", relays)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually sets DM relays (e.g., from saved preferences).
|
|
||||||
*/
|
|
||||||
fun setDmRelays(relays: Set<NormalizedRelayUrl>) {
|
fun setDmRelays(relays: Set<NormalizedRelayUrl>) {
|
||||||
|
lastDmCreatedAt.set(TimeUtils.now())
|
||||||
_dmRelayList.value = relays
|
_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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -70,6 +70,7 @@ class DesktopIAccount(
|
|||||||
private val relayManager: RelayConnectionManager,
|
private val relayManager: RelayConnectionManager,
|
||||||
val dmSendTracker: DmSendTracker,
|
val dmSendTracker: DmSendTracker,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
private val accountRelays: DesktopAccountRelays? = null,
|
||||||
) : IAccount {
|
) : IAccount {
|
||||||
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
||||||
|
|
||||||
@@ -98,9 +99,12 @@ class DesktopIAccount(
|
|||||||
localCache,
|
localCache,
|
||||||
scope,
|
scope,
|
||||||
object : Nip65RelayListRepository {
|
object : Nip65RelayListRepository {
|
||||||
override val backupNIP65RelayList: AdvertisedRelayListEvent? = null
|
override val backupNIP65RelayList: AdvertisedRelayListEvent? =
|
||||||
|
accountRelays?.loadPersistedNip65Event()
|
||||||
|
|
||||||
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) { /* no persistence yet */ }
|
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) {
|
||||||
|
accountRelays?.consumePublishedEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
override val defaultOutboxRelays = relayManager.connectedRelays.value
|
override val defaultOutboxRelays = relayManager.connectedRelays.value
|
||||||
override val defaultInboxRelays = relayManager.connectedRelays.value
|
override val defaultInboxRelays = relayManager.connectedRelays.value
|
||||||
|
|||||||
+105
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-7
@@ -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.listeners.RelayConnectionListener
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
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.toClient.Message
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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.
|
* Manages Nostr relay connections, subscriptions, and status tracking.
|
||||||
@@ -55,10 +67,30 @@ open class RelayConnectionManager(
|
|||||||
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.connectedRelaysFlow()
|
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.connectedRelaysFlow()
|
||||||
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.availableRelaysFlow()
|
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 {
|
init {
|
||||||
_client.addConnectionListener(this)
|
_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() {
|
fun connect() {
|
||||||
_client.connect()
|
_client.connect()
|
||||||
}
|
}
|
||||||
@@ -69,12 +101,14 @@ open class RelayConnectionManager(
|
|||||||
|
|
||||||
fun addRelay(url: String): NormalizedRelayUrl? {
|
fun addRelay(url: String): NormalizedRelayUrl? {
|
||||||
val normalized = RelayUrlNormalizer.Companion.normalizeOrNull(url) ?: return null
|
val normalized = RelayUrlNormalizer.Companion.normalizeOrNull(url) ?: return null
|
||||||
|
removedRelays.remove(normalized)
|
||||||
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
|
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeRelay(url: NormalizedRelayUrl) {
|
fun removeRelay(url: NormalizedRelayUrl) {
|
||||||
_relayStatuses.value = _relayStatuses.value - url
|
removedRelays.add(url)
|
||||||
|
_relayStatuses.update { it - url }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addDefaultRelays() {
|
fun addDefaultRelays() {
|
||||||
@@ -167,12 +201,12 @@ open class RelayConnectionManager(
|
|||||||
|
|
||||||
private fun updateRelayStatus(
|
private fun updateRelayStatus(
|
||||||
url: NormalizedRelayUrl,
|
url: NormalizedRelayUrl,
|
||||||
update: (RelayStatus) -> RelayStatus,
|
transform: (RelayStatus) -> RelayStatus,
|
||||||
) {
|
) {
|
||||||
_relayStatuses.value =
|
if (url in removedRelays) return
|
||||||
_relayStatuses.value.toMutableMap().apply {
|
_relayStatuses.update { current ->
|
||||||
val current = this[url] ?: RelayStatus(url, connected = false)
|
val existing = current[url] ?: RelayStatus(url, connected = false)
|
||||||
this[url] = update(current)
|
current + (url to transform(existing))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +246,14 @@ open class RelayConnectionManager(
|
|||||||
msgStr: String,
|
msgStr: String,
|
||||||
msg: Message,
|
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(
|
override fun onSent(
|
||||||
|
|||||||
+34
-3
@@ -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.DesktopUploadOrchestrator
|
||||||
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker
|
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker
|
||||||
import com.vitorpamplona.amethyst.desktop.service.upload.UploadResult
|
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.ClipboardPasteHandler
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
|
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
|
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
|
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||||
@@ -104,6 +107,15 @@ fun ComposeNoteDialog(
|
|||||||
var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) }
|
var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) }
|
||||||
var postAsPicture by remember { mutableStateOf(false) }
|
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
|
// Drag-and-drop state
|
||||||
var isDragOver by remember { mutableStateOf(false) }
|
var isDragOver by remember { mutableStateOf(false) }
|
||||||
val dropTarget =
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -316,6 +343,7 @@ fun ComposeNoteDialog(
|
|||||||
images = pictureMetas,
|
images = pictureMetas,
|
||||||
account = account,
|
account = account,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
|
relays = selectedRelays,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val imetaTags = buildIMetaTags(uploadResults)
|
val imetaTags = buildIMetaTags(uploadResults)
|
||||||
@@ -325,6 +353,7 @@ fun ComposeNoteDialog(
|
|||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
replyTo = replyTo,
|
replyTo = replyTo,
|
||||||
imetaTags = imetaTags,
|
imetaTags = imetaTags,
|
||||||
|
relays = selectedRelays,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -470,6 +499,7 @@ private suspend fun publishPicture(
|
|||||||
images: List<com.vitorpamplona.quartz.nip68Picture.PictureMeta>,
|
images: List<com.vitorpamplona.quartz.nip68Picture.PictureMeta>,
|
||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (account.isReadOnly) {
|
if (account.isReadOnly) {
|
||||||
@@ -485,7 +515,7 @@ private suspend fun publishPicture(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val signedEvent = account.signer.sign(template)
|
val signedEvent = account.signer.sign(template)
|
||||||
relayManager.broadcastToAll(signedEvent)
|
relayManager.publish(signedEvent, relays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +525,7 @@ private suspend fun publishNote(
|
|||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
replyTo: Event?,
|
replyTo: Event?,
|
||||||
imetaTags: List<IMetaTag> = emptyList(),
|
imetaTags: List<IMetaTag> = emptyList(),
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (account.isReadOnly) {
|
if (account.isReadOnly) {
|
||||||
@@ -518,6 +549,6 @@ private suspend fun publishNote(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val signedEvent = account.signer.sign(template)
|
val signedEvent = account.signer.sign(template)
|
||||||
relayManager.broadcastToAll(signedEvent)
|
relayManager.publish(signedEvent, relays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
package com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Dns
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -80,6 +84,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
|||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
|
||||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||||
@@ -87,6 +93,10 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
|||||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class LightboxState(
|
data class LightboxState(
|
||||||
val urls: List<String>,
|
val urls: List<String>,
|
||||||
@@ -260,6 +270,7 @@ fun FeedScreen(
|
|||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
account: AccountState.LoggedIn? = null,
|
account: AccountState.LoggedIn? = null,
|
||||||
|
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
|
||||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
initialFeedMode: FeedMode? = null,
|
initialFeedMode: FeedMode? = null,
|
||||||
@@ -267,6 +278,7 @@ fun FeedScreen(
|
|||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
onNavigateToThread: (String) -> Unit = {},
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||||
|
onNavigateToRelays: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
@@ -275,8 +287,13 @@ fun FeedScreen(
|
|||||||
// Available relay URLs — subscribe triggers connection on-demand
|
// Available relay URLs — subscribe triggers connection on-demand
|
||||||
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
|
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 replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||||
|
var showRelayPicker by remember { mutableStateOf(false) }
|
||||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
||||||
|
|
||||||
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
||||||
@@ -295,13 +312,13 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to feed events (kind 1) — populates cache via coordinator
|
// Subscribe to feed events (kind 1) — populates cache via coordinator
|
||||||
rememberSubscription(allRelayUrls, feedMode, followedUsers, relayManager = relayManager) {
|
rememberSubscription(feedRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||||
if (allRelayUrls.isEmpty()) return@rememberSubscription null
|
if (feedRelays.isEmpty()) return@rememberSubscription null
|
||||||
|
|
||||||
when (feedMode) {
|
when (feedMode) {
|
||||||
FeedMode.GLOBAL -> {
|
FeedMode.GLOBAL -> {
|
||||||
createGlobalFeedSubscription(
|
createGlobalFeedSubscription(
|
||||||
relays = allRelayUrls,
|
relays = feedRelays,
|
||||||
onEvent = { event, _, relay, _ ->
|
onEvent = { event, _, relay, _ ->
|
||||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||||
},
|
},
|
||||||
@@ -312,7 +329,7 @@ fun FeedScreen(
|
|||||||
val follows = followedUsers.toList()
|
val follows = followedUsers.toList()
|
||||||
if (follows.isNotEmpty()) {
|
if (follows.isNotEmpty()) {
|
||||||
createFollowingFeedSubscription(
|
createFollowingFeedSubscription(
|
||||||
relays = allRelayUrls,
|
relays = feedRelays,
|
||||||
followedUsers = follows,
|
followedUsers = follows,
|
||||||
onEvent = { event, _, relay, _ ->
|
onEvent = { event, _, relay, _ ->
|
||||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||||
@@ -481,7 +498,7 @@ fun FeedScreen(
|
|||||||
FeedHeader(
|
FeedHeader(
|
||||||
feedMode = feedMode,
|
feedMode = feedMode,
|
||||||
account = account,
|
account = account,
|
||||||
connectedRelays = connectedRelays,
|
feedRelays = feedRelays,
|
||||||
followedUsersCount = followedUsers.size,
|
followedUsersCount = followedUsers.size,
|
||||||
onFeedModeChange = { mode ->
|
onFeedModeChange = { mode ->
|
||||||
feedMode = mode
|
feedMode = mode
|
||||||
@@ -489,6 +506,8 @@ fun FeedScreen(
|
|||||||
},
|
},
|
||||||
onRefresh = { relayManager.connect() },
|
onRefresh = { relayManager.connect() },
|
||||||
onCompose = onCompose,
|
onCompose = onCompose,
|
||||||
|
onNavigateToRelays = onNavigateToRelays,
|
||||||
|
onOpenRelayPicker = { showRelayPicker = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
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
|
// Lightbox overlay
|
||||||
lightboxState?.let { state ->
|
lightboxState?.let { state ->
|
||||||
LightboxOverlay(
|
LightboxOverlay(
|
||||||
@@ -592,11 +638,13 @@ fun FeedScreen(
|
|||||||
private fun FeedHeader(
|
private fun FeedHeader(
|
||||||
feedMode: FeedMode,
|
feedMode: FeedMode,
|
||||||
account: AccountState.LoggedIn?,
|
account: AccountState.LoggedIn?,
|
||||||
connectedRelays: Set<Any>,
|
feedRelays: Set<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>,
|
||||||
followedUsersCount: Int,
|
followedUsersCount: Int,
|
||||||
onFeedModeChange: (FeedMode) -> Unit,
|
onFeedModeChange: (FeedMode) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
|
onNavigateToRelays: () -> Unit = {},
|
||||||
|
onOpenRelayPicker: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
@@ -633,9 +681,11 @@ private fun FeedHeader(
|
|||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
"${connectedRelays.size} relays connected",
|
"${feedRelays.size} relays",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable { onNavigateToRelays() },
|
||||||
)
|
)
|
||||||
if (feedMode == FeedMode.FOLLOWING) {
|
if (feedMode == FeedMode.FOLLOWING) {
|
||||||
Text(
|
Text(
|
||||||
@@ -645,6 +695,20 @@ private fun FeedHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
if (account != null && !account.isReadOnly) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onOpenRelayPicker,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Dns,
|
||||||
|
contentDescription = "Edit Feed Relays",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onRefresh,
|
onClick = onRefresh,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
|
|||||||
+60
-9
@@ -44,12 +44,14 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
|||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Description
|
import androidx.compose.material.icons.filled.Description
|
||||||
|
import androidx.compose.material.icons.filled.Dns
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.filled.Tag
|
import androidx.compose.material.icons.filled.Tag
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -64,6 +66,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -91,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchResult
|
|||||||
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
|
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
|
||||||
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||||
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
@@ -100,6 +104,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscripti
|
|||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||||
@@ -111,6 +118,7 @@ fun SearchScreen(
|
|||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
|
account: AccountState.LoggedIn? = null,
|
||||||
initialQuery: String = "",
|
initialQuery: String = "",
|
||||||
onNavigateToProfile: (String) -> Unit,
|
onNavigateToProfile: (String) -> Unit,
|
||||||
onNavigateToThread: (String) -> Unit,
|
onNavigateToThread: (String) -> Unit,
|
||||||
@@ -118,8 +126,10 @@ fun SearchScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val accountRelays = LocalAccountRelays.current
|
||||||
val state = remember { AdvancedSearchBarState(scope) }
|
val state = remember { AdvancedSearchBarState(scope) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
var showRelayPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pre-fill initial query
|
// Pre-fill initial query
|
||||||
LaunchedEffect(initialQuery) {
|
LaunchedEffect(initialQuery) {
|
||||||
@@ -131,6 +141,8 @@ fun SearchScreen(
|
|||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
|
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
|
||||||
|
val relayCategories = LocalRelayCategories.current
|
||||||
|
val searchRelays by relayCategories.searchRelays.collectAsState()
|
||||||
val displayText by state.displayText.collectAsState()
|
val displayText by state.displayText.collectAsState()
|
||||||
// Track TextFieldValue locally to preserve cursor position
|
// Track TextFieldValue locally to preserve cursor position
|
||||||
var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) }
|
var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) }
|
||||||
@@ -160,7 +172,7 @@ fun SearchScreen(
|
|||||||
LaunchedEffect(debouncedQuery) {
|
LaunchedEffect(debouncedQuery) {
|
||||||
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
||||||
state.clearResults()
|
state.clearResults()
|
||||||
state.initRelayStates(allRelayUrls)
|
state.initRelayStates(searchRelays)
|
||||||
if (shouldSearchPeople) {
|
if (shouldSearchPeople) {
|
||||||
state.startSearching("people-search")
|
state.startSearching("people-search")
|
||||||
}
|
}
|
||||||
@@ -171,9 +183,9 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-50 people search subscription (use allRelayUrls — subscribe will connect)
|
// NIP-50 people search subscription (use searchRelays from relay categories)
|
||||||
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
|
||||||
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
|
if (searchRelays.isEmpty() || debouncedQuery.isEmpty) {
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
||||||
@@ -183,7 +195,7 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
createSearchPeopleSubscription(
|
createSearchPeopleSubscription(
|
||||||
relays = allRelayUrls,
|
relays = searchRelays,
|
||||||
searchQuery =
|
searchQuery =
|
||||||
debouncedQuery.text.ifBlank {
|
debouncedQuery.text.ifBlank {
|
||||||
QuerySerializer.serialize(debouncedQuery)
|
QuerySerializer.serialize(debouncedQuery)
|
||||||
@@ -212,9 +224,9 @@ fun SearchScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NIP-50 advanced note search subscription (use allRelayUrls)
|
// NIP-50 advanced note search subscription (use searchRelays from relay categories)
|
||||||
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
|
||||||
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
|
if (searchRelays.isEmpty() || debouncedQuery.isEmpty) {
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
||||||
@@ -225,7 +237,7 @@ fun SearchScreen(
|
|||||||
SubscriptionConfig(
|
SubscriptionConfig(
|
||||||
subId = generateSubId("adv-search"),
|
subId = generateSubId("adv-search"),
|
||||||
filters = filters,
|
filters = filters,
|
||||||
relays = allRelayUrls,
|
relays = searchRelays,
|
||||||
onEvent = { event, _, relay, _ ->
|
onEvent = { event, _, relay, _ ->
|
||||||
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
|
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
|
||||||
if (state.trackRelayEvent(relay.url, event.id)) {
|
if (state.trackRelayEvent(relay.url, event.id)) {
|
||||||
@@ -382,6 +394,15 @@ fun SearchScreen(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
)
|
)
|
||||||
|
if (account != null && !account.isReadOnly) {
|
||||||
|
IconButton(onClick = { showRelayPicker = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Dns,
|
||||||
|
contentDescription = "Search Relays",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
IconButton(onClick = { state.togglePanel() }) {
|
IconButton(onClick = { state.togglePanel() }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Tune,
|
Icons.Default.Tune,
|
||||||
@@ -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
|
// Expandable advanced panel
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = panelExpanded,
|
visible = panelExpanded,
|
||||||
|
|||||||
+13
@@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Dns
|
||||||
import androidx.compose.material.icons.filled.Group
|
import androidx.compose.material.icons.filled.Group
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -91,6 +92,7 @@ fun ConversationListPane(
|
|||||||
selectedRoom: ChatroomKey?,
|
selectedRoom: ChatroomKey?,
|
||||||
onConversationSelected: (ChatroomKey) -> Unit,
|
onConversationSelected: (ChatroomKey) -> Unit,
|
||||||
onNewConversation: () -> Unit = {},
|
onNewConversation: () -> Unit = {},
|
||||||
|
onShowRelayPicker: () -> Unit = {},
|
||||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -179,6 +181,17 @@ fun ConversationListPane(
|
|||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onShowRelayPicker,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Dns,
|
||||||
|
contentDescription = "DM Relays",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onNewConversation,
|
onClick = onNewConversation,
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
|
|||||||
+43
@@ -85,6 +85,8 @@ fun DesktopMessagesScreen(
|
|||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||||
|
var showDmRelayPicker by remember { mutableStateOf(false) }
|
||||||
val listState =
|
val listState =
|
||||||
remember(account) {
|
remember(account) {
|
||||||
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
||||||
@@ -110,6 +112,11 @@ fun DesktopMessagesScreen(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
event.key == Key.R && isModifier && event.isShiftPressed -> {
|
||||||
|
showDmRelayPicker = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -126,6 +133,7 @@ fun DesktopMessagesScreen(
|
|||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
listFocusRequester = listFocusRequester,
|
listFocusRequester = listFocusRequester,
|
||||||
onShowNewDm = { showNewDmDialog = true },
|
onShowNewDm = { showNewDmDialog = true },
|
||||||
|
onShowRelayPicker = { showDmRelayPicker = true },
|
||||||
keyHandler = keyHandler,
|
keyHandler = keyHandler,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -138,6 +146,7 @@ fun DesktopMessagesScreen(
|
|||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
listFocusRequester = listFocusRequester,
|
listFocusRequester = listFocusRequester,
|
||||||
onShowNewDm = { showNewDmDialog = true },
|
onShowNewDm = { showNewDmDialog = true },
|
||||||
|
onShowRelayPicker = { showDmRelayPicker = true },
|
||||||
keyHandler = keyHandler,
|
keyHandler = keyHandler,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -154,6 +163,36 @@ fun DesktopMessagesScreen(
|
|||||||
onDismiss = { showNewDmDialog = false },
|
onDismiss = { showNewDmDialog = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDmRelayPicker && accountRelays != null) {
|
||||||
|
val pickerRelays =
|
||||||
|
remember {
|
||||||
|
androidx.compose.runtime.mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
|
||||||
|
it.addAll(accountRelays.dmRelayList.value.sortedBy { r -> r.url })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { showDmRelayPicker = false },
|
||||||
|
title = { androidx.compose.material3.Text("DM Relays") },
|
||||||
|
text = {
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.relay.DmRelayEditor(
|
||||||
|
dmRelays = accountRelays.dmRelayList,
|
||||||
|
signer = account.signer,
|
||||||
|
onPublish = { event ->
|
||||||
|
relayManager.broadcastToAll(event)
|
||||||
|
accountRelays.consumePublishedEvent(event)
|
||||||
|
accountRelays.setDmRelays(pickerRelays.toSet())
|
||||||
|
},
|
||||||
|
onDmRelaysUpdated = { accountRelays.setDmRelays(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { showDmRelayPicker = false }) {
|
||||||
|
androidx.compose.material3.Text("Close")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +209,7 @@ private fun CompactMessagesContent(
|
|||||||
onNavigateToProfile: (String) -> Unit,
|
onNavigateToProfile: (String) -> Unit,
|
||||||
listFocusRequester: FocusRequester,
|
listFocusRequester: FocusRequester,
|
||||||
onShowNewDm: () -> Unit,
|
onShowNewDm: () -> Unit,
|
||||||
|
onShowRelayPicker: () -> Unit = {},
|
||||||
keyHandler: Modifier,
|
keyHandler: Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||||
@@ -208,6 +248,7 @@ private fun CompactMessagesContent(
|
|||||||
selectedRoom = selectedRoom,
|
selectedRoom = selectedRoom,
|
||||||
onConversationSelected = { listState.selectRoom(it) },
|
onConversationSelected = { listState.selectRoom(it) },
|
||||||
onNewConversation = onShowNewDm,
|
onNewConversation = onShowNewDm,
|
||||||
|
onShowRelayPicker = onShowRelayPicker,
|
||||||
focusRequester = listFocusRequester,
|
focusRequester = listFocusRequester,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -229,6 +270,7 @@ private fun SplitMessagesContent(
|
|||||||
onNavigateToProfile: (String) -> Unit,
|
onNavigateToProfile: (String) -> Unit,
|
||||||
listFocusRequester: FocusRequester,
|
listFocusRequester: FocusRequester,
|
||||||
onShowNewDm: () -> Unit,
|
onShowNewDm: () -> Unit,
|
||||||
|
onShowRelayPicker: () -> Unit = {},
|
||||||
keyHandler: Modifier,
|
keyHandler: Modifier,
|
||||||
) {
|
) {
|
||||||
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||||
@@ -237,6 +279,7 @@ private fun SplitMessagesContent(
|
|||||||
selectedRoom = selectedRoom,
|
selectedRoom = selectedRoom,
|
||||||
onConversationSelected = { listState.selectRoom(it) },
|
onConversationSelected = { listState.selectRoom(it) },
|
||||||
onNewConversation = onShowNewDm,
|
onNewConversation = onShowNewDm,
|
||||||
|
onShowRelayPicker = onShowRelayPicker,
|
||||||
focusRequester = listFocusRequester,
|
focusRequester = listFocusRequester,
|
||||||
modifier = Modifier.width(280.dp),
|
modifier = Modifier.width(280.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
+146
@@ -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.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.Edit
|
||||||
import androidx.compose.material.icons.filled.Explore
|
import androidx.compose.material.icons.filled.Explore
|
||||||
import androidx.compose.material.icons.filled.Groups
|
import androidx.compose.material.icons.filled.Groups
|
||||||
@@ -114,6 +115,7 @@ enum class ScreenCategory(
|
|||||||
DISCOVERY("Discovery", Icons.Default.Explore),
|
DISCOVERY("Discovery", Icons.Default.Explore),
|
||||||
IDENTITY("Identity", Icons.Default.Person),
|
IDENTITY("Identity", Icons.Default.Person),
|
||||||
PLAY("Play", Icons.Default.SportsEsports),
|
PLAY("Play", Icons.Default.SportsEsports),
|
||||||
|
NETWORK("Network", Icons.Default.Dns),
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Extensions on DeckColumnType --
|
// -- Extensions on DeckColumnType --
|
||||||
@@ -143,6 +145,8 @@ fun DeckColumnType.category(): ScreenCategory =
|
|||||||
|
|
||||||
DeckColumnType.Chess -> ScreenCategory.PLAY
|
DeckColumnType.Chess -> ScreenCategory.PLAY
|
||||||
|
|
||||||
|
DeckColumnType.Relays -> ScreenCategory.NETWORK
|
||||||
|
|
||||||
// Deep-link types — not in LAUNCHABLE_SCREENS but need a category for exhaustiveness
|
// Deep-link types — not in LAUNCHABLE_SCREENS but need a category for exhaustiveness
|
||||||
is DeckColumnType.Profile,
|
is DeckColumnType.Profile,
|
||||||
is DeckColumnType.Thread,
|
is DeckColumnType.Thread,
|
||||||
@@ -181,6 +185,7 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
|
|||||||
DeckColumnType.Bookmarks,
|
DeckColumnType.Bookmarks,
|
||||||
DeckColumnType.MyProfile,
|
DeckColumnType.MyProfile,
|
||||||
DeckColumnType.Settings,
|
DeckColumnType.Settings,
|
||||||
|
DeckColumnType.Relays,
|
||||||
DeckColumnType.Chess,
|
DeckColumnType.Chess,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
@@ -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.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Article
|
import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.Email
|
||||||
import androidx.compose.material.icons.filled.Extension
|
import androidx.compose.material.icons.filled.Extension
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
@@ -132,6 +133,7 @@ fun DeckColumnType.icon(): ImageVector =
|
|||||||
DeckColumnType.MyProfile -> Icons.Default.Person
|
DeckColumnType.MyProfile -> Icons.Default.Person
|
||||||
DeckColumnType.Chess -> Icons.Default.Extension
|
DeckColumnType.Chess -> Icons.Default.Extension
|
||||||
DeckColumnType.Settings -> Icons.Default.Settings
|
DeckColumnType.Settings -> Icons.Default.Settings
|
||||||
|
DeckColumnType.Relays -> Icons.Default.Dns
|
||||||
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
|
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
|
||||||
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
|
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
|
||||||
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
|
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
|
||||||
|
|||||||
+26
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
|||||||
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
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.drafts.DesktopDraftStore
|
||||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
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.UserProfileScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
|
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -99,10 +101,12 @@ fun DeckColumnContainer(
|
|||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
highlightStore: DesktopHighlightStore,
|
highlightStore: DesktopHighlightStore,
|
||||||
draftStore: DesktopDraftStore,
|
draftStore: DesktopDraftStore,
|
||||||
|
nip11Fetcher: Nip11Fetcher,
|
||||||
appScope: CoroutineScope,
|
appScope: CoroutineScope,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onZapFeedback: (ZapFeedback) -> Unit,
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
|
onNavigateToRelays: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val navState = remember(column.id) { ColumnNavigationState() }
|
val navState = remember(column.id) { ColumnNavigationState() }
|
||||||
@@ -141,6 +145,7 @@ fun DeckColumnContainer(
|
|||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
highlightStore = highlightStore,
|
highlightStore = highlightStore,
|
||||||
draftStore = draftStore,
|
draftStore = draftStore,
|
||||||
|
nip11Fetcher = nip11Fetcher,
|
||||||
appScope = appScope,
|
appScope = appScope,
|
||||||
compactMode = true,
|
compactMode = true,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
@@ -150,6 +155,7 @@ fun DeckColumnContainer(
|
|||||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||||
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
||||||
|
onNavigateToRelays = onNavigateToRelays,
|
||||||
)
|
)
|
||||||
if (currentOverlay != null) {
|
if (currentOverlay != null) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -191,6 +197,7 @@ internal fun RootContent(
|
|||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
highlightStore: DesktopHighlightStore? = null,
|
highlightStore: DesktopHighlightStore? = null,
|
||||||
draftStore: DesktopDraftStore? = null,
|
draftStore: DesktopDraftStore? = null,
|
||||||
|
nip11Fetcher: Nip11Fetcher,
|
||||||
appScope: CoroutineScope,
|
appScope: CoroutineScope,
|
||||||
compactMode: Boolean = false,
|
compactMode: Boolean = false,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
@@ -200,6 +207,7 @@ internal fun RootContent(
|
|||||||
onNavigateToThread: (String) -> Unit,
|
onNavigateToThread: (String) -> Unit,
|
||||||
onNavigateToArticle: (String) -> Unit = {},
|
onNavigateToArticle: (String) -> Unit = {},
|
||||||
onNavigateToEditor: (String?) -> Unit = {},
|
onNavigateToEditor: (String?) -> Unit = {},
|
||||||
|
onNavigateToRelays: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -209,6 +217,7 @@ internal fun RootContent(
|
|||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
|
iAccount = iAccount,
|
||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
initialFeedMode = FeedMode.FOLLOWING,
|
initialFeedMode = FeedMode.FOLLOWING,
|
||||||
@@ -216,6 +225,7 @@ internal fun RootContent(
|
|||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToRelays = onNavigateToRelays,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +249,7 @@ internal fun RootContent(
|
|||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
account = account,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
)
|
)
|
||||||
@@ -275,6 +286,7 @@ internal fun RootContent(
|
|||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
|
iAccount = iAccount,
|
||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
initialFeedMode = FeedMode.GLOBAL,
|
initialFeedMode = FeedMode.GLOBAL,
|
||||||
@@ -282,6 +294,7 @@ internal fun RootContent(
|
|||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
onZapFeedback = onZapFeedback,
|
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 -> {
|
is DeckColumnType.Profile -> {
|
||||||
UserProfileScreen(
|
UserProfileScreen(
|
||||||
pubKeyHex = columnType.pubKeyHex,
|
pubKeyHex = columnType.pubKeyHex,
|
||||||
@@ -398,6 +423,7 @@ internal fun RootContent(
|
|||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
account = account,
|
||||||
initialQuery = "#${columnType.tag}",
|
initialQuery = "#${columnType.tag}",
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
|||||||
+4
@@ -43,6 +43,8 @@ sealed class DeckColumnType {
|
|||||||
|
|
||||||
object Settings : DeckColumnType()
|
object Settings : DeckColumnType()
|
||||||
|
|
||||||
|
object Relays : DeckColumnType()
|
||||||
|
|
||||||
data class Profile(
|
data class Profile(
|
||||||
val pubKeyHex: String,
|
val pubKeyHex: String,
|
||||||
) : DeckColumnType()
|
) : DeckColumnType()
|
||||||
@@ -79,6 +81,7 @@ sealed class DeckColumnType {
|
|||||||
MyProfile -> "Profile"
|
MyProfile -> "Profile"
|
||||||
Chess -> "Chess"
|
Chess -> "Chess"
|
||||||
Settings -> "Settings"
|
Settings -> "Settings"
|
||||||
|
Relays -> "Relays"
|
||||||
is Article -> "Article"
|
is Article -> "Article"
|
||||||
is Editor -> "New Article"
|
is Editor -> "New Article"
|
||||||
Drafts -> "Drafts"
|
Drafts -> "Drafts"
|
||||||
@@ -100,6 +103,7 @@ sealed class DeckColumnType {
|
|||||||
MyProfile -> "my_profile"
|
MyProfile -> "my_profile"
|
||||||
Chess -> "chess"
|
Chess -> "chess"
|
||||||
Settings -> "settings"
|
Settings -> "settings"
|
||||||
|
Relays -> "relays"
|
||||||
is Article -> "article"
|
is Article -> "article"
|
||||||
is Editor -> "editor"
|
is Editor -> "editor"
|
||||||
Drafts -> "drafts"
|
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.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
@@ -64,10 +65,12 @@ fun DeckLayout(
|
|||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
highlightStore: DesktopHighlightStore,
|
highlightStore: DesktopHighlightStore,
|
||||||
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
||||||
|
nip11Fetcher: Nip11Fetcher,
|
||||||
appScope: CoroutineScope,
|
appScope: CoroutineScope,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onZapFeedback: (ZapFeedback) -> Unit,
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
|
onNavigateToRelays: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val columns by deckState.columns.collectAsState()
|
val columns by deckState.columns.collectAsState()
|
||||||
@@ -118,10 +121,12 @@ fun DeckLayout(
|
|||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
highlightStore = highlightStore,
|
highlightStore = highlightStore,
|
||||||
draftStore = draftStore,
|
draftStore = draftStore,
|
||||||
|
nip11Fetcher = nip11Fetcher,
|
||||||
appScope = appScope,
|
appScope = appScope,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToRelays = onNavigateToRelays,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ class DeckState(
|
|||||||
"my_profile" -> DeckColumnType.MyProfile
|
"my_profile" -> DeckColumnType.MyProfile
|
||||||
"chess" -> DeckColumnType.Chess
|
"chess" -> DeckColumnType.Chess
|
||||||
"settings" -> DeckColumnType.Settings
|
"settings" -> DeckColumnType.Settings
|
||||||
|
"relays" -> DeckColumnType.Relays
|
||||||
"drafts" -> DeckColumnType.Drafts
|
"drafts" -> DeckColumnType.Drafts
|
||||||
"highlights" -> DeckColumnType.MyHighlights
|
"highlights" -> DeckColumnType.MyHighlights
|
||||||
"editor" -> DeckColumnType.Editor(param)
|
"editor" -> DeckColumnType.Editor(param)
|
||||||
|
|||||||
+1
@@ -102,6 +102,7 @@ class PinnedNavBarState(
|
|||||||
DeckColumnType.Notifications,
|
DeckColumnType.Notifications,
|
||||||
DeckColumnType.MyProfile,
|
DeckColumnType.MyProfile,
|
||||||
DeckColumnType.Chess,
|
DeckColumnType.Chess,
|
||||||
|
DeckColumnType.Relays,
|
||||||
DeckColumnType.Settings,
|
DeckColumnType.Settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+4
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
|||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
@@ -73,6 +74,7 @@ fun SinglePaneLayout(
|
|||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
highlightStore: DesktopHighlightStore,
|
highlightStore: DesktopHighlightStore,
|
||||||
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
||||||
|
nip11Fetcher: Nip11Fetcher,
|
||||||
appScope: CoroutineScope,
|
appScope: CoroutineScope,
|
||||||
singlePaneState: SinglePaneState,
|
singlePaneState: SinglePaneState,
|
||||||
pinnedNavBarState: PinnedNavBarState,
|
pinnedNavBarState: PinnedNavBarState,
|
||||||
@@ -190,6 +192,7 @@ fun SinglePaneLayout(
|
|||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
highlightStore = highlightStore,
|
highlightStore = highlightStore,
|
||||||
draftStore = draftStore,
|
draftStore = draftStore,
|
||||||
|
nip11Fetcher = nip11Fetcher,
|
||||||
appScope = appScope,
|
appScope = appScope,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
@@ -198,6 +201,7 @@ fun SinglePaneLayout(
|
|||||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||||
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
||||||
|
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
|
||||||
)
|
)
|
||||||
if (currentOverlay != null) {
|
if (currentOverlay != null) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
+199
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.ui.relay
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
||||||
|
|
||||||
|
val LocalAccountRelays =
|
||||||
|
staticCompositionLocalOf<DesktopAccountRelays?> {
|
||||||
|
null
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.ui.relay
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
|
||||||
|
|
||||||
|
val LocalRelayCategories =
|
||||||
|
staticCompositionLocalOf<DesktopRelayCategories> {
|
||||||
|
error("No DesktopRelayCategories provided")
|
||||||
|
}
|
||||||
+292
@@ -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
|
||||||
|
}
|
||||||
+207
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
+171
@@ -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
|
||||||
|
}
|
||||||
+176
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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
|
||||||
|
}
|
||||||
+211
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
---
|
||||||
|
title: "feat: Relay Config Parity — All Desktop-Relevant Categories"
|
||||||
|
type: feat
|
||||||
|
status: draft
|
||||||
|
date: 2026-04-21
|
||||||
|
parent: docs/plans/2026-04-20-feat-relay-power-tools-plan.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Relay Config Parity — All Desktop-Relevant Categories
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 1-2 of Relay Power Tools shipped a dashboard with Monitor tab, a Configure tab with only Connected Relays (NIP-65/DM as placeholders), and a compose relay picker. This plan fills in all relay categories that desktop features actually consume.
|
||||||
|
|
||||||
|
## Scope: Feature-Driven Categories
|
||||||
|
|
||||||
|
Only categories where desktop has a feature that reads/writes them:
|
||||||
|
|
||||||
|
| Category | Kind | Desktop Feature | State Class | Priority |
|
||||||
|
|----------|------|----------------|-------------|----------|
|
||||||
|
| **NIP-65 Inbox/Outbox** | 10002 | Feeds, notifications, outbox publishing | `Nip65RelayListState` (commons) | P0 |
|
||||||
|
| **DM Relays** | 10050 | DMs (DesktopMessagesScreen) | `DesktopAccountRelays._dmRelayList` | P0 |
|
||||||
|
| **Search Relays** | 10007 | Search (SearchScreen, NIP-50) | — (new) | P1 |
|
||||||
|
| **Blocked Relays** | 10006 | Privacy/moderation | — (new) | P2 |
|
||||||
|
| **Connected Relays** | runtime | Everything (fallback pool) | `RelayConnectionManager` | ✅ Done |
|
||||||
|
|
||||||
|
### Explicitly Out of Scope
|
||||||
|
|
||||||
|
| Category | Kind | Why |
|
||||||
|
|----------|------|-----|
|
||||||
|
| Private Outbox | 10013 | Desktop drafts are local-only (`DesktopDraftStore`) |
|
||||||
|
| Indexer Relays | 10086 | Desktop uses connected relays for indexing |
|
||||||
|
| Proxy Relays | 10087 | No proxy relay feature on desktop |
|
||||||
|
| Broadcast Relays | 10088 | Compose picker already handles per-action relay selection |
|
||||||
|
| Trusted Relays | 10089 | No trust-scoring UI on desktop |
|
||||||
|
| Key Package | MIP-00 | No MLS/group messaging on desktop |
|
||||||
|
| Relay Sets | 30002 | No custom grouping UI |
|
||||||
|
| Wiki Relays | 10102 | No wiki feature |
|
||||||
|
| Relay Feeds | 10012 | No relay feeds feature |
|
||||||
|
| Local Relays | custom | No local relay feature |
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Existing (Phase 1-2):
|
||||||
|
├── RelayConfigTab.kt # MODIFY: replace placeholders with real editors
|
||||||
|
├── RelayListEditor.kt # REUSE: generic add/remove/validate
|
||||||
|
├── DesktopAccountRelays.kt # MODIFY: add NIP-65 + search relay state
|
||||||
|
├── DesktopIAccount.kt # Already has Nip65RelayListState
|
||||||
|
|
||||||
|
New:
|
||||||
|
├── Nip65RelayEditor.kt # NEW: inbox/outbox editor with read/write toggles
|
||||||
|
├── DmRelayEditor.kt # NEW: DM relay editor with block-send-if-empty
|
||||||
|
├── SearchRelayEditor.kt # NEW: search relay editor
|
||||||
|
├── BlockedRelayEditor.kt # NEW: blocked relay editor
|
||||||
|
└── DesktopSearchRelayState.kt # NEW: search relay state (kind 10007)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3a: NIP-65 Inbox/Outbox Editor (P0)
|
||||||
|
|
||||||
|
**Why P0:** Feeds, notifications, and publishing all depend on NIP-65. Without this, desktop uses hardcoded default relays.
|
||||||
|
|
||||||
|
**State:** `Nip65RelayListState` already exists in commons and is instantiated in `DesktopIAccount`. Desktop already has `outboxFlow` and `inboxFlow`.
|
||||||
|
|
||||||
|
**UI: `Nip65RelayEditor.kt`**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun Nip65RelayEditor(
|
||||||
|
nip65State: Nip65RelayListState,
|
||||||
|
relayManager: RelayConnectionManager,
|
||||||
|
onPublish: (AdvertisedRelayListEvent) -> Unit,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Two-column or tagged list: each relay has Read/Write/Both toggle
|
||||||
|
- Uses `AdvertisedRelayInfo` with `type` (READ, WRITE, BOTH)
|
||||||
|
- "Save" button calls `nip65State.saveRelayList(relays)` → returns signed event → `onPublish` broadcasts
|
||||||
|
- Shows "Published to X of Y relays" confirmation
|
||||||
|
- "Reset to defaults" option restores `defaultOutboxRelays`/`defaultInboxRelays`
|
||||||
|
|
||||||
|
**Data flow:**
|
||||||
|
1. Read current: `nip65State.getNIP65RelayList()?.relays()` → list of `AdvertisedRelayInfo`
|
||||||
|
2. User edits in mutable local state
|
||||||
|
3. Save: `nip65State.saveRelayList(editedRelays)` → signed `AdvertisedRelayListEvent`
|
||||||
|
4. Broadcast: `relayManager.publish(event, connectedRelays)`
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- Replace "NIP-65 Inbox/Outbox — coming soon" placeholder in `RelayConfigTab`
|
||||||
|
- Thread `nip65State` from `DesktopIAccount` through to `RelayConfigTab`
|
||||||
|
|
||||||
|
### Phase 3b: DM Relay Editor (P0)
|
||||||
|
|
||||||
|
**Why P0:** Desktop has full DM support. DM relays control where encrypted messages are sent/received.
|
||||||
|
|
||||||
|
**State:** `DesktopAccountRelays._dmRelayList` already tracks kind 10050.
|
||||||
|
|
||||||
|
**UI: `DmRelayEditor.kt`**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun DmRelayEditor(
|
||||||
|
dmRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||||
|
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||||
|
signer: NostrSigner,
|
||||||
|
onPublish: (Event) -> Unit,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Simple relay list (no read/write split — DM relays are all-or-nothing)
|
||||||
|
- Warning banner if empty: "No DM relays configured — DMs will use connected relays as fallback"
|
||||||
|
- "Save" builds `ChatMessageRelayListEvent` → sign → publish
|
||||||
|
- Security: highlight that these relays see your DM metadata
|
||||||
|
|
||||||
|
**Data flow:**
|
||||||
|
1. Read current: `accountRelays.dmRelayList` StateFlow
|
||||||
|
2. Save: build `ChatMessageRelayListEvent.create(relays, signer)` → publish
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- Replace "DM Relays — coming soon" placeholder in `RelayConfigTab`
|
||||||
|
- Thread `accountRelays` + `signer` through to `RelayConfigTab`
|
||||||
|
|
||||||
|
### Phase 3c: Search Relay Editor (P1)
|
||||||
|
|
||||||
|
**Why P1:** Desktop has a full search screen with NIP-50 support but currently searches all connected relays. Dedicated search relays improve result quality.
|
||||||
|
|
||||||
|
**State:** New `DesktopSearchRelayState` needed — simple `StateFlow<Set<NormalizedRelayUrl>>` backed by kind 10007 events.
|
||||||
|
|
||||||
|
**UI: `SearchRelayEditor.kt`**
|
||||||
|
|
||||||
|
- Same pattern as DM relay editor
|
||||||
|
- Explain to user: "These relays support NIP-50 full-text search"
|
||||||
|
- Default suggestion: `wss://relay.nostr.band` (common NIP-50 relay)
|
||||||
|
- "Save" builds `SearchRelayListEvent` → sign → publish
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- New section in `RelayConfigTab` after DM Relays
|
||||||
|
- Wire search relay state into `DesktopRelaySubscriptionsCoordinator` for search queries
|
||||||
|
|
||||||
|
### Phase 3d: Blocked Relay Editor (P2)
|
||||||
|
|
||||||
|
**Why P2:** Privacy feature — user can maintain a list of relays they don't want to connect to. Lower priority but simple to implement since pattern is identical.
|
||||||
|
|
||||||
|
**State:** New — kind 10006 `BlockedRelayListEvent`.
|
||||||
|
|
||||||
|
**UI: `BlockedRelayEditor.kt`**
|
||||||
|
|
||||||
|
- List of blocked relay URLs
|
||||||
|
- "Save" publishes kind 10006 event
|
||||||
|
- Integration: filter blocked relays from connection pool (future)
|
||||||
|
|
||||||
|
### Implementation Order
|
||||||
|
|
||||||
|
| Phase | What | Files | Depends On |
|
||||||
|
|-------|------|-------|------------|
|
||||||
|
| 3a | NIP-65 editor | `Nip65RelayEditor.kt`, modify `RelayConfigTab`, modify `DeckColumnContainer` (thread state) | `Nip65RelayListState` (exists) |
|
||||||
|
| 3b | DM relay editor | `DmRelayEditor.kt`, modify `RelayConfigTab` | `DesktopAccountRelays` (exists) |
|
||||||
|
| 3c | Search relay editor | `SearchRelayEditor.kt`, `DesktopSearchRelayState.kt`, modify `RelayConfigTab`, modify subscriptions coordinator | New state class |
|
||||||
|
| 3d | Blocked relay editor | `BlockedRelayEditor.kt`, modify `RelayConfigTab` | `BlockedRelayListEvent` (exists in quartz) |
|
||||||
|
|
||||||
|
### State Threading
|
||||||
|
|
||||||
|
`RelayConfigTab` currently receives only `relayManager`. It needs:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun RelayConfigTab(
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
nip65State: Nip65RelayListState, // Phase 3a
|
||||||
|
accountRelays: DesktopAccountRelays, // Phase 3b (DM relays)
|
||||||
|
signer: NostrSigner, // For signing relay list events
|
||||||
|
onPublish: (Event) -> Unit, // Broadcast signed events
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Thread through: `Main.kt` → `MainContent` → `DeckLayout`/`SinglePaneLayout` → `DeckColumnContainer` → `RootContent` → `RelayDashboardScreen` → `RelayConfigTab`
|
||||||
|
|
||||||
|
Alternative: pass `DesktopIAccount` (already threaded) which has `nip65State` + `signer`, and `accountRelays` (already created alongside).
|
||||||
|
|
||||||
|
### Shared `RelayListEditor` Pattern
|
||||||
|
|
||||||
|
All editors follow the same pattern — reuse `RelayListEditor` for the add/remove/validate part. Each editor wraps it with:
|
||||||
|
1. Category-specific header + description
|
||||||
|
2. Optional per-relay toggles (read/write for NIP-65)
|
||||||
|
3. Save button that builds the right event kind
|
||||||
|
4. Publish feedback
|
||||||
|
|
||||||
|
### Event Kind Reference (from quartz)
|
||||||
|
|
||||||
|
| Kind | Event Class | Tag Format |
|
||||||
|
|------|------------|------------|
|
||||||
|
| 10002 | `AdvertisedRelayListEvent` | `["r", "wss://...", "read"\|"write"]` |
|
||||||
|
| 10050 | `ChatMessageRelayListEvent` | `["relay", "wss://..."]` |
|
||||||
|
| 10007 | `SearchRelayListEvent` | `["relay", "wss://..."]` |
|
||||||
|
| 10006 | `BlockedRelayListEvent` | `["relay", "wss://..."]` |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Phase 3a: NIP-65
|
||||||
|
- [ ] NIP-65 section shows current inbox/outbox relays from user's kind 10002
|
||||||
|
- [ ] Each relay has Read/Write/Both toggle
|
||||||
|
- [ ] Can add/remove relays
|
||||||
|
- [ ] Save signs + publishes AdvertisedRelayListEvent
|
||||||
|
- [ ] Shows "Published to X of Y" confirmation
|
||||||
|
- [ ] Reset to defaults option
|
||||||
|
|
||||||
|
### Phase 3b: DM Relays
|
||||||
|
- [ ] DM section shows current kind 10050 relays
|
||||||
|
- [ ] Warning if empty
|
||||||
|
- [ ] Save signs + publishes ChatMessageRelayListEvent
|
||||||
|
- [ ] Shows publish confirmation
|
||||||
|
|
||||||
|
### Phase 3c: Search Relays
|
||||||
|
- [ ] Search section shows current kind 10007 relays
|
||||||
|
- [ ] Suggests relay.nostr.band if empty
|
||||||
|
- [ ] Save signs + publishes SearchRelayListEvent
|
||||||
|
- [ ] Search screen uses configured search relays
|
||||||
|
|
||||||
|
### Phase 3d: Blocked Relays
|
||||||
|
- [ ] Blocked section shows kind 10006 relays
|
||||||
|
- [ ] Save signs + publishes BlockedRelayListEvent
|
||||||
|
|
||||||
|
## Unanswered Questions
|
||||||
|
|
||||||
|
1. Should NIP-65 editor show relays from the user's existing event, or from `defaultOutboxRelays`/`defaultInboxRelays` if no event exists?
|
||||||
|
2. Should DM relay editor block fallback to connected relays (per original plan security decision) or just warn?
|
||||||
|
3. For search relays — should we auto-detect NIP-50 support via NIP-11 `supported_nips` field and suggest capable relays from the connected pool?
|
||||||
|
4. Should publishing relay list events use `connectedRelays` or the NIP-65 outbox relays? (Bootstrap problem if NIP-65 is empty.)
|
||||||
|
5. How to handle the threading of 4+ state objects through the composable tree — pass `DesktopIAccount` directly or keep explicit params?
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
---
|
||||||
|
title: "feat: Wire Relay Config Categories into Desktop Subscriptions"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-04-21
|
||||||
|
origin: docs/plans/2026-04-21-feat-relay-config-parity-plan.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Wire Relay Config Categories into Desktop Subscriptions
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Desktop's relay config UI publishes relay list events (kinds 10002, 10050, 10007, 10006) but the app itself uses hardcoded defaults or all connected relays for every subscription. This plan wires each relay category into the features that consume it.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
| Feature | Currently Uses | Should Use |
|
||||||
|
|---------|---------------|------------|
|
||||||
|
| Home feed | all connected relays | NIP-65 outbox (kind 10002 write) |
|
||||||
|
| Notifications | all connected relays | NIP-65 inbox (kind 10002 read) |
|
||||||
|
| Search | all connected relays | Search relays (kind 10007) |
|
||||||
|
| DMs | `emptySet()` fallback → connected | DM relays (kind 10050) |
|
||||||
|
| All features | no filtering | Minus blocked relays (kind 10006) |
|
||||||
|
|
||||||
|
**Root causes:**
|
||||||
|
1. `DesktopAccountRelays` is dead code — never instantiated in Main.kt
|
||||||
|
2. `DesktopDmRelayState` in Main.kt uses hardcoded `MutableStateFlow(emptySet())`
|
||||||
|
3. No desktop state holders for search or blocked relay lists
|
||||||
|
4. No bootstrap subscription fetches user's own relay config events at login
|
||||||
|
5. Screens pass `allRelayUrls` to all `rememberSubscription` calls
|
||||||
|
6. `DesktopRelaySubscriptionsCoordinator.indexRelays` is static
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
4 phases, each independently shippable:
|
||||||
|
1. Bootstrap subscription + wire existing state objects
|
||||||
|
2. Create missing state holders + aggregate relay categories
|
||||||
|
3. Update screens to consume category relay sets
|
||||||
|
4. Persist relay lists to Preferences
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
New/Modified:
|
||||||
|
├── Main.kt # MODIFY: instantiate DesktopAccountRelays, bootstrap sub
|
||||||
|
├── model/
|
||||||
|
│ ├── DesktopAccountRelays.kt # MODIFY: add search + blocked relay tracking
|
||||||
|
│ ├── DesktopRelayCategories.kt # NEW: aggregator with fallback logic
|
||||||
|
│ └── DesktopDmRelayState.kt # UNCHANGED (already correct)
|
||||||
|
├── subscriptions/
|
||||||
|
│ ├── DesktopRelaySubscriptionsCoordinator.kt # MODIFY: accept reactive relay sets
|
||||||
|
│ ├── BootstrapSubscription.kt # NEW: fetch user's kind 10002/10050/10007/10006
|
||||||
|
│ └── SubscriptionUtils.kt # MODIFY: rememberSubscription keys
|
||||||
|
├── ui/
|
||||||
|
│ ├── FeedScreen.kt # MODIFY: use NIP-65 outbox relays
|
||||||
|
│ ├── SearchScreen.kt # MODIFY: use search relays
|
||||||
|
│ └── NotificationsScreen.kt # MODIFY: use NIP-65 inbox relays
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Outbox model | v1: all filters to all outbox relays | True per-relay filter maps requires SubscriptionConfig redesign — defer |
|
||||||
|
| Search fallback | Hardcoded NIP-50 relays (relay.nostr.band) | Most connected relays don't support NIP-50 |
|
||||||
|
| DM fallback | Connected relays (existing behavior) | Blocking send is too aggressive for desktop UX |
|
||||||
|
| Blocked subtraction | Utility function at call site | Centralized in `RelayConnectionManager.subscribe` is opaque and affects all callers |
|
||||||
|
| Relay set change → resub | Debounce 1s via `distinctUntilChanged()` on StateFlows | Prevents thrashing during startup when multiple kind events arrive |
|
||||||
|
| Auto-connect category relays | Yes, add to relay pool if not present | DM/search relays from events may not be in connected set |
|
||||||
|
| Persist relay lists | java.util.prefs.Preferences | Consistent with existing DesktopPreferences pattern |
|
||||||
|
| FeedMetadataCoordinator.indexRelays | Keep static, recreate coordinator when relays change | Avoids commons API change |
|
||||||
|
|
||||||
|
### Phase 1: Bootstrap + Wire Existing State
|
||||||
|
|
||||||
|
**Goal:** Fetch user's relay config on login. Wire `DesktopAccountRelays` into Main.kt so kind 10050 events actually update DM relay state.
|
||||||
|
|
||||||
|
**1a. Create `BootstrapSubscription.kt`**
|
||||||
|
|
||||||
|
Fetches user's own replaceable events (kinds 10002, 10050, 10007, 10006) from connected relays on login.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class BootstrapSubscription(
|
||||||
|
private val relayManager: RelayConnectionManager,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
fun subscribe(
|
||||||
|
userPubKeyHex: HexKey,
|
||||||
|
onEvent: (Event) -> Unit,
|
||||||
|
) {
|
||||||
|
val filter = Filter(
|
||||||
|
kinds = listOf(10002, 10050, 10007, 10006),
|
||||||
|
authors = listOf(userPubKeyHex),
|
||||||
|
limit = 4,
|
||||||
|
)
|
||||||
|
relayManager.subscribe(
|
||||||
|
subId = "bootstrap-relay-config",
|
||||||
|
filters = listOf(filter),
|
||||||
|
listener = object : SubscriptionListener {
|
||||||
|
override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
|
||||||
|
onEvent(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1b. Instantiate `DesktopAccountRelays` in Main.kt**
|
||||||
|
|
||||||
|
Replace the standalone `DesktopDmRelayState(MutableStateFlow(emptySet()), ...)` with `DesktopAccountRelays`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// In Main.kt logged-in section (replace lines 924-929)
|
||||||
|
val accountRelays = remember(account, relayManager, scope) {
|
||||||
|
DesktopAccountRelays(account.pubKeyHex, relayManager, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap: fetch user's relay config events
|
||||||
|
LaunchedEffect(accountRelays) {
|
||||||
|
relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||||
|
BootstrapSubscription(relayManager, scope).subscribe(account.pubKeyHex) { event ->
|
||||||
|
accountRelays.consumeIfRelevant(event) // routes to appropriate handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1c. Expand `DesktopAccountRelays` to route all relay config events**
|
||||||
|
|
||||||
|
Add methods to consume kinds 10002, 10007, 10006 (currently only handles 10050):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// In DesktopAccountRelays
|
||||||
|
private val _searchRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||||
|
val searchRelayList: StateFlow<Set<NormalizedRelayUrl>> = _searchRelayList.asStateFlow()
|
||||||
|
|
||||||
|
private val _blockedRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||||
|
val blockedRelayList: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelayList.asStateFlow()
|
||||||
|
|
||||||
|
fun consumeIfRelevant(event: Event): Boolean {
|
||||||
|
return when (event.kind) {
|
||||||
|
ChatMessageRelayListEvent.KIND -> { consumeDmRelayList(event as ChatMessageRelayListEvent); true }
|
||||||
|
SearchRelayListEvent.KIND -> { consumeSearchRelayList(event); true }
|
||||||
|
BlockedRelayListEvent.KIND -> { consumeBlockedRelayList(event); true }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1d. Thread `accountRelays` to MainContent and layouts**
|
||||||
|
|
||||||
|
Single instance created in Main.kt, passed through composable tree. Replace the per-column `DesktopAccountRelays` created in `DeckColumnContainer`.
|
||||||
|
|
||||||
|
### Phase 2: Relay Category Aggregator
|
||||||
|
|
||||||
|
**Goal:** Single source of truth for "which relays should feature X use?" with fallback logic.
|
||||||
|
|
||||||
|
**Create `DesktopRelayCategories.kt`**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class DesktopRelayCategories(
|
||||||
|
private val nip65State: Nip65RelayListState,
|
||||||
|
private val accountRelays: DesktopAccountRelays,
|
||||||
|
private val connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
/** Relays for home feed / publishing. NIP-65 write → fallback to connected */
|
||||||
|
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
nip65State.outboxFlow,
|
||||||
|
connectedRelays,
|
||||||
|
) { outbox, connected -> outbox.ifEmpty { connected } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||||
|
|
||||||
|
/** Relays for receiving notifications. NIP-65 read → fallback to connected */
|
||||||
|
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
nip65State.inboxFlow,
|
||||||
|
connectedRelays,
|
||||||
|
) { inbox, connected -> inbox.ifEmpty { connected } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||||
|
|
||||||
|
/** Relays for NIP-50 search. Search list → fallback to DEFAULT_SEARCH_RELAYS */
|
||||||
|
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> = accountRelays.searchRelayList
|
||||||
|
.map { it.ifEmpty { DEFAULT_SEARCH_RELAYS } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
|
||||||
|
|
||||||
|
/** DM relays — already handled by DesktopDmRelayState with fallback */
|
||||||
|
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> = accountRelays.dmRelays.flow
|
||||||
|
|
||||||
|
/** Blocked relays to exclude from all subscriptions */
|
||||||
|
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>> = accountRelays.blockedRelayList
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT_SEARCH_RELAYS = setOf(
|
||||||
|
NormalizedRelayUrl("wss://relay.nostr.band/"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Update Screens
|
||||||
|
|
||||||
|
**3a. SearchScreen.kt**
|
||||||
|
|
||||||
|
Replace `allRelayUrls` with `relayCategories.searchRelays`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Current (line 175):
|
||||||
|
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
||||||
|
// relays = allRelayUrls
|
||||||
|
|
||||||
|
// Target:
|
||||||
|
val searchRelays by relayCategories.searchRelays.collectAsState()
|
||||||
|
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
|
||||||
|
// relays = searchRelays
|
||||||
|
```
|
||||||
|
|
||||||
|
Thread `relayCategories` to SearchScreen via RootContent params.
|
||||||
|
|
||||||
|
**3b. FeedScreen.kt**
|
||||||
|
|
||||||
|
Replace `allRelayUrls` with `relayCategories.feedRelays`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val feedRelays by relayCategories.feedRelays.collectAsState()
|
||||||
|
rememberSubscription(feedRelays, ..., relayManager = relayManager) {
|
||||||
|
// relays = feedRelays
|
||||||
|
```
|
||||||
|
|
||||||
|
**3c. NotificationsScreen.kt**
|
||||||
|
|
||||||
|
Use `relayCategories.notificationRelays` for incoming notification subscriptions.
|
||||||
|
|
||||||
|
**3d. Blocked relay subtraction**
|
||||||
|
|
||||||
|
Add utility:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// In RelayValidation.kt or new file
|
||||||
|
fun Set<NormalizedRelayUrl>.minusBlocked(
|
||||||
|
blocked: Set<NormalizedRelayUrl>
|
||||||
|
): Set<NormalizedRelayUrl> = this - blocked
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply at each subscription site:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val effectiveRelays = feedRelays.minusBlocked(blockedRelays)
|
||||||
|
```
|
||||||
|
|
||||||
|
**3e. Make DM subscriptions reactive**
|
||||||
|
|
||||||
|
In Main.kt, replace one-shot `subscribeToDms()` with a collector:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
LaunchedEffect(accountRelays) {
|
||||||
|
accountRelays.dmRelays.flow.collect { dmRelaySet ->
|
||||||
|
subscriptionsCoordinator.resubscribeToDms(account.pubKeyHex, accountRelays.dmRelays, onDmEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Persistence
|
||||||
|
|
||||||
|
**Goal:** Relay lists survive app restart without waiting for bootstrap fetch.
|
||||||
|
|
||||||
|
Save last-known relay lists to `DesktopPreferences`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// On every relay list update
|
||||||
|
DesktopPreferences.nip65RelayList = mapper.writeValueAsString(outboxRelays)
|
||||||
|
DesktopPreferences.dmRelayList = mapper.writeValueAsString(dmRelays)
|
||||||
|
DesktopPreferences.searchRelayList = mapper.writeValueAsString(searchRelays)
|
||||||
|
DesktopPreferences.blockedRelayList = mapper.writeValueAsString(blockedRelays)
|
||||||
|
|
||||||
|
// On startup, before bootstrap fetch completes
|
||||||
|
val savedDmRelays = mapper.readValue<Set<String>>(DesktopPreferences.dmRelayList)
|
||||||
|
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||||
|
accountRelays.setDmRelays(savedDmRelays)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Phase 1: Bootstrap + Wire
|
||||||
|
- [ ] `DesktopAccountRelays` instantiated once in Main.kt, threaded to all screens
|
||||||
|
- [ ] Bootstrap subscription fetches kinds 10002, 10050, 10007, 10006 on login
|
||||||
|
- [ ] Kind 10050 events update `accountRelays.dmRelayList` (no longer hardcoded empty)
|
||||||
|
- [ ] Kind 10007 events populate `accountRelays.searchRelayList`
|
||||||
|
- [ ] Kind 10006 events populate `accountRelays.blockedRelayList`
|
||||||
|
- [ ] Remove per-column `DesktopAccountRelays` from DeckColumnContainer
|
||||||
|
|
||||||
|
### Phase 2: Aggregator
|
||||||
|
- [ ] `DesktopRelayCategories` exposes `feedRelays`, `notificationRelays`, `searchRelays`, `dmRelays`, `blockedRelays`
|
||||||
|
- [ ] Each StateFlow has appropriate fallback (connected, default search relays)
|
||||||
|
- [ ] `distinctUntilChanged()` on all flows to prevent subscription thrashing
|
||||||
|
|
||||||
|
### Phase 3: Screen Wiring
|
||||||
|
- [ ] SearchScreen uses `searchRelays` for NIP-50 subscriptions
|
||||||
|
- [ ] FeedScreen uses `feedRelays` for feed subscriptions
|
||||||
|
- [ ] NotificationsScreen uses `notificationRelays`
|
||||||
|
- [ ] Blocked relays subtracted from all subscription relay sets
|
||||||
|
- [ ] DM subscriptions reactive — resubscribe when dmRelays flow changes
|
||||||
|
- [ ] `rememberSubscription` keys on category relay sets
|
||||||
|
|
||||||
|
### Phase 4: Persistence
|
||||||
|
- [ ] Relay lists saved to Preferences on every update
|
||||||
|
- [ ] Loaded from Preferences on startup before bootstrap completes
|
||||||
|
- [ ] Bootstrap fetch overwrites saved data with fresh data from relays
|
||||||
|
|
||||||
|
## Dependencies & Risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Subscription thrashing during startup | Medium | Medium | `distinctUntilChanged()` + 1s debounce on relay set changes |
|
||||||
|
| Bootstrap subscription returns stale events | Low | Low | Replaceable events — latest `created_at` wins |
|
||||||
|
| DM relays not in connected set | Medium | Medium | Auto-add to relay pool on relay set change |
|
||||||
|
| Blocked relay decryption fails (NIP-44) | Low | Low | Graceful fallback — empty blocked set |
|
||||||
|
| Coordinator recreation on relay change | Medium | Low | Debounce, only recreate when relay set structurally changes |
|
||||||
|
|
||||||
|
## Unanswered Questions
|
||||||
|
|
||||||
|
1. Should bootstrap subscription unsubscribe after receiving all 4 kinds, or stay open for live updates?
|
||||||
|
2. Should `DesktopRelayCategories` auto-add category relays to the relay pool (so they connect)?
|
||||||
|
3. For blocked relay decryption — can we use `signer.decrypt()` directly or need async handling for NIP-46?
|
||||||
|
4. Should we show a UI indicator when operating on fallback relays ("No NIP-65 published — using defaults")?
|
||||||
|
5. True outbox model (per-relay filter maps) — defer to separate plan or include as Phase 5?
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
---
|
||||||
|
title: "feat: Relay Config Persistence, Correct Counts, Per-Screen Picker"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-04-22
|
||||||
|
origin: docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Relay Config Persistence, Correct Counts, Per-Screen Picker
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three fixes to make relay management work end-to-end: persist config across restarts, show correct per-category relay counts, and add inline relay editing per screen.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
1. **Config lost on restart/reopen**: Search/DM/blocked relay lists vanish — no Preferences persistence, NIP-51 private tags not decrypted
|
||||||
|
2. **Wrong relay counts**: Search shows "0 of 7 relays responded" against all connected relays, not the 1 configured search relay
|
||||||
|
3. **No per-screen editing**: Must navigate to full Dashboard to change which relays a feature uses
|
||||||
|
|
||||||
|
(see brainstorm: `docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md`)
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Design Decisions (from brainstorm)
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Persistence format | Raw event JSON per kind | Preserves `created_at` for dedup, matches Android `backupXxxRelayList` pattern |
|
||||||
|
| Preferences keys | `relay_<kind>_<pubkey-prefix>` | Per-account isolation, avoids key collision |
|
||||||
|
| NIP-51 decryption | Decrypt lazily when signer available, persist encrypted | Don't leak private relay info to disk |
|
||||||
|
| Relay count source | Category-specific `searchRelays.size`, `feedRelays.size` | Not `allRelayUrls.size` |
|
||||||
|
| Picker type | Subscribe-FROM picker (changes which relays screen uses) | Different from compose picker (publish-TO) |
|
||||||
|
| NIP-65 picker | Read/write toggles in expandable form | Other categories are simple add/remove |
|
||||||
|
| Publish on save | Immediately with loading/error state | User confirmed |
|
||||||
|
| Connection dots | Yes | User confirmed |
|
||||||
|
|
||||||
|
### Phase 1: Persistence + NIP-51 Decryption
|
||||||
|
|
||||||
|
**`DesktopRelayListPersistence.kt`** (new):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object DesktopRelayListPersistence {
|
||||||
|
private val prefs = Preferences.userNodeForPackage(DesktopRelayListPersistence::class.java)
|
||||||
|
|
||||||
|
fun saveEvent(kind: Int, pubKeyHex: String, event: Event) {
|
||||||
|
prefs.put(key(kind, pubKeyHex), event.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadEvent(kind: Int, pubKeyHex: String): Event? {
|
||||||
|
val json = prefs.get(key(kind, pubKeyHex), "")
|
||||||
|
if (json.isBlank()) return null
|
||||||
|
return try { Event.fromJson(json) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(kind: Int, pubKeyHex: String) = "relay_${kind}_${pubKeyHex.take(8)}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`DesktopAccountRelays.kt`** changes:
|
||||||
|
- Accept `NostrSigner` in constructor
|
||||||
|
- Accept `scope: CoroutineScope` for async decryption
|
||||||
|
- `consumeIfRelevant` becomes `suspend` — calls `event.relays(signer)` for NIP-51 kinds
|
||||||
|
- On every state change → `DesktopRelayListPersistence.saveEvent(kind, pubKeyHex, event)`
|
||||||
|
- `loadFromPersistence()` method — loads events, decrypts NIP-51 in coroutine
|
||||||
|
- Call `loadFromPersistence()` in `init {}` block
|
||||||
|
|
||||||
|
**`Main.kt`** changes:
|
||||||
|
- Pass `signer` to `DesktopAccountRelays` constructor
|
||||||
|
- Bootstrap `LaunchedEffect` launches `consumeIfRelevant` in coroutine (now suspend)
|
||||||
|
|
||||||
|
### Phase 2: Correct Per-Screen Relay Counts
|
||||||
|
|
||||||
|
**`SearchScreen.kt`** changes:
|
||||||
|
- Replace `state.initRelayStates(allRelayUrls)` with `state.initRelayStates(searchRelays)`
|
||||||
|
- `searchRelays` already from `LocalRelayCategories.current.searchRelays.collectAsState()`
|
||||||
|
- Banner shows "0 of 1 relays responded" when 1 search relay configured
|
||||||
|
|
||||||
|
**`FeedScreen.kt`** changes:
|
||||||
|
- Replace `"${connectedRelays.size} relays connected"` with `"${feedRelays.size} feed relays"`
|
||||||
|
- `feedRelays` already from `LocalRelayCategories.current.feedRelays.collectAsState()`
|
||||||
|
|
||||||
|
**`AdvancedSearchBarState.kt`** changes:
|
||||||
|
- `initRelayStates` takes `Set<NormalizedRelayUrl>` instead of `Set<Any>`
|
||||||
|
|
||||||
|
### Phase 3: Per-Screen Relay Picker Dialog
|
||||||
|
|
||||||
|
**`RelayPickerDialog.kt`** (new):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun RelayPickerDialog(
|
||||||
|
title: String, // "Search Relays", "Feed Relays", etc.
|
||||||
|
currentRelays: List<NormalizedRelayUrl>, // or List<AdvertisedRelayInfo> for NIP-65
|
||||||
|
connectedRelays: Set<NormalizedRelayUrl>,
|
||||||
|
signer: NostrSigner,
|
||||||
|
isNip65: Boolean = false, // show read/write toggles
|
||||||
|
onSave: suspend (List<NormalizedRelayUrl>) -> Event, // returns signed event
|
||||||
|
onPublish: (Event) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
UI:
|
||||||
|
- Modal dialog with category title
|
||||||
|
- Relay list with connection status dots (green/gray)
|
||||||
|
- Add relay input with validation
|
||||||
|
- NIP-65 mode: expandable read/write/both toggles per relay
|
||||||
|
- Save button with loading spinner + error text
|
||||||
|
- Flow: Save → `isLoading = true` → `onSave()` → `onPublish()` → persist → `isLoading = false` → `onDismiss()`
|
||||||
|
|
||||||
|
**Screen integration — relay icon buttons:**
|
||||||
|
|
||||||
|
| Screen | Location | Category | Picker type |
|
||||||
|
|--------|----------|----------|-------------|
|
||||||
|
| SearchScreen | Next to search bar | Search relays | Simple add/remove |
|
||||||
|
| FeedScreen | Next to "X feed relays" text | NIP-65 outbox | Read/write toggles |
|
||||||
|
| DM screen | Header area | DM relays | Simple add/remove |
|
||||||
|
|
||||||
|
Each screen:
|
||||||
|
```kotlin
|
||||||
|
var showRelayPicker by remember { mutableStateOf(false) }
|
||||||
|
// Relay icon button
|
||||||
|
IconButton(onClick = { showRelayPicker = true }) { Icon(Icons.Default.Dns, ...) }
|
||||||
|
// Dialog
|
||||||
|
if (showRelayPicker) {
|
||||||
|
RelayPickerDialog(
|
||||||
|
title = "Search Relays",
|
||||||
|
currentRelays = searchRelays.toList(),
|
||||||
|
connectedRelays = connectedRelays,
|
||||||
|
signer = signer,
|
||||||
|
onSave = { relays -> SearchRelayListEvent.create(relays, signer) },
|
||||||
|
onPublish = { event -> relayManager.broadcastToAll(event); accountRelays.setSearchRelays(relays) },
|
||||||
|
onDismiss = { showRelayPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `DesktopRelayListPersistence.kt` | NEW: save/load event JSON per kind per account |
|
||||||
|
| `DesktopAccountRelays.kt` | Add signer, suspend consumeIfRelevant, persistence, NIP-51 decrypt |
|
||||||
|
| `RelayPickerDialog.kt` | NEW: modal per-screen relay editor with loading/error |
|
||||||
|
| `SearchScreen.kt` | Fix relay count init, add relay picker icon |
|
||||||
|
| `FeedScreen.kt` | Fix relay count text, add relay picker icon |
|
||||||
|
| `Main.kt` | Pass signer to accountRelays, update bootstrap for suspend |
|
||||||
|
| `AdvancedSearchBarState.kt` | `initRelayStates` takes typed Set |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Phase 1: Persistence
|
||||||
|
- [ ] Relay configs survive app restart (save to Preferences, load on startup)
|
||||||
|
- [ ] NIP-51 events decrypted via signer when available
|
||||||
|
- [ ] Bootstrap overwrites persisted data only if newer (`created_at`)
|
||||||
|
- [ ] Per-account key isolation (pubkey prefix)
|
||||||
|
- [ ] Graceful fallback on corrupt/missing persistence data
|
||||||
|
|
||||||
|
### Phase 2: Relay Counts
|
||||||
|
- [ ] SearchScreen shows "X of Y" against search relay set size, not all connected
|
||||||
|
- [ ] FeedScreen shows feed relay count, not connected relay count
|
||||||
|
- [ ] Counts update when relay set changes
|
||||||
|
|
||||||
|
### Phase 3: Per-Screen Picker
|
||||||
|
- [ ] Relay icon on SearchScreen, FeedScreen, DM screen opens picker dialog
|
||||||
|
- [ ] Picker shows current category relays with connection status dots
|
||||||
|
- [ ] Add/remove with validation (domain check, wss:// required)
|
||||||
|
- [ ] NIP-65 picker has expandable read/write/both toggles
|
||||||
|
- [ ] Save publishes immediately with loading spinner
|
||||||
|
- [ ] Error handling: signing failure, publish failure shown in dialog
|
||||||
|
- [ ] Screen resubscribes after picker save
|
||||||
|
|
||||||
|
## Dependencies & Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|-----------|
|
||||||
|
| Preferences 8KB limit per key | Relay list JSON is typically <2KB — safe. Monitor for large lists |
|
||||||
|
| NIP-46 signer timeout on decrypt | Persist encrypted event, decrypt lazily. Show relays as "loading..." |
|
||||||
|
| Picker save echoes back via bootstrap | Dedup by `created_at` — same or newer event is no-op |
|
||||||
|
| `consumeIfRelevant` now suspend | Bootstrap already runs in coroutine scope |
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- **Origin brainstorm:** [docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md](docs/brainstorms/2026-04-22-relay-config-persistence-and-per-screen-editing-brainstorm.md) — Key decisions: persist raw event JSON, decrypt NIP-51 lazily, publish immediately from picker
|
||||||
|
- Android persistence pattern: `amethyst/LocalPreferences.kt` lines 113-128
|
||||||
|
- Android AccountSettings: `amethyst/model/AccountSettings.kt` lines 196-202
|
||||||
|
- Desktop Preferences pattern: `desktopApp/.../DesktopPreferences.kt`
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
---
|
||||||
|
title: "feat: Wire Relay Config Categories into Desktop Subscriptions"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-04-22
|
||||||
|
origin: docs/plans/2026-04-21-feat-relay-config-parity-plan.md
|
||||||
|
deepened: 2026-04-22
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Wire Relay Config Categories into Desktop Subscriptions
|
||||||
|
|
||||||
|
## Enhancement Summary
|
||||||
|
|
||||||
|
**Deepened on:** 2026-04-22
|
||||||
|
**Review agents used:** nostr-expert, kotlin-coroutines, desktop-expert, performance-oracle, architecture-strategist, code-simplicity-reviewer
|
||||||
|
|
||||||
|
### Key Changes from Review
|
||||||
|
1. **2 phases, not 4** — merged aggregator into Phase 1, cut persistence (YAGNI)
|
||||||
|
2. **No new files for BootstrapSubscription or minusBlocked** — inline everything
|
||||||
|
3. **Keep DesktopRelayCategories** but provide via `LocalRelayCategories` CompositionLocal (matches `LocalTorState` pattern)
|
||||||
|
4. **Bake blocked subtraction + `debounce(1s)` into aggregator** — centralized, not per-call-site
|
||||||
|
5. **Route bootstrap through localCache** — keeps Nip65RelayListState in sync
|
||||||
|
6. **Kind 10006/10007 need NIP-51 decryption** — use `privateTags(signer)`
|
||||||
|
7. **`connectedRelays.first { isNotEmpty }` needs timeout** — `withTimeoutOrNull(30s)`
|
||||||
|
8. **Make FeedMetadataCoordinator.indexRelays mutable** — avoid recreation, preserve dedupe state
|
||||||
|
9. **Add `created_at` checking** in consumeIfRelevant to prevent stale overwrites
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
| Feature | Currently Uses | Should Use |
|
||||||
|
|---------|---------------|------------|
|
||||||
|
| Home feed | all connected relays | NIP-65 outbox (kind 10002 write) |
|
||||||
|
| Notifications | all connected relays | NIP-65 inbox (kind 10002 read) |
|
||||||
|
| Search | all connected relays | Search relays (kind 10007) |
|
||||||
|
| DMs | `emptySet()` fallback → connected | DM relays (kind 10050) |
|
||||||
|
| All features | no filtering | Minus blocked relays (kind 10006) |
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Phase 1: Wire State + Bootstrap (single PR)
|
||||||
|
|
||||||
|
**1a. Expand `DesktopAccountRelays.kt`**
|
||||||
|
|
||||||
|
Add search/blocked StateFlows + `consumeIfRelevant()` dispatcher with `created_at` dedup:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val _searchRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||||
|
val searchRelayList: StateFlow<Set<NormalizedRelayUrl>> = _searchRelayList.asStateFlow()
|
||||||
|
|
||||||
|
private val _blockedRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||||
|
val blockedRelayList: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelayList.asStateFlow()
|
||||||
|
|
||||||
|
// Track created_at to prevent stale overwrites
|
||||||
|
private var lastSearchCreatedAt = 0L
|
||||||
|
private var lastBlockedCreatedAt = 0L
|
||||||
|
private var lastDmCreatedAt = 0L
|
||||||
|
|
||||||
|
fun consumeIfRelevant(event: Event): Boolean {
|
||||||
|
return when (event.kind) {
|
||||||
|
ChatMessageRelayListEvent.KIND -> {
|
||||||
|
if (event.createdAt > lastDmCreatedAt) {
|
||||||
|
lastDmCreatedAt = event.createdAt
|
||||||
|
consumeDmRelayList(event as ChatMessageRelayListEvent)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
SearchRelayListEvent.KIND -> {
|
||||||
|
if (event.createdAt > lastSearchCreatedAt) {
|
||||||
|
lastSearchCreatedAt = event.createdAt
|
||||||
|
// NIP-51: try public tags first, then decrypt private
|
||||||
|
val relays = (event as? SearchRelayListEvent)?.publicRelays()?.toSet() ?: emptySet()
|
||||||
|
_searchRelayList.value = relays
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
BlockedRelayListEvent.KIND -> {
|
||||||
|
if (event.createdAt > lastBlockedCreatedAt) {
|
||||||
|
lastBlockedCreatedAt = event.createdAt
|
||||||
|
val relays = (event as? BlockedRelayListEvent)?.publicRelays()?.toSet() ?: emptySet()
|
||||||
|
_blockedRelayList.value = relays
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1b. Create `DesktopRelayCategories.kt`** — aggregator with fallback + blocked subtraction + debounce
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class DesktopRelayCategories(
|
||||||
|
nip65State: Nip65RelayListState,
|
||||||
|
accountRelays: DesktopAccountRelays,
|
||||||
|
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
nip65State.outboxFlow, connectedRelays, accountRelays.blockedRelayList,
|
||||||
|
) { outbox, connected, blocked ->
|
||||||
|
(outbox.ifEmpty { connected }) - blocked
|
||||||
|
}.debounce(1.seconds).distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||||
|
|
||||||
|
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
nip65State.inboxFlow, connectedRelays, accountRelays.blockedRelayList,
|
||||||
|
) { inbox, connected, blocked ->
|
||||||
|
(inbox.ifEmpty { connected }) - blocked
|
||||||
|
}.debounce(1.seconds).distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, connectedRelays.value)
|
||||||
|
|
||||||
|
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
accountRelays.searchRelayList, accountRelays.blockedRelayList,
|
||||||
|
) { search, blocked ->
|
||||||
|
(search.ifEmpty { DEFAULT_SEARCH_RELAYS }) - blocked
|
||||||
|
}.debounce(1.seconds).distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
|
||||||
|
|
||||||
|
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> = accountRelays.dmRelays.flow
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT_SEARCH_RELAYS = setOf(NormalizedRelayUrl("wss://relay.nostr.band/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1c. Create `LocalRelayCategories.kt`** — CompositionLocal (matches `LocalTorState` pattern)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val LocalRelayCategories = compositionLocalOf<DesktopRelayCategories> {
|
||||||
|
error("No DesktopRelayCategories provided")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1d. Wire in Main.kt**
|
||||||
|
|
||||||
|
- Instantiate single `DesktopAccountRelays` in logged-in section
|
||||||
|
- Create `DesktopRelayCategories` combining nip65State + accountRelays + connectedRelays
|
||||||
|
- Inline bootstrap subscription in `LaunchedEffect` (~10 lines)
|
||||||
|
- Route bootstrap events through `localCache` for NIP-65 sync
|
||||||
|
- Provide `LocalRelayCategories` via `CompositionLocalProvider`
|
||||||
|
- Use `withTimeoutOrNull(30.seconds)` on `connectedRelays.first { isNotEmpty }`
|
||||||
|
- Remove hardcoded `DesktopDmRelayState(emptySet())` and per-column `DesktopAccountRelays`
|
||||||
|
|
||||||
|
### Phase 2: Screen Wiring
|
||||||
|
|
||||||
|
Each screen collects from `LocalRelayCategories.current`:
|
||||||
|
|
||||||
|
**SearchScreen.kt:** `val searchRelays by LocalRelayCategories.current.searchRelays.collectAsState()` → use in `rememberSubscription`
|
||||||
|
|
||||||
|
**FeedScreen.kt:** `val feedRelays by LocalRelayCategories.current.feedRelays.collectAsState()` → replace `allRelayUrls`
|
||||||
|
|
||||||
|
**NotificationsScreen.kt:** `val notificationRelays by LocalRelayCategories.current.notificationRelays.collectAsState()`
|
||||||
|
|
||||||
|
**DM subscriptions:** Make reactive via `debounce(500).collect { resubscribe }` with Job tracking
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `DesktopAccountRelays.kt` | Add search/blocked flows, consumeIfRelevant, created_at dedup |
|
||||||
|
| `DesktopRelayCategories.kt` | NEW: aggregator with debounce + blocked subtraction |
|
||||||
|
| `LocalRelayCategories.kt` | NEW: CompositionLocal (3 lines) |
|
||||||
|
| `Main.kt` | Wire accountRelays, relayCategories, inline bootstrap, CompositionLocalProvider |
|
||||||
|
| `DeckColumnContainer.kt` | Remove per-column DesktopAccountRelays creation |
|
||||||
|
| `FeedScreen.kt` | Use feedRelays from LocalRelayCategories |
|
||||||
|
| `SearchScreen.kt` | Use searchRelays from LocalRelayCategories |
|
||||||
|
| `NotificationsScreen.kt` | Use notificationRelays from LocalRelayCategories |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Bootstrap subscription fetches kinds 10002/10050/10007/10006 on login
|
||||||
|
- [ ] Events routed through localCache (NIP-65 stays in sync)
|
||||||
|
- [ ] SearchScreen uses search relays (falls back to relay.nostr.band)
|
||||||
|
- [ ] FeedScreen uses NIP-65 outbox relays (falls back to connected)
|
||||||
|
- [ ] Blocked relays subtracted from all category relay sets
|
||||||
|
- [ ] DM subscriptions reactive to relay changes
|
||||||
|
- [ ] No subscription thrashing at startup (debounce 1s)
|
||||||
|
- [ ] `connectedRelays.first` has 30s timeout
|
||||||
|
|
||||||
|
## Unanswered Questions
|
||||||
|
|
||||||
|
1. NIP-51 private tag decryption for kind 10007/10006 — defer to v2 or attempt now? (Public tags work for `create()`, but `updateRelayList()` moves to private)
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
title: "feat: DesktopRelayConfig — Single Source of Truth for All Relay Categories"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-04-23
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: DesktopRelayConfig — Single Source of Truth for All Relay Categories
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current relay wiring is broken. Multiple classes (`DesktopRelayCategories`, `DesktopAccountRelays`, `RelayConnectionManager`) each hold parts of relay state, leading to race conditions, empty initial values, and screens using inconsistent relay sources.
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Feed shows "0 feed relays" on startup (race: `allConfiguredRelays` empty at `DesktopRelayCategories` construction)
|
||||||
|
- NIP-65 relay changes don't propagate to feed subscriptions
|
||||||
|
- Different screens use different relay sources (`relayStatuses.keys` vs `connectedRelays` vs `feedRelays` vs `allRelayUrls`)
|
||||||
|
- No screen resubscribes when relay config changes
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
| Issue | Why |
|
||||||
|
|-------|-----|
|
||||||
|
| `feedRelays` starts empty | `stateIn(Eagerly, allConfiguredRelays.value)` captures empty snapshot at construction time |
|
||||||
|
| Screens use inconsistent sources | FeedScreen uses `feedRelays`, NotificationsScreen uses `relayStatuses.keys`, ReadsScreen uses `relayStatuses.keys` |
|
||||||
|
| No reconnection on config change | `rememberSubscription` keys don't include relay category flows |
|
||||||
|
| Coordinator `indexRelays` is static | Passed at construction, never updated |
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
**One class: `DesktopRelayConfig`** that:
|
||||||
|
1. Holds all relay category sets as reactive `StateFlow`s
|
||||||
|
2. Is initialized from `DefaultRelays.RELAYS` immediately (never empty)
|
||||||
|
3. Updates when NIP-65/DM/search/blocked events arrive
|
||||||
|
4. Persists to Preferences
|
||||||
|
5. Every screen reads from this single source
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Delete/Replace
|
||||||
|
|
||||||
|
| Remove | Replace With |
|
||||||
|
|--------|-------------|
|
||||||
|
| `DesktopRelayCategories.kt` | `DesktopRelayConfig.kt` |
|
||||||
|
| `DesktopAccountRelays.kt` | Merged into `DesktopRelayConfig` |
|
||||||
|
| `DesktopDmRelayState.kt` | Merged into `DesktopRelayConfig` |
|
||||||
|
| `LocalRelayCategories` CompositionLocal | `LocalRelayConfig` |
|
||||||
|
| `LocalAccountRelays` CompositionLocal | Removed (merged into `LocalRelayConfig`) |
|
||||||
|
|
||||||
|
### `DesktopRelayConfig.kt` — The One Class
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class DesktopRelayConfig(
|
||||||
|
val userPubKeyHex: HexKey,
|
||||||
|
private val relayManager: RelayConnectionManager,
|
||||||
|
private val nip65State: Nip65RelayListState,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val prefs = Preferences.userNodeForPackage(DesktopRelayConfig::class.java)
|
||||||
|
|
||||||
|
// === Raw category state (updated by events + persistence + UI) ===
|
||||||
|
|
||||||
|
private val _dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||||
|
private val _searchRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(DEFAULT_SEARCH_RELAYS)
|
||||||
|
private val _blockedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||||
|
|
||||||
|
// === Derived relay sets for subscriptions ===
|
||||||
|
|
||||||
|
/** Default relays — always populated from DefaultRelays.RELAYS, never empty */
|
||||||
|
private val defaultRelays: Set<NormalizedRelayUrl> =
|
||||||
|
DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||||
|
|
||||||
|
/** Feed relays: NIP-65 outbox → fallback to defaultRelays, minus blocked */
|
||||||
|
val feedRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
nip65State.outboxFlow,
|
||||||
|
_blockedRelays,
|
||||||
|
) { outbox, blocked ->
|
||||||
|
(outbox.ifEmpty { defaultRelays }) - blocked
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, defaultRelays) // NEVER empty
|
||||||
|
|
||||||
|
/** Notification relays: NIP-65 inbox → fallback to defaultRelays, minus blocked */
|
||||||
|
val notificationRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
nip65State.inboxFlow,
|
||||||
|
_blockedRelays,
|
||||||
|
) { inbox, blocked ->
|
||||||
|
(inbox.ifEmpty { defaultRelays }) - blocked
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||||
|
|
||||||
|
/** Search relays: kind 10007 → fallback to relay.nostr.band, minus blocked */
|
||||||
|
val searchRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
_searchRelays,
|
||||||
|
_blockedRelays,
|
||||||
|
) { search, blocked ->
|
||||||
|
search - blocked
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_SEARCH_RELAYS)
|
||||||
|
|
||||||
|
/** DM relays: kind 10050 → fallback to defaultRelays */
|
||||||
|
val dmRelays: StateFlow<Set<NormalizedRelayUrl>> = combine(
|
||||||
|
_dmRelays,
|
||||||
|
_blockedRelays,
|
||||||
|
) { dm, blocked ->
|
||||||
|
(dm.ifEmpty { defaultRelays }) - blocked
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, defaultRelays)
|
||||||
|
|
||||||
|
/** Blocked relays (public read-only) */
|
||||||
|
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelays.asStateFlow()
|
||||||
|
|
||||||
|
// Also expose raw lists for editors
|
||||||
|
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelays.asStateFlow()
|
||||||
|
val searchRelayList: StateFlow<Set<NormalizedRelayUrl>> = _searchRelays.asStateFlow()
|
||||||
|
val blockedRelayList: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelays.asStateFlow()
|
||||||
|
|
||||||
|
init { loadFromPersistence() }
|
||||||
|
|
||||||
|
// === Persistence (relay URLs as CSV) ===
|
||||||
|
|
||||||
|
fun setDmRelays(relays: Set<NormalizedRelayUrl>) { _dmRelays.value = relays; save("dm", relays) }
|
||||||
|
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) { _searchRelays.value = relays; save("search", relays) }
|
||||||
|
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) { _blockedRelays.value = relays; save("blocked", relays) }
|
||||||
|
|
||||||
|
// === Event consumption (from bootstrap + relay subscriptions) ===
|
||||||
|
|
||||||
|
fun consumeEvent(event: Event) { ... } // routes by kind, checks created_at
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val DEFAULT_SEARCH_RELAYS = setOfNotNull(RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design difference from current code:** `defaultRelays` is a `val` computed once from `DefaultRelays.RELAYS` — it's NEVER empty. The `stateIn` initial value is `defaultRelays`, not a snapshot of some flow that might not be populated yet.
|
||||||
|
|
||||||
|
### Screen Updates
|
||||||
|
|
||||||
|
Every screen uses `LocalRelayConfig.current` to get the right relay set:
|
||||||
|
|
||||||
|
| Screen | Current Source | New Source | Change |
|
||||||
|
|--------|---------------|------------|--------|
|
||||||
|
| **FeedScreen** (feed sub) | `feedRelays` from DesktopRelayCategories | `relayConfig.feedRelays` | Same concept, but initial value is never empty |
|
||||||
|
| **FeedScreen** (contact list) | `allRelayUrls = relayStatuses.keys` | `relayConfig.feedRelays` | Unified source |
|
||||||
|
| **FeedScreen** (metadata) | `allRelayUrls` | `relayConfig.feedRelays` | Unified |
|
||||||
|
| **FeedScreen** (interactions) | `relayStatuses.value.keys` snapshot | `relayConfig.feedRelays` | Fix snapshot issue |
|
||||||
|
| **SearchScreen** | `searchRelays` from categories | `relayConfig.searchRelays` | Same concept |
|
||||||
|
| **NotificationsScreen** | `relayStatuses.keys` | `relayConfig.notificationRelays` | Now uses NIP-65 inbox |
|
||||||
|
| **ReadsScreen** | `relayStatuses.keys` | `relayConfig.feedRelays` | Unified with feed |
|
||||||
|
| **BookmarksScreen** | `relayStatuses.keys` | `relayConfig.feedRelays` | Unified |
|
||||||
|
| **DM subscriptions** | Hardcoded empty | `relayConfig.dmRelays` | Actually works now |
|
||||||
|
|
||||||
|
### Subscription Reactivity
|
||||||
|
|
||||||
|
`rememberSubscription` already rekeys when its key params change. Each screen uses `relayConfig.feedRelays.collectAsState()` as a key — when the StateFlow emits a new set (e.g., after NIP-65 update), the subscription teardowns and recreates with the new relay set. This is already how it works; the fix is just making the initial value non-empty.
|
||||||
|
|
||||||
|
### Coordinator Fix
|
||||||
|
|
||||||
|
`DesktopRelaySubscriptionsCoordinator.indexRelays` should use `relayConfig.feedRelays.value` at construction. Or better: make it a `var` so it can be updated:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class DesktopRelaySubscriptionsCoordinator(
|
||||||
|
private val client: INostrClient,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
var indexRelays: Set<NormalizedRelayUrl>, // var, not val
|
||||||
|
private val localCache: DesktopLocalCache,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Update it when relay config changes:
|
||||||
|
```kotlin
|
||||||
|
LaunchedEffect(relayConfig.feedRelays) {
|
||||||
|
relayConfig.feedRelays.collect { relays ->
|
||||||
|
subscriptionsCoordinator.indexRelays = relays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. Create `DesktopRelayConfig.kt` merging AccountRelays + DmRelayState + RelayCategories
|
||||||
|
2. Create `LocalRelayConfig` CompositionLocal, remove `LocalRelayCategories` + `LocalAccountRelays`
|
||||||
|
3. Provide in Main.kt, remove old classes
|
||||||
|
4. Update every screen to use `LocalRelayConfig.current`
|
||||||
|
5. Make coordinator `indexRelays` mutable + reactive
|
||||||
|
6. Test: feed shows 7 relays on startup, search uses configured relays, NIP-65 changes propagate
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Feed shows relay count immediately on startup (never "0 feed relays")
|
||||||
|
- [ ] All screens use `LocalRelayConfig.current` — no direct `relayStatuses.keys` usage
|
||||||
|
- [ ] NIP-65 relay changes propagate to feed subscriptions (resubscribes)
|
||||||
|
- [ ] Search relay changes propagate to search subscriptions
|
||||||
|
- [ ] DM relay changes propagate to DM subscriptions
|
||||||
|
- [ ] Relay config persists across restarts
|
||||||
|
- [ ] Per-screen relay picker dialogs use `DesktopRelayConfig` setters
|
||||||
|
- [ ] `DesktopRelaySubscriptionsCoordinator.indexRelays` updates reactively
|
||||||
|
|
||||||
|
## Unanswered Questions
|
||||||
|
|
||||||
|
1. Should `defaultRelays` also include user's connected relays, or strictly `DefaultRelays.RELAYS`?
|
||||||
|
2. Should we delete `DesktopDmRelayState` or keep it as an internal implementation detail?
|
||||||
Reference in New Issue
Block a user