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.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||
@@ -103,10 +105,18 @@ import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -116,6 +126,8 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
@@ -225,6 +237,8 @@ fun main() {
|
||||
},
|
||||
)
|
||||
}
|
||||
// Callback set by App() for single pane navigation from MenuBar
|
||||
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
@@ -351,6 +365,26 @@ fun main() {
|
||||
},
|
||||
onClick = { showAppDrawer = !showAppDrawer },
|
||||
)
|
||||
Item(
|
||||
"Relay Dashboard",
|
||||
shortcut =
|
||||
if (isMacOS) {
|
||||
KeyShortcut(Key.R, meta = true, shift = true)
|
||||
} else {
|
||||
KeyShortcut(Key.R, ctrl = true, shift = true)
|
||||
},
|
||||
onClick = {
|
||||
if (layoutMode == LayoutMode.DECK) {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Relays)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Relays)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Relays)
|
||||
}
|
||||
} else {
|
||||
navigateToScreen?.invoke(DeckColumnType.Relays)
|
||||
}
|
||||
},
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
|
||||
@@ -467,6 +501,7 @@ fun main() {
|
||||
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
|
||||
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
|
||||
Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) })
|
||||
Item("Relays", onClick = { deckState.addColumn(DeckColumnType.Relays) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,6 +546,7 @@ fun main() {
|
||||
torTypeFlow = torTypeFlow,
|
||||
externalPortFlow = externalPortFlow,
|
||||
initialTorSettings = torSettings,
|
||||
onNavigateToScreen = { navigateToScreen = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -538,10 +574,16 @@ fun App(
|
||||
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
|
||||
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
||||
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
|
||||
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit = {},
|
||||
) {
|
||||
val singlePaneState = remember { SinglePaneState() }
|
||||
val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } }
|
||||
|
||||
// Register single pane navigation callback for MenuBar shortcuts
|
||||
LaunchedEffect(singlePaneState) {
|
||||
onNavigateToScreen { screen -> singlePaneState.navigate(screen) }
|
||||
}
|
||||
|
||||
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings
|
||||
var torSettings by remember {
|
||||
mutableStateOf(
|
||||
@@ -631,6 +673,12 @@ fun App(
|
||||
}
|
||||
|
||||
val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) }
|
||||
val nip11Fetcher = remember { Nip11Fetcher() }
|
||||
|
||||
// Start 1Hz metrics snapshot for relay dashboard
|
||||
LaunchedEffect(relayManager) {
|
||||
relayManager.startMetricsSnapshot(this)
|
||||
}
|
||||
|
||||
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
|
||||
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
|
||||
@@ -755,6 +803,7 @@ fun App(
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
@@ -850,6 +899,7 @@ fun MainContent(
|
||||
account: AccountState.LoggedIn,
|
||||
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
nip11Fetcher: Nip11Fetcher,
|
||||
appScope: CoroutineScope,
|
||||
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
@@ -866,9 +916,27 @@ fun MainContent(
|
||||
remember(relayManager) {
|
||||
DmSendTracker(relayManager.client)
|
||||
}
|
||||
// Centralized relay state for all categories (DM, search, blocked, NIP-65 persistence)
|
||||
// Created before iAccount so NIP-65 backup can be loaded
|
||||
val accountRelays =
|
||||
remember(account, relayManager, scope) {
|
||||
DesktopAccountRelays(account.pubKeyHex, relayManager, scope)
|
||||
}
|
||||
|
||||
val iAccount =
|
||||
remember(account, localCache, relayManager, dmSendTracker) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
|
||||
remember(account, localCache, relayManager, dmSendTracker, accountRelays) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays)
|
||||
}
|
||||
|
||||
// Aggregated relay categories (feed, notifications, search, DM)
|
||||
val relayCategories =
|
||||
remember(iAccount.nip65RelayList, accountRelays, relayManager) {
|
||||
DesktopRelayCategories(
|
||||
nip65State = iAccount.nip65RelayList,
|
||||
accountRelays = accountRelays,
|
||||
connectedRelays = relayManager.connectedRelays,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
val highlightStore = remember { DesktopHighlightStore(appScope) }
|
||||
@@ -878,19 +946,63 @@ fun MainContent(
|
||||
.DesktopDraftStore(appScope)
|
||||
}
|
||||
|
||||
// Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006)
|
||||
// Uses DisposableEffect to clean up subscription on account change
|
||||
DisposableEffect(accountRelays) {
|
||||
val bootstrapSubId = "bootstrap-relay-config"
|
||||
scope.launch {
|
||||
val connected =
|
||||
withTimeoutOrNull(30.seconds) {
|
||||
relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||
}
|
||||
if (connected != null) {
|
||||
val filter =
|
||||
Filter(
|
||||
kinds =
|
||||
listOf(
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
ChatMessageRelayListEvent.KIND,
|
||||
SearchRelayListEvent.KIND,
|
||||
BlockedRelayListEvent.KIND,
|
||||
),
|
||||
authors = listOf(account.pubKeyHex),
|
||||
limit = 4,
|
||||
)
|
||||
relayManager.subscribe(
|
||||
subId = bootstrapSubId,
|
||||
filters = listOf(filter),
|
||||
listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
// NIP-65 (kind 10002) must go through justConsumeMyOwnEvent
|
||||
// because localCache.consume() doesn't handle addressable events
|
||||
if (event is AdvertisedRelayListEvent) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
localCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
// Route to accountRelays for persistence + state updates
|
||||
accountRelays.consumeIfRelevant(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
onDispose { relayManager.unsubscribe(bootstrapSubId) }
|
||||
}
|
||||
|
||||
// Subscribe to incoming DMs and process into chatroomList
|
||||
LaunchedEffect(account) {
|
||||
relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||
|
||||
val dmRelayState =
|
||||
DesktopDmRelayState(
|
||||
dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()),
|
||||
connectedRelays = relayManager.connectedRelays,
|
||||
scope = scope,
|
||||
)
|
||||
subscriptionsCoordinator.subscribeToDms(
|
||||
userPubKeyHex = account.pubKeyHex,
|
||||
dmRelayState = dmRelayState,
|
||||
dmRelayState = accountRelays.dmRelays,
|
||||
onDmEvent = { event, relay ->
|
||||
// Store raw event in cache
|
||||
val note = localCache.getOrCreateNote(event.id)
|
||||
@@ -984,91 +1096,105 @@ fun MainContent(
|
||||
|
||||
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxSize().weight(1f)) {
|
||||
when (layoutMode) {
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState()
|
||||
SinglePaneLayout(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
appScope = appScope,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
onOpenAppDrawer = onShowAppDrawer,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
lastRelayEventAt = lastRelayEvent,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
LayoutMode.DECK -> {
|
||||
if (!isImmersive) {
|
||||
DeckSidebar(
|
||||
onAddColumn = onShowAppDrawer,
|
||||
onOpenSettings = {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
CompositionLocalProvider(
|
||||
LocalRelayCategories provides relayCategories,
|
||||
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxSize().weight(1f)) {
|
||||
when (layoutMode) {
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState()
|
||||
SinglePaneLayout(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
appScope = appScope,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
onOpenAppDrawer = onShowAppDrawer,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
torStatus = torStatus,
|
||||
lastRelayEventAt = lastRelayEvent,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
}
|
||||
|
||||
DeckLayout(
|
||||
deckState = deckState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LayoutMode.DECK -> {
|
||||
if (!isImmersive) {
|
||||
DeckSidebar(
|
||||
onAddColumn = onShowAppDrawer,
|
||||
onOpenSettings = {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
torStatus = torStatus,
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
}
|
||||
|
||||
DeckLayout(
|
||||
deckState = deckState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToRelays = {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Relays)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Relays)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Relays)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Row
|
||||
} // end Row
|
||||
|
||||
// Persistent media control bar
|
||||
// Persistent media control bar
|
||||
com.vitorpamplona.amethyst.desktop.ui.media
|
||||
.NowPlayingBar()
|
||||
} // end Column
|
||||
|
||||
// Global fullscreen video overlay
|
||||
com.vitorpamplona.amethyst.desktop.ui.media
|
||||
.NowPlayingBar()
|
||||
} // end Column
|
||||
.GlobalFullscreenOverlay()
|
||||
|
||||
// Global fullscreen video overlay
|
||||
com.vitorpamplona.amethyst.desktop.ui.media
|
||||
.GlobalFullscreenOverlay()
|
||||
|
||||
// Snackbar for zap feedback
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||
)
|
||||
}
|
||||
// Snackbar for zap feedback
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||
)
|
||||
}
|
||||
} // end CompositionLocalProvider
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
Vendored
+11
-1
@@ -545,7 +545,17 @@ class DesktopLocalCache : ICacheProvider {
|
||||
// ----- Own event consumption -----
|
||||
|
||||
override fun justConsumeMyOwnEvent(event: Event): Boolean {
|
||||
// Desktop doesn't track own events separately
|
||||
// For addressable/replaceable events, store in the addressable note cache
|
||||
// so state holders (Nip65RelayListState, etc.) pick it up via their flows
|
||||
if (event is com.vitorpamplona.quartz.nip01Core.core.AddressableEvent) {
|
||||
val address = event.address()
|
||||
val note = getOrCreateAddressableNote(address)
|
||||
val author = getOrCreateUser(event.pubKey) ?: return false
|
||||
if (note.event == null || (note.event?.createdAt ?: 0) <= event.createdAt) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+174
-18
@@ -24,11 +24,18 @@ import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* Manages relay state for a desktop account.
|
||||
@@ -51,10 +58,25 @@ class DesktopAccountRelays(
|
||||
relayManager: RelayConnectionManager,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
private val prefs = Preferences.userNodeForPackage(DesktopAccountRelays::class.java)
|
||||
|
||||
/** User-configured DM relays from kind 10050 events */
|
||||
private val _dmRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _dmRelayList.asStateFlow()
|
||||
|
||||
/** User-configured search relays from kind 10007 events */
|
||||
private val _searchRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
val searchRelayList: StateFlow<Set<NormalizedRelayUrl>> = _searchRelayList.asStateFlow()
|
||||
|
||||
/** User-configured blocked relays from kind 10006 events */
|
||||
private val _blockedRelayList = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
val blockedRelayList: StateFlow<Set<NormalizedRelayUrl>> = _blockedRelayList.asStateFlow()
|
||||
|
||||
// Track created_at to prevent stale overwrites (thread-safe)
|
||||
private val lastDmCreatedAt = AtomicLong(0L)
|
||||
private val lastSearchCreatedAt = AtomicLong(0L)
|
||||
private val lastBlockedCreatedAt = AtomicLong(0L)
|
||||
|
||||
/** Aggregated DM relay state (DM relays + fallback to connected relays) */
|
||||
val dmRelays =
|
||||
DesktopDmRelayState(
|
||||
@@ -63,32 +85,166 @@ class DesktopAccountRelays(
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
/**
|
||||
* Processes a ChatMessageRelayListEvent (kind 10050) to update DM relays.
|
||||
* Call this when receiving kind 10050 events from relay subscriptions.
|
||||
*/
|
||||
fun consumeDmRelayList(event: ChatMessageRelayListEvent) {
|
||||
if (event.pubKey != userPubKeyHex) return
|
||||
val relays = event.relays().toSet()
|
||||
_dmRelayList.value = relays
|
||||
init {
|
||||
loadFromPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes any event that might be a DM relay list.
|
||||
* Returns true if the event was consumed as a DM relay list.
|
||||
*/
|
||||
fun consumeIfDmRelayList(event: Event): Boolean {
|
||||
if (event.kind == ChatMessageRelayListEvent.KIND && event is ChatMessageRelayListEvent) {
|
||||
consumeDmRelayList(event)
|
||||
return true
|
||||
private fun prefsKey(kind: Int) = "relay_${kind}_${userPubKeyHex.take(16)}"
|
||||
|
||||
private fun saveEvent(
|
||||
kind: Int,
|
||||
event: Event,
|
||||
) {
|
||||
try {
|
||||
val json = event.toJson()
|
||||
if (json.length > MAX_PREFS_VALUE_LENGTH) return // Preferences 8KB limit
|
||||
prefs.put(prefsKey(kind), json)
|
||||
} catch (_: Exception) {
|
||||
// Best-effort persistence
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_PREFS_VALUE_LENGTH = 8000 // java.util.prefs limit is 8192
|
||||
}
|
||||
|
||||
private fun loadEvent(kind: Int): Event? =
|
||||
try {
|
||||
val json = prefs.get(prefsKey(kind), null) ?: return null
|
||||
val event = Event.fromJson(json)
|
||||
if (event.kind == kind && event.pubKey == userPubKeyHex) event else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun loadFromPersistence() {
|
||||
// Load DM relays from event or URL cache
|
||||
val dmEvent = loadEvent(ChatMessageRelayListEvent.KIND)
|
||||
if (dmEvent is ChatMessageRelayListEvent) {
|
||||
_dmRelayList.value = dmEvent.relays().toSet()
|
||||
lastDmCreatedAt.set(dmEvent.createdAt)
|
||||
} else {
|
||||
loadRelayUrls("dm").let { if (it.isNotEmpty()) _dmRelayList.value = it }
|
||||
}
|
||||
|
||||
// Load search relays from event or URL cache
|
||||
val searchEvent = loadEvent(SearchRelayListEvent.KIND)
|
||||
if (searchEvent is SearchRelayListEvent) {
|
||||
val relays = searchEvent.publicRelays().toSet()
|
||||
if (relays.isNotEmpty()) {
|
||||
_searchRelayList.value = relays
|
||||
lastSearchCreatedAt.set(searchEvent.createdAt)
|
||||
} else {
|
||||
// Public tags empty — try URL cache (NIP-51 private tags can't be decrypted here)
|
||||
loadRelayUrls("search").let { if (it.isNotEmpty()) _searchRelayList.value = it }
|
||||
}
|
||||
} else {
|
||||
loadRelayUrls("search").let { if (it.isNotEmpty()) _searchRelayList.value = it }
|
||||
}
|
||||
|
||||
// Load blocked relays — always use URL cache (private tags can't be decrypted synchronously)
|
||||
loadRelayUrls("blocked").let { if (it.isNotEmpty()) _blockedRelayList.value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually sets DM relays (e.g., from saved preferences).
|
||||
* Routes relay config events (kinds 10050, 10007, 10006) to the appropriate handler.
|
||||
* Returns true if the event was consumed.
|
||||
* Uses created_at checking to prevent stale overwrites.
|
||||
*/
|
||||
fun consumeIfRelevant(event: Event): Boolean {
|
||||
if (event.pubKey != userPubKeyHex) return false
|
||||
return when (event.kind) {
|
||||
AdvertisedRelayListEvent.KIND -> {
|
||||
// Persist NIP-65 event for restart survival (state managed by Nip65RelayListState)
|
||||
saveEvent(event.kind, event)
|
||||
true
|
||||
}
|
||||
|
||||
ChatMessageRelayListEvent.KIND -> {
|
||||
if (event is ChatMessageRelayListEvent && event.createdAt >= lastDmCreatedAt.get()) {
|
||||
lastDmCreatedAt.set(event.createdAt)
|
||||
val relays = event.relays().toSet()
|
||||
_dmRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
saveRelayUrls("dm", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
SearchRelayListEvent.KIND -> {
|
||||
if (event is SearchRelayListEvent && event.createdAt >= lastSearchCreatedAt.get()) {
|
||||
lastSearchCreatedAt.set(event.createdAt)
|
||||
val relays = event.publicRelays().toSet()
|
||||
_searchRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
saveRelayUrls("search", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
BlockedRelayListEvent.KIND -> {
|
||||
if (event is BlockedRelayListEvent && event.createdAt >= lastBlockedCreatedAt.get()) {
|
||||
lastBlockedCreatedAt.set(event.createdAt)
|
||||
// publicRelays() may be empty for NIP-51 private-tag events
|
||||
val relays = event.publicRelays().toSet()
|
||||
_blockedRelayList.value = relays
|
||||
saveEvent(event.kind, event)
|
||||
if (relays.isNotEmpty()) saveRelayUrls("blocked", relays)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called after publishing a relay list event from the UI — updates local state + persists */
|
||||
fun consumePublishedEvent(event: Event) {
|
||||
consumeIfRelevant(event)
|
||||
}
|
||||
|
||||
/** Load persisted NIP-65 event for Nip65RelayListState backup */
|
||||
fun loadPersistedNip65Event(): AdvertisedRelayListEvent? {
|
||||
val event = loadEvent(AdvertisedRelayListEvent.KIND)
|
||||
return event as? AdvertisedRelayListEvent
|
||||
}
|
||||
|
||||
fun setDmRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastDmCreatedAt.set(TimeUtils.now())
|
||||
_dmRelayList.value = relays
|
||||
saveRelayUrls("dm", relays)
|
||||
}
|
||||
|
||||
fun setSearchRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastSearchCreatedAt.set(TimeUtils.now())
|
||||
_searchRelayList.value = relays
|
||||
saveRelayUrls("search", relays)
|
||||
}
|
||||
|
||||
fun setBlockedRelays(relays: Set<NormalizedRelayUrl>) {
|
||||
lastBlockedCreatedAt.set(TimeUtils.now())
|
||||
_blockedRelayList.value = relays
|
||||
saveRelayUrls("blocked", relays)
|
||||
}
|
||||
|
||||
private fun saveRelayUrls(
|
||||
category: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
try {
|
||||
val key = "urls_${category}_${userPubKeyHex.take(16)}"
|
||||
prefs.put(key, relays.joinToString(",") { it.url })
|
||||
prefs.flush()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRelayUrls(category: String): Set<NormalizedRelayUrl> {
|
||||
val key = "urls_${category}_${userPubKeyHex.take(16)}"
|
||||
val csv = prefs.get(key, "") ?: return emptySet()
|
||||
if (csv.isBlank()) return emptySet()
|
||||
return csv.split(",").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -70,6 +70,7 @@ class DesktopIAccount(
|
||||
private val relayManager: RelayConnectionManager,
|
||||
val dmSendTracker: DmSendTracker,
|
||||
private val scope: CoroutineScope,
|
||||
private val accountRelays: DesktopAccountRelays? = null,
|
||||
) : IAccount {
|
||||
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
||||
|
||||
@@ -98,9 +99,12 @@ class DesktopIAccount(
|
||||
localCache,
|
||||
scope,
|
||||
object : Nip65RelayListRepository {
|
||||
override val backupNIP65RelayList: AdvertisedRelayListEvent? = null
|
||||
override val backupNIP65RelayList: AdvertisedRelayListEvent? =
|
||||
accountRelays?.loadPersistedNip65Event()
|
||||
|
||||
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) { /* no persistence yet */ }
|
||||
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) {
|
||||
accountRelays?.consumePublishedEvent(event)
|
||||
}
|
||||
|
||||
override val defaultOutboxRelays = relayManager.connectedRelays.value
|
||||
override val defaultInboxRelays = relayManager.connectedRelays.value
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+49
-8
@@ -25,15 +25,27 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class RelayMetrics(
|
||||
val eventCount: Long = 0,
|
||||
val lastEventAt: Long? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Manages Nostr relay connections, subscriptions, and status tracking.
|
||||
@@ -55,10 +67,30 @@ open class RelayConnectionManager(
|
||||
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.connectedRelaysFlow()
|
||||
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.availableRelaysFlow()
|
||||
|
||||
// Relays explicitly removed by user — suppress status updates from pool callbacks
|
||||
private val removedRelays = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
|
||||
|
||||
// Hot metrics — written on every event, no StateFlow emission
|
||||
private val rawMetricsMap = ConcurrentHashMap<NormalizedRelayUrl, RelayMetrics>()
|
||||
|
||||
// Throttled snapshot — 1Hz emission for UI
|
||||
private val _relayMetrics = MutableStateFlow<Map<NormalizedRelayUrl, RelayMetrics>>(emptyMap())
|
||||
val relayMetrics: StateFlow<Map<NormalizedRelayUrl, RelayMetrics>> = _relayMetrics.asStateFlow()
|
||||
|
||||
init {
|
||||
_client.addConnectionListener(this)
|
||||
}
|
||||
|
||||
/** Start 1Hz metrics snapshot. Call once after connect(). */
|
||||
fun startMetricsSnapshot(scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(1_000)
|
||||
_relayMetrics.value = HashMap(rawMetricsMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
_client.connect()
|
||||
}
|
||||
@@ -69,12 +101,14 @@ open class RelayConnectionManager(
|
||||
|
||||
fun addRelay(url: String): NormalizedRelayUrl? {
|
||||
val normalized = RelayUrlNormalizer.Companion.normalizeOrNull(url) ?: return null
|
||||
removedRelays.remove(normalized)
|
||||
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun removeRelay(url: NormalizedRelayUrl) {
|
||||
_relayStatuses.value = _relayStatuses.value - url
|
||||
removedRelays.add(url)
|
||||
_relayStatuses.update { it - url }
|
||||
}
|
||||
|
||||
fun addDefaultRelays() {
|
||||
@@ -167,13 +201,13 @@ open class RelayConnectionManager(
|
||||
|
||||
private fun updateRelayStatus(
|
||||
url: NormalizedRelayUrl,
|
||||
update: (RelayStatus) -> RelayStatus,
|
||||
transform: (RelayStatus) -> RelayStatus,
|
||||
) {
|
||||
_relayStatuses.value =
|
||||
_relayStatuses.value.toMutableMap().apply {
|
||||
val current = this[url] ?: RelayStatus(url, connected = false)
|
||||
this[url] = update(current)
|
||||
}
|
||||
if (url in removedRelays) return
|
||||
_relayStatuses.update { current ->
|
||||
val existing = current[url] ?: RelayStatus(url, connected = false)
|
||||
current + (url to transform(existing))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
@@ -212,7 +246,14 @@ open class RelayConnectionManager(
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
// Events are handled by subscription listeners
|
||||
// Only count EVENT messages, not EOSE/OK/NOTICE/AUTH
|
||||
if (msg !is EventMessage) return
|
||||
rawMetricsMap.compute(relay.url) { _, m ->
|
||||
RelayMetrics(
|
||||
eventCount = (m?.eventCount ?: 0) + 1,
|
||||
lastEventAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSent(
|
||||
|
||||
+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.DesktopUploadTracker
|
||||
import com.vitorpamplona.amethyst.desktop.service.upload.UploadResult
|
||||
import com.vitorpamplona.amethyst.desktop.ui.compose.ComposeRelayPicker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.compose.RelayPickerState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
@@ -104,6 +107,15 @@ fun ComposeNoteDialog(
|
||||
var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) }
|
||||
var postAsPicture by remember { mutableStateOf(false) }
|
||||
|
||||
// Relay picker state
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val allRelays by relayManager.availableRelays.collectAsState()
|
||||
val pickerState =
|
||||
remember(allRelays, connectedRelays) {
|
||||
RelayPickerState(allRelays = allRelays, connectedRelays = connectedRelays)
|
||||
}
|
||||
var selectedRelays by remember(connectedRelays) { mutableStateOf(connectedRelays) }
|
||||
|
||||
// Drag-and-drop state
|
||||
var isDragOver by remember { mutableStateOf(false) }
|
||||
val dropTarget =
|
||||
@@ -255,7 +267,22 @@ fun ComposeNoteDialog(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
ComposeRelayPicker(
|
||||
pickerState = pickerState,
|
||||
selectedRelays = selectedRelays,
|
||||
onToggleRelay = { url ->
|
||||
selectedRelays =
|
||||
if (url in selectedRelays) {
|
||||
selectedRelays - url
|
||||
} else {
|
||||
selectedRelays + url
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -316,6 +343,7 @@ fun ComposeNoteDialog(
|
||||
images = pictureMetas,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
relays = selectedRelays,
|
||||
)
|
||||
} else {
|
||||
val imetaTags = buildIMetaTags(uploadResults)
|
||||
@@ -325,6 +353,7 @@ fun ComposeNoteDialog(
|
||||
relayManager = relayManager,
|
||||
replyTo = replyTo,
|
||||
imetaTags = imetaTags,
|
||||
relays = selectedRelays,
|
||||
)
|
||||
}
|
||||
onDismiss()
|
||||
@@ -470,6 +499,7 @@ private suspend fun publishPicture(
|
||||
images: List<com.vitorpamplona.quartz.nip68Picture.PictureMeta>,
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (account.isReadOnly) {
|
||||
@@ -485,7 +515,7 @@ private suspend fun publishPicture(
|
||||
}
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
relayManager.publish(signedEvent, relays)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +525,7 @@ private suspend fun publishNote(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
replyTo: Event?,
|
||||
imetaTags: List<IMetaTag> = emptyList(),
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (account.isReadOnly) {
|
||||
@@ -518,6 +549,6 @@ private suspend fun publishNote(
|
||||
}
|
||||
|
||||
val signedEvent = account.signer.sign(template)
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
relayManager.publish(signedEvent, relays)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -37,13 +38,16 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -80,6 +84,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
@@ -87,6 +93,10 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LightboxState(
|
||||
val urls: List<String>,
|
||||
@@ -260,6 +270,7 @@ fun FeedScreen(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn? = null,
|
||||
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
|
||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
initialFeedMode: FeedMode? = null,
|
||||
@@ -267,6 +278,7 @@ fun FeedScreen(
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
) {
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
@@ -275,8 +287,13 @@ fun FeedScreen(
|
||||
// Available relay URLs — subscribe triggers connection on-demand
|
||||
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
|
||||
|
||||
// Feed relays from relay categories (NIP-65 outbox, minus blocked, with fallback)
|
||||
val relayCategories = LocalRelayCategories.current
|
||||
val feedRelays by relayCategories.feedRelays.collectAsState()
|
||||
|
||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
var showRelayPicker by remember { mutableStateOf(false) }
|
||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
||||
|
||||
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
||||
@@ -295,13 +312,13 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to feed events (kind 1) — populates cache via coordinator
|
||||
rememberSubscription(allRelayUrls, feedMode, followedUsers, relayManager = relayManager) {
|
||||
if (allRelayUrls.isEmpty()) return@rememberSubscription null
|
||||
rememberSubscription(feedRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||
if (feedRelays.isEmpty()) return@rememberSubscription null
|
||||
|
||||
when (feedMode) {
|
||||
FeedMode.GLOBAL -> {
|
||||
createGlobalFeedSubscription(
|
||||
relays = allRelayUrls,
|
||||
relays = feedRelays,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
},
|
||||
@@ -312,7 +329,7 @@ fun FeedScreen(
|
||||
val follows = followedUsers.toList()
|
||||
if (follows.isNotEmpty()) {
|
||||
createFollowingFeedSubscription(
|
||||
relays = allRelayUrls,
|
||||
relays = feedRelays,
|
||||
followedUsers = follows,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
@@ -481,7 +498,7 @@ fun FeedScreen(
|
||||
FeedHeader(
|
||||
feedMode = feedMode,
|
||||
account = account,
|
||||
connectedRelays = connectedRelays,
|
||||
feedRelays = feedRelays,
|
||||
followedUsersCount = followedUsers.size,
|
||||
onFeedModeChange = { mode ->
|
||||
feedMode = mode
|
||||
@@ -489,6 +506,8 @@ fun FeedScreen(
|
||||
},
|
||||
onRefresh = { relayManager.connect() },
|
||||
onCompose = onCompose,
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
onOpenRelayPicker = { showRelayPicker = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -571,6 +590,33 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Feed relay picker dialog
|
||||
if (showRelayPicker && account != null && iAccount != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRelayPicker = false },
|
||||
title = { Text("Feed Relays (NIP-65)") },
|
||||
text = {
|
||||
Nip65RelayEditor(
|
||||
nip65State = iAccount.nip65RelayList,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
// Update local NIP-65 state immediately via addressable note cache
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
localCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showRelayPicker = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Lightbox overlay
|
||||
lightboxState?.let { state ->
|
||||
LightboxOverlay(
|
||||
@@ -592,11 +638,13 @@ fun FeedScreen(
|
||||
private fun FeedHeader(
|
||||
feedMode: FeedMode,
|
||||
account: AccountState.LoggedIn?,
|
||||
connectedRelays: Set<Any>,
|
||||
feedRelays: Set<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>,
|
||||
followedUsersCount: Int,
|
||||
onFeedModeChange: (FeedMode) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
onOpenRelayPicker: () -> Unit = {},
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
@@ -633,9 +681,11 @@ private fun FeedHeader(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
"${feedRelays.size} relays",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
Modifier.clickable { onNavigateToRelays() },
|
||||
)
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
Text(
|
||||
@@ -645,6 +695,20 @@ private fun FeedHeader(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (account != null && !account.isReadOnly) {
|
||||
IconButton(
|
||||
onClick = onOpenRelayPicker,
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "Edit Feed Relays",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp),
|
||||
|
||||
+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.Delete
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -64,6 +66,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -91,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchResult
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
|
||||
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
@@ -100,6 +104,9 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscripti
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.SearchRelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||
@@ -111,6 +118,7 @@ fun SearchScreen(
|
||||
localCache: DesktopLocalCache,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
account: AccountState.LoggedIn? = null,
|
||||
initialQuery: String = "",
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
@@ -118,8 +126,10 @@ fun SearchScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val accountRelays = LocalAccountRelays.current
|
||||
val state = remember { AdvancedSearchBarState(scope) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var showRelayPicker by remember { mutableStateOf(false) }
|
||||
|
||||
// Pre-fill initial query
|
||||
LaunchedEffect(initialQuery) {
|
||||
@@ -131,6 +141,8 @@ fun SearchScreen(
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
|
||||
val relayCategories = LocalRelayCategories.current
|
||||
val searchRelays by relayCategories.searchRelays.collectAsState()
|
||||
val displayText by state.displayText.collectAsState()
|
||||
// Track TextFieldValue locally to preserve cursor position
|
||||
var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) }
|
||||
@@ -160,7 +172,7 @@ fun SearchScreen(
|
||||
LaunchedEffect(debouncedQuery) {
|
||||
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
||||
state.clearResults()
|
||||
state.initRelayStates(allRelayUrls)
|
||||
state.initRelayStates(searchRelays)
|
||||
if (shouldSearchPeople) {
|
||||
state.startSearching("people-search")
|
||||
}
|
||||
@@ -171,9 +183,9 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-50 people search subscription (use allRelayUrls — subscribe will connect)
|
||||
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
||||
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
|
||||
// NIP-50 people search subscription (use searchRelays from relay categories)
|
||||
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
|
||||
if (searchRelays.isEmpty() || debouncedQuery.isEmpty) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
||||
@@ -183,7 +195,7 @@ fun SearchScreen(
|
||||
}
|
||||
|
||||
createSearchPeopleSubscription(
|
||||
relays = allRelayUrls,
|
||||
relays = searchRelays,
|
||||
searchQuery =
|
||||
debouncedQuery.text.ifBlank {
|
||||
QuerySerializer.serialize(debouncedQuery)
|
||||
@@ -212,9 +224,9 @@ fun SearchScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-50 advanced note search subscription (use allRelayUrls)
|
||||
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
||||
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
|
||||
// NIP-50 advanced note search subscription (use searchRelays from relay categories)
|
||||
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
|
||||
if (searchRelays.isEmpty() || debouncedQuery.isEmpty) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
||||
@@ -225,7 +237,7 @@ fun SearchScreen(
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("adv-search"),
|
||||
filters = filters,
|
||||
relays = allRelayUrls,
|
||||
relays = searchRelays,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
|
||||
if (state.trackRelayEvent(relay.url, event.id)) {
|
||||
@@ -382,6 +394,15 @@ fun SearchScreen(
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
if (account != null && !account.isReadOnly) {
|
||||
IconButton(onClick = { showRelayPicker = true }) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "Search Relays",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { state.togglePanel() }) {
|
||||
Icon(
|
||||
Icons.Default.Tune,
|
||||
@@ -396,6 +417,36 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Search relay picker dialog
|
||||
if (showRelayPicker && account != null) {
|
||||
val pickerRelays =
|
||||
remember {
|
||||
mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
|
||||
it.addAll(searchRelays)
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRelayPicker = false },
|
||||
title = { Text("Search Relays") },
|
||||
text = {
|
||||
SearchRelayEditor(
|
||||
localRelays = pickerRelays,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
accountRelays?.consumePublishedEvent(event)
|
||||
accountRelays?.setSearchRelays(pickerRelays.toSet())
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showRelayPicker = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Expandable advanced panel
|
||||
AnimatedVisibility(
|
||||
visible = panelExpanded,
|
||||
|
||||
+13
@@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -91,6 +92,7 @@ fun ConversationListPane(
|
||||
selectedRoom: ChatroomKey?,
|
||||
onConversationSelected: (ChatroomKey) -> Unit,
|
||||
onNewConversation: () -> Unit = {},
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -179,6 +181,17 @@ fun ConversationListPane(
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = onShowRelayPicker,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Dns,
|
||||
contentDescription = "DM Relays",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onNewConversation,
|
||||
modifier = Modifier.size(32.dp),
|
||||
|
||||
+43
@@ -85,6 +85,8 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||
var showDmRelayPicker by remember { mutableStateOf(false) }
|
||||
val listState =
|
||||
remember(account) {
|
||||
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
||||
@@ -110,6 +112,11 @@ fun DesktopMessagesScreen(
|
||||
true
|
||||
}
|
||||
|
||||
event.key == Key.R && isModifier && event.isShiftPressed -> {
|
||||
showDmRelayPicker = true
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
@@ -126,6 +133,7 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
@@ -138,6 +146,7 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
onShowRelayPicker = { showDmRelayPicker = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
}
|
||||
@@ -154,6 +163,36 @@ fun DesktopMessagesScreen(
|
||||
onDismiss = { showNewDmDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showDmRelayPicker && accountRelays != null) {
|
||||
val pickerRelays =
|
||||
remember {
|
||||
androidx.compose.runtime.mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
|
||||
it.addAll(accountRelays.dmRelayList.value.sortedBy { r -> r.url })
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showDmRelayPicker = false },
|
||||
title = { androidx.compose.material3.Text("DM Relays") },
|
||||
text = {
|
||||
com.vitorpamplona.amethyst.desktop.ui.relay.DmRelayEditor(
|
||||
dmRelays = accountRelays.dmRelayList,
|
||||
signer = account.signer,
|
||||
onPublish = { event ->
|
||||
relayManager.broadcastToAll(event)
|
||||
accountRelays.consumePublishedEvent(event)
|
||||
accountRelays.setDmRelays(pickerRelays.toSet())
|
||||
},
|
||||
onDmRelaysUpdated = { accountRelays.setDmRelays(it) },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showDmRelayPicker = false }) {
|
||||
androidx.compose.material3.Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +209,7 @@ private fun CompactMessagesContent(
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
onShowNewDm: () -> Unit,
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
@@ -208,6 +248,7 @@ private fun CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
onShowRelayPicker = onShowRelayPicker,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -229,6 +270,7 @@ private fun SplitMessagesContent(
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
onShowNewDm: () -> Unit,
|
||||
onShowRelayPicker: () -> Unit = {},
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
@@ -237,6 +279,7 @@ private fun SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
onShowRelayPicker = onShowRelayPicker,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.width(280.dp),
|
||||
)
|
||||
|
||||
+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.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Explore
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
@@ -114,6 +115,7 @@ enum class ScreenCategory(
|
||||
DISCOVERY("Discovery", Icons.Default.Explore),
|
||||
IDENTITY("Identity", Icons.Default.Person),
|
||||
PLAY("Play", Icons.Default.SportsEsports),
|
||||
NETWORK("Network", Icons.Default.Dns),
|
||||
}
|
||||
|
||||
// -- Extensions on DeckColumnType --
|
||||
@@ -143,6 +145,8 @@ fun DeckColumnType.category(): ScreenCategory =
|
||||
|
||||
DeckColumnType.Chess -> ScreenCategory.PLAY
|
||||
|
||||
DeckColumnType.Relays -> ScreenCategory.NETWORK
|
||||
|
||||
// Deep-link types — not in LAUNCHABLE_SCREENS but need a category for exhaustiveness
|
||||
is DeckColumnType.Profile,
|
||||
is DeckColumnType.Thread,
|
||||
@@ -181,6 +185,7 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
|
||||
DeckColumnType.Bookmarks,
|
||||
DeckColumnType.MyProfile,
|
||||
DeckColumnType.Settings,
|
||||
DeckColumnType.Relays,
|
||||
DeckColumnType.Chess,
|
||||
)
|
||||
|
||||
|
||||
+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.Article
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
@@ -132,6 +133,7 @@ fun DeckColumnType.icon(): ImageVector =
|
||||
DeckColumnType.MyProfile -> Icons.Default.Person
|
||||
DeckColumnType.Chess -> Icons.Default.Extension
|
||||
DeckColumnType.Settings -> Icons.Default.Settings
|
||||
DeckColumnType.Relays -> Icons.Default.Dns
|
||||
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
|
||||
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
|
||||
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
|
||||
|
||||
+26
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -99,10 +101,12 @@ fun DeckColumnContainer(
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
highlightStore: DesktopHighlightStore,
|
||||
draftStore: DesktopDraftStore,
|
||||
nip11Fetcher: Nip11Fetcher,
|
||||
appScope: CoroutineScope,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val navState = remember(column.id) { ColumnNavigationState() }
|
||||
@@ -141,6 +145,7 @@ fun DeckColumnContainer(
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
appScope = appScope,
|
||||
compactMode = true,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
@@ -150,6 +155,7 @@ fun DeckColumnContainer(
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
)
|
||||
if (currentOverlay != null) {
|
||||
Surface(
|
||||
@@ -191,6 +197,7 @@ internal fun RootContent(
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
highlightStore: DesktopHighlightStore? = null,
|
||||
draftStore: DesktopDraftStore? = null,
|
||||
nip11Fetcher: Nip11Fetcher,
|
||||
appScope: CoroutineScope,
|
||||
compactMode: Boolean = false,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
@@ -200,6 +207,7 @@ internal fun RootContent(
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
onNavigateToArticle: (String) -> Unit = {},
|
||||
onNavigateToEditor: (String?) -> Unit = {},
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -209,6 +217,7 @@ internal fun RootContent(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
initialFeedMode = FeedMode.FOLLOWING,
|
||||
@@ -216,6 +225,7 @@ internal fun RootContent(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,6 +249,7 @@ internal fun RootContent(
|
||||
localCache = localCache,
|
||||
relayManager = relayManager,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
account = account,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
)
|
||||
@@ -275,6 +286,7 @@ internal fun RootContent(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
initialFeedMode = FeedMode.GLOBAL,
|
||||
@@ -282,6 +294,7 @@ internal fun RootContent(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,6 +336,18 @@ internal fun RootContent(
|
||||
)
|
||||
}
|
||||
|
||||
DeckColumnType.Relays -> {
|
||||
val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current
|
||||
RelayDashboardScreen(
|
||||
relayManager = relayManager,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
nip65State = iAccount.nip65RelayList,
|
||||
accountRelays = accountRelays ?: return,
|
||||
signer = iAccount.signer,
|
||||
onPublish = { event -> relayManager.broadcastToAll(event) },
|
||||
)
|
||||
}
|
||||
|
||||
is DeckColumnType.Profile -> {
|
||||
UserProfileScreen(
|
||||
pubKeyHex = columnType.pubKeyHex,
|
||||
@@ -398,6 +423,7 @@ internal fun RootContent(
|
||||
localCache = localCache,
|
||||
relayManager = relayManager,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
account = account,
|
||||
initialQuery = "#${columnType.tag}",
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
|
||||
+4
@@ -43,6 +43,8 @@ sealed class DeckColumnType {
|
||||
|
||||
object Settings : DeckColumnType()
|
||||
|
||||
object Relays : DeckColumnType()
|
||||
|
||||
data class Profile(
|
||||
val pubKeyHex: String,
|
||||
) : DeckColumnType()
|
||||
@@ -79,6 +81,7 @@ sealed class DeckColumnType {
|
||||
MyProfile -> "Profile"
|
||||
Chess -> "Chess"
|
||||
Settings -> "Settings"
|
||||
Relays -> "Relays"
|
||||
is Article -> "Article"
|
||||
is Editor -> "New Article"
|
||||
Drafts -> "Drafts"
|
||||
@@ -100,6 +103,7 @@ sealed class DeckColumnType {
|
||||
MyProfile -> "my_profile"
|
||||
Chess -> "chess"
|
||||
Settings -> "settings"
|
||||
Relays -> "relays"
|
||||
is Article -> "article"
|
||||
is Editor -> "editor"
|
||||
Drafts -> "drafts"
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
@@ -64,10 +65,12 @@ fun DeckLayout(
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
highlightStore: DesktopHighlightStore,
|
||||
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
||||
nip11Fetcher: Nip11Fetcher,
|
||||
appScope: CoroutineScope,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val columns by deckState.columns.collectAsState()
|
||||
@@ -118,10 +121,12 @@ fun DeckLayout(
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToRelays = onNavigateToRelays,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +298,7 @@ class DeckState(
|
||||
"my_profile" -> DeckColumnType.MyProfile
|
||||
"chess" -> DeckColumnType.Chess
|
||||
"settings" -> DeckColumnType.Settings
|
||||
"relays" -> DeckColumnType.Relays
|
||||
"drafts" -> DeckColumnType.Drafts
|
||||
"highlights" -> DeckColumnType.MyHighlights
|
||||
"editor" -> DeckColumnType.Editor(param)
|
||||
|
||||
+1
@@ -102,6 +102,7 @@ class PinnedNavBarState(
|
||||
DeckColumnType.Notifications,
|
||||
DeckColumnType.MyProfile,
|
||||
DeckColumnType.Chess,
|
||||
DeckColumnType.Relays,
|
||||
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.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
@@ -73,6 +74,7 @@ fun SinglePaneLayout(
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
highlightStore: DesktopHighlightStore,
|
||||
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
||||
nip11Fetcher: Nip11Fetcher,
|
||||
appScope: CoroutineScope,
|
||||
singlePaneState: SinglePaneState,
|
||||
pinnedNavBarState: PinnedNavBarState,
|
||||
@@ -190,6 +192,7 @@ fun SinglePaneLayout(
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
highlightStore = highlightStore,
|
||||
draftStore = draftStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
@@ -198,6 +201,7 @@ fun SinglePaneLayout(
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
||||
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
|
||||
)
|
||||
if (currentOverlay != null) {
|
||||
Surface(
|
||||
|
||||
+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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user