Merge pull request #3922 from nrobi144/feat/desktop-settings-search-accordion

feat(desktop): searchable settings accordion
This commit is contained in:
Vitor Pamplona
2026-08-14 09:54:21 -04:00
committed by GitHub
13 changed files with 1153 additions and 378 deletions
@@ -0,0 +1,50 @@
# Manual Testing — Desktop Settings Searchable Accordion
Feature branch: `feat/desktop-settings-search-accordion`
Run: `./gradlew :desktopApp:run` → open **Settings** (sidebar, File → Settings, or Cmd/Ctrl+,).
Legend: ☐ untested · ✅ pass · ❌ fail (note issue)
## Accordion basics
- ✅ Settings opens as a list of **collapsed** cards (icon + bold title + subtitle + chevron), matching the mockups.
- ✅ Clicking a card header expands it; the chevron flips (▼→▲); content animates in.
- ✅ Multiple cards can be open at once.
- ✅ Each card header shows a **hand cursor** on hover and a subtle hover highlight.
- ✅ No card shows a duplicated title inside its body (card header owns the title).
- ✅ Leaving Settings and returning **resets** all cards to collapsed.
## Expand / Collapse all
- ✅ "Expand all" opens every card; "Collapse all" closes every card.
## Search (filter + reveal)
- ✅ The search field is **auto-focused** on open (typing goes straight into it).
- ✅ Typing filters cards case-insensitively by title (e.g. `wallet` → Wallet Connect).
- ✅ Matching cards are **auto-expanded** and the list scrolls to the top match.
- ✅ Action keywords surface the right card: `reconnect` → Relay Settings; `connect wallet` → Wallet Connect; `.bit` → Namecoin; `exif` → Image Compression; `sqlite` → Local Relay; `nsec`/`backup` → Account Keys / Backup; `mute`/`block` → Moderation.
- ✅ A no-match query (e.g. `zzzzz`) shows the "No settings match …" placeholder and no cards.
- ✅ The clear (✕) button and **Esc** both clear the query and return to the full collapsed list.
- ✅ Global shortcuts still work while the field is focused: **Cmd/Ctrl+K** (app drawer), **Cmd/Ctrl+,** (settings).
## Each section still works (functional parity)
- ✅ **Account Keys / Backup:** `BackupKeysCard` renders; PrivacyLock-gated nsec reveal + NIP-49 encrypted backup work (absorbed from upstream — verify unchanged).
- ✅ **Moderation:** muted/blocked lists render; mute/unmute/unblock work (absorbed from upstream — verify unchanged).
- ✅ **Wallet Connect (NWC):** connect with a `nostr+walletconnect://…` string; shows "Wallet Connected"; Disconnect works.
- ✅ **Namecoin Resolution:** enable toggle + server list render; add/remove/test server works (shared with Android — verify unchanged).
- ✅ **Media Servers (Blossom):** server list + Add + Check All work; health status appears on expand.
- ✅ **Image Compression:** quality presets + EXIF toggle work.
- ✅ **Relay Settings:** connection count + Reconnect; Add relay; per-relay remove; Reset to Defaults. Relay list scrolls with the page (no nested-scroll crash).
- ✅ **Local Relay:** stats/storage/export sub-sections render and function.
- ✅ **Tor:** status indicator + Advanced… dialog + mode selector work.
- ✅ **Privacy Lock:** lock/inactivity/redaction cards work.
- ✅ **Content Filters:** hashtag-spam switch + threshold slider work.
- ✅ **Developer Settings:** present only in debug builds.
- ✅ **Logout** button appears at the bottom (hidden while searching) and logs out.
## Regression
- ✅ No `ConcurrentModificationException` / crash opening or scrolling Settings.
- ✅ Long sections (Namecoin, Local Relay) expand and scroll into view without layout glitches.
## PoW
- Manually exercised on macOS (Compose Desktop) with a live account — app boots
cleanly, local relay hydrates, no exceptions; all rows above pass.
- Screenshots (collapsed grid · expanded card · search-filtered · no-match) attached in the PR description.
@@ -35,11 +35,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -48,6 +45,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -56,15 +54,25 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.KeyShortcut
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.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.MenuBar
import androidx.compose.ui.window.Window
@@ -144,10 +152,14 @@ import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationRead
import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationSettings
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.ImageCompressionSettings
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
import com.vitorpamplona.amethyst.desktop.ui.settings.RelaySettingsSection
import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsAccordionCard
import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsEntry
import com.vitorpamplona.amethyst.desktop.ui.settings.SettingsMeta
import com.vitorpamplona.amethyst.desktop.ui.settings.WalletConnectSettingsSection
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -165,6 +177,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -2391,7 +2404,7 @@ fun ProfileScreen(
}
@Composable
fun RelaySettingsScreen(
fun SettingsScreen(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
accountManager: AccountManager,
@@ -2403,14 +2416,30 @@ fun RelaySettingsScreen(
namecoinPreferences: DesktopNamecoinPreferences? = null,
onBlossomServersChanged: (List<String>) -> Unit = {},
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
val nwcConnection by accountManager.nwcConnection.collectAsState()
var newRelayUrl by remember { mutableStateOf("") }
var nwcInput by remember { mutableStateOf("") }
var nwcError by remember { mutableStateOf<String?>(null) }
val entries =
settingsEntries(
relayManager = relayManager,
account = account,
accountManager = accountManager,
torStatus = torStatus,
torSettings = torSettings,
onTorSettingsChanged = onTorSettingsChanged,
namecoinPreferences = namecoinPreferences,
onBlossomServersChanged = onBlossomServersChanged,
)
// Absent id = collapsed. Not rememberSaveable: expand state resets each visit.
val expandedIds = remember { mutableStateMapOf<String, Boolean>() }
val logoutScope = rememberCoroutineScope()
val nwcScope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val listState = rememberLazyListState()
// Tiny list; a plain filter is cheaper than derivedStateOf here.
val visible = if (query.isBlank()) entries else entries.filter { it.meta.matches(query) }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
// A non-blank query reveals matches at the top of the list.
LaunchedEffect(query) { if (query.isNotBlank()) listState.animateScrollToItem(0) }
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
val sidePadding =
@@ -2421,7 +2450,6 @@ fun RelaySettingsScreen(
Modifier
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(horizontal = sidePadding),
) {
Row(
@@ -2431,352 +2459,504 @@ fun RelaySettingsScreen(
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Settings",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(
onClick = { entries.forEach { expandedIds[it.meta.id] = false } },
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
) {
Text("Collapse all")
}
TextButton(
onClick = { entries.forEach { expandedIds[it.meta.id] = true } },
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
) {
Text("Expand all")
}
}
}
Spacer(Modifier.height(16.dp))
// Account Keys / Backup Section
BackupKeysCard(account = account)
Spacer(Modifier.height(24.dp))
// Wallet Connect Section
Text(
"Wallet Connect (NWC)",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Text(
"Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown &&
event.key == Key.Escape &&
query.isNotEmpty()
) {
query = ""
expandedIds.clear()
true
} else {
false
}
},
placeholder = { Text("Search settings…") },
leadingIcon = { Icon(MaterialSymbols.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(
onClick = {
query = ""
expandedIds.clear()
},
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
) {
Icon(MaterialSymbols.Clear, contentDescription = "Clear search")
}
}
},
singleLine = true,
)
Spacer(Modifier.height(12.dp))
if (nwcConnection != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
"Wallet Connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Relay: ${nwcConnection!!.relayUri.url}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
onClick = { nwcScope.launch { accountManager.clearNwcConnection(account.npub) } },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Disconnect")
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = nwcInput,
onValueChange = {
nwcInput = it
nwcError = null
},
label = { Text("NWC Connection String") },
placeholder = { Text("nostr+walletconnect://...") },
modifier = Modifier.weight(1f),
singleLine = true,
isError = nwcError != null,
supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
)
Button(
onClick = {
nwcScope.launch {
val result = accountManager.setNwcConnection(account.npub, nwcInput)
result.fold(
onSuccess = { nwcInput = "" },
onFailure = { nwcError = it.message ?: "Invalid connection string" },
)
}
},
enabled = nwcInput.isNotBlank(),
) {
Text("Connect")
}
}
}
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Media Server Settings (Blossom, kind 10063 — synced with mobile)
val networkBlossomServers by (LocalBlossomServers.current?.collectAsState() ?: remember { mutableStateOf(emptyList<String>()) })
// The kind-10063 list is authoritative; before it loads (or when the
// user has published none) show the default server.
val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) }
key(effectiveBlossomServers) {
MediaServerSettings(
initialServers = effectiveBlossomServers,
onServersChanged = onBlossomServersChanged,
)
}
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Image Compression Settings
ImageCompressionSettings()
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Tor Settings
com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection(
torStatus = torStatus,
currentSettings = torSettings,
onSettingsChanged = onTorSettingsChanged,
)
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Namecoin Settings (ElectrumX servers for .bit / d/ / id/ resolution)
val namecoinPrefsHere = namecoinPreferences ?: LocalNamecoinPreferences.current
val namecoinServiceHere = LocalNamecoinService.current
if (namecoinPrefsHere != null) {
val namecoinScope = rememberCoroutineScope()
val namecoinSettings by namecoinPrefsHere.settings.collectAsState()
NamecoinSettingsSection(
settings = namecoinSettings,
onToggleEnabled = { enabled ->
namecoinScope.launch { namecoinPrefsHere.setEnabled(enabled) }
},
onAddServer = { server ->
namecoinScope.launch { namecoinPrefsHere.addServer(server) }
},
onRemoveServer = { server ->
namecoinScope.launch { namecoinPrefsHere.removeServer(server) }
},
onReset = {
namecoinScope.launch { namecoinPrefsHere.reset() }
},
onTestServer =
namecoinServiceHere?.let { svc ->
{ server -> svc.client.testServer(server) }
},
onPinCert =
namecoinServiceHere?.let { svc ->
{ pem ->
namecoinPrefsHere.addPinnedCert(pem)
// Apply immediately so the next lookup uses the new pin.
// The same list is shared with the Namecoin Core RPC
// client when present, mirroring Android's behaviour
// where both backends consume one trust store.
namecoinScope.launch {
try {
val pins = namecoinPrefsHere.loadPinnedCerts()
svc.client.setDynamicCerts(pins)
svc.rpcClient?.setDynamicCerts(pins)
} catch (_: Exception) {
// Best-effort — persisted, will apply on next restart.
}
}
}
},
onSetBackend = { backend ->
namecoinScope.launch { namecoinPrefsHere.setBackend(backend) }
},
onSetCoreRpcConfig = { cfg ->
namecoinScope.launch {
namecoinPrefsHere.setCoreRpcConfig(cfg)
// Push the new config into the live client so the
// next lookup uses it without restarting the app.
namecoinServiceHere?.rpcClient?.setConfig(cfg)
}
},
onSetFallbackToCustomElectrumx = { enabled ->
namecoinScope.launch {
namecoinPrefsHere.setFallbackToCustomElectrumx(enabled)
}
},
onSetFallbackToDefaultElectrumx = { enabled ->
namecoinScope.launch {
namecoinPrefsHere.setFallbackToDefaultElectrumx(enabled)
}
},
onTestCoreRpc =
namecoinServiceHere?.let { svc ->
{ cfg ->
svc.probeCoreRpc(cfg)
?: com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
.RpcProbeResult(
success = false,
elapsedMs = 0,
error = "Namecoin Core RPC client not available",
)
}
},
)
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
}
// Developer Settings Section (only in debug mode)
if (DebugConfig.isDebugMode) {
com.vitorpamplona.amethyst.desktop.ui
.DevSettingsSection(account = account)
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
}
Text(
"Relay Settings",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"${connectedRelays.size} of ${relayStatuses.size} relays connected",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { relayManager.connect() }) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Reconnect",
tint = MaterialTheme.colorScheme.primary,
)
}
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = newRelayUrl,
onValueChange = { newRelayUrl = it },
label = { Text("Add relay") },
placeholder = { Text("wss://relay.example.com") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Button(
onClick = {
if (newRelayUrl.isNotBlank()) {
relayManager.addRelay(newRelayUrl)
newRelayUrl = ""
}
},
enabled = newRelayUrl.isNotBlank(),
) {
Text("Add")
}
}
Spacer(Modifier.height(16.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f),
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(relayStatuses.values.toList(), key = { it.url.url }) { status ->
RelayStatusCard(
status = status,
onRemove = { relayManager.removeRelay(status.url) },
items(visible, key = { it.meta.id }) { entry ->
SettingsAccordionCard(
// A non-blank query force-expands matches to reveal them.
expanded = if (query.isBlank()) expandedIds[entry.meta.id] == true else true,
onToggle = {
expandedIds[entry.meta.id] = !(expandedIds[entry.meta.id] ?: false)
},
headlineContent = { Text(entry.meta.title) },
leadingContent = {
Icon(
symbol = entry.meta.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
supportingContent = { Text(entry.meta.subtitle) },
content = entry.content,
)
}
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { relayManager.addDefaultRelays() }) {
Text("Reset to Defaults")
if (query.isNotBlank() && visible.isEmpty()) {
item(key = "__empty__") {
Text(
"No settings match \"$query\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
// Local Relay section
val localRelay = com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore.current
if (localRelay != null) {
com.vitorpamplona.amethyst.desktop.ui.settings.LocalRelaySettingsScreen(
localRelayStore = localRelay,
)
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
}
// Privacy lock section
com.vitorpamplona.amethyst.desktop.ui.settings
.PrivacyLockSettingsScreen()
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
// Content Filters section — hashtag-spam filter and future
// content-moderation toggles.
Text(
text = "Content Filters",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
com.vitorpamplona.amethyst.desktop.ui.settings.HashtagSpamSettingsSection(
settings = LocalHashtagSpamSettings.current,
)
Spacer(Modifier.height(16.dp))
com.vitorpamplona.amethyst.desktop.ui.settings
.ModerationSettingsSection()
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
val logoutScope = rememberCoroutineScope()
OutlinedButton(
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Logout")
if (query.isBlank()) {
item(key = "__logout__") {
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Logout")
}
Spacer(Modifier.height(24.dp))
}
}
}
}
}
}
/**
* Builds the ordered Settings accordion entries. Rebuilt on each recomposition
* (the list is tiny and collapsed cards render only their header), so each
* entry's [SettingsEntry.content] lambda captures the current param values and
* never goes stale. Reads that must react (relay counts, composition locals)
* happen here or lazily inside each content lambda.
*/
@Composable
private fun settingsEntries(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
accountManager: AccountManager,
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
torSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit,
namecoinPreferences: DesktopNamecoinPreferences?,
onBlossomServersChanged: (List<String>) -> Unit,
): List<SettingsEntry> {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
val namecoinPrefsHere = namecoinPreferences ?: LocalNamecoinPreferences.current
val localRelay = com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore.current
return buildList {
add(
SettingsEntry(
SettingsMeta(
id = "keyBackup",
icon = MaterialSymbols.Key,
title = "Account Keys / Backup",
subtitle = "Back up your secret key",
keywords =
persistentListOf(
"backup",
"nsec",
"key",
"recovery",
"secret key",
"export",
"ncryptsec",
),
),
) {
com.vitorpamplona.amethyst.desktop.ui.keyBackup
.BackupKeysCard(account = account)
},
)
add(
SettingsEntry(
SettingsMeta(
id = "nwc",
icon = MaterialSymbols.Bolt,
title = "Wallet Connect (NWC)",
subtitle = "Connect a Lightning wallet",
keywords =
persistentListOf(
"lightning",
"zap",
"wallet",
"alby",
"mutiny",
"nostr wallet connect",
"connect wallet",
"pay",
),
),
) {
WalletConnectSettingsSection(account = account, accountManager = accountManager)
},
)
if (namecoinPrefsHere != null) {
add(
SettingsEntry(
SettingsMeta(
id = "namecoin",
icon = MaterialSymbols.Lock,
title = "Namecoin Resolution",
subtitle = "Blockchain identity lookups (.bit)",
keywords =
persistentListOf(
".bit",
"blockchain",
"identity",
"electrumx",
"dns",
"namecoin",
"resolve",
),
),
) {
NamecoinSettingsEntryContent(namecoinPreferences)
},
)
}
add(
SettingsEntry(
SettingsMeta(
id = "blossom",
icon = MaterialSymbols.Image,
title = "Media Servers (Blossom)",
subtitle = "Configure media uploads",
keywords =
persistentListOf(
"upload",
"media",
"blossom",
"server",
"images",
"check all",
"add server",
),
),
) {
val networkBlossomServers by (
LocalBlossomServers.current?.collectAsState()
?: remember { mutableStateOf(emptyList<String>()) }
)
val effectiveBlossomServers = networkBlossomServers.ifEmpty { listOf(DEFAULT_BLOSSOM_SERVER) }
key(effectiveBlossomServers) {
MediaServerSettings(
initialServers = effectiveBlossomServers,
onServersChanged = onBlossomServersChanged,
)
}
},
)
add(
SettingsEntry(
SettingsMeta(
id = "imageCompression",
icon = MaterialSymbols.Tune,
title = "Image Compression",
subtitle = "Default upload quality",
keywords =
persistentListOf(
"quality",
"compression",
"compress",
"exif",
"upload quality",
"strip metadata",
),
),
) {
ImageCompressionSettings()
},
)
add(
SettingsEntry(
SettingsMeta(
id = "relays",
icon = MaterialSymbols.Podcasts,
title = "Relay Settings",
subtitle = "${connectedRelays.size} of ${relayStatuses.size} relays connected",
keywords =
persistentListOf(
"relay",
"nip-65",
"connect",
"reconnect",
"add relay",
"ws",
"wss",
),
),
) {
RelaySettingsSection(relayManager = relayManager)
},
)
if (localRelay != null) {
add(
SettingsEntry(
SettingsMeta(
id = "localRelay",
icon = MaterialSymbols.Storage,
title = "Local Relay",
subtitle = "Local event cache",
keywords =
persistentListOf(
"cache",
"sqlite",
"offline",
"local event store",
"vacuum",
"prune",
),
),
) {
com.vitorpamplona.amethyst.desktop.ui.settings
.LocalRelaySettingsScreen(localRelayStore = localRelay)
},
)
}
add(
SettingsEntry(
SettingsMeta(
id = "tor",
icon = MaterialSymbols.Shield,
title = "Tor",
subtitle = "Route connections through Tor",
keywords = persistentListOf("tor", "proxy", "privacy", "onion", "socks"),
),
) {
com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection(
torStatus = torStatus,
currentSettings = torSettings,
onSettingsChanged = onTorSettingsChanged,
)
},
)
add(
SettingsEntry(
SettingsMeta(
id = "privacyLock",
icon = MaterialSymbols.Lock,
title = "Privacy Lock",
subtitle = "Inactivity lock & redaction",
keywords =
persistentListOf(
"lock",
"password",
"inactivity",
"redaction",
"screen lock",
),
),
) {
com.vitorpamplona.amethyst.desktop.ui.settings
.PrivacyLockSettingsScreen()
},
)
add(
SettingsEntry(
SettingsMeta(
id = "contentFilters",
icon = MaterialSymbols.FilterAlt,
title = "Content Filters",
subtitle = "Hashtag-spam filter",
keywords =
persistentListOf(
"hashtag",
"spam",
"filter",
"mute",
"threshold",
),
),
) {
com.vitorpamplona.amethyst.desktop.ui.settings.HashtagSpamSettingsSection(
settings = LocalHashtagSpamSettings.current,
)
},
)
add(
SettingsEntry(
SettingsMeta(
id = "moderation",
icon = MaterialSymbols.Block,
title = "Moderation",
subtitle = "Muted & blocked accounts",
keywords =
persistentListOf(
"mute",
"block",
"report",
"moderation",
"blocked",
"safety",
),
),
) {
com.vitorpamplona.amethyst.desktop.ui.settings
.ModerationSettingsSection()
},
)
if (DebugConfig.isDebugMode) {
add(
SettingsEntry(
SettingsMeta(
id = "developer",
icon = MaterialSymbols.Warning,
title = "Developer Settings",
subtitle = "Debug tools",
keywords = persistentListOf("debug", "developer", "logs"),
),
) {
com.vitorpamplona.amethyst.desktop.ui
.DevSettingsSection(account = account)
},
)
}
}
}
/** Namecoin (.bit) resolution settings body for the Settings accordion. */
@Composable
private fun NamecoinSettingsEntryContent(namecoinPreferences: DesktopNamecoinPreferences?) {
val namecoinPrefsHere = namecoinPreferences ?: LocalNamecoinPreferences.current
val namecoinServiceHere = LocalNamecoinService.current
if (namecoinPrefsHere != null) {
val namecoinScope = rememberCoroutineScope()
val namecoinSettings by namecoinPrefsHere.settings.collectAsState()
NamecoinSettingsSection(
settings = namecoinSettings,
onToggleEnabled = { enabled ->
namecoinScope.launch { namecoinPrefsHere.setEnabled(enabled) }
},
onAddServer = { server ->
namecoinScope.launch { namecoinPrefsHere.addServer(server) }
},
onRemoveServer = { server ->
namecoinScope.launch { namecoinPrefsHere.removeServer(server) }
},
onReset = {
namecoinScope.launch { namecoinPrefsHere.reset() }
},
onTestServer =
namecoinServiceHere?.let { svc ->
{ server -> svc.client.testServer(server) }
},
onPinCert =
namecoinServiceHere?.let { svc ->
{ pem ->
namecoinPrefsHere.addPinnedCert(pem)
namecoinScope.launch {
try {
val pins = namecoinPrefsHere.loadPinnedCerts()
svc.client.setDynamicCerts(pins)
svc.rpcClient?.setDynamicCerts(pins)
} catch (_: Exception) {
// Best-effort — persisted, will apply on next restart.
}
}
}
},
onSetBackend = { backend ->
namecoinScope.launch { namecoinPrefsHere.setBackend(backend) }
},
onSetCoreRpcConfig = { cfg ->
namecoinScope.launch {
namecoinPrefsHere.setCoreRpcConfig(cfg)
namecoinServiceHere?.rpcClient?.setConfig(cfg)
}
},
onSetFallbackToCustomElectrumx = { enabled ->
namecoinScope.launch {
namecoinPrefsHere.setFallbackToCustomElectrumx(enabled)
}
},
onSetFallbackToDefaultElectrumx = { enabled ->
namecoinScope.launch {
namecoinPrefsHere.setFallbackToDefaultElectrumx(enabled)
}
},
onTestCoreRpc =
namecoinServiceHere?.let { svc ->
{ cfg ->
svc.probeCoreRpc(cfg)
?: com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
.RpcProbeResult(
success = false,
elapsedMs = 0,
error = "Namecoin Core RPC client not available",
)
}
},
)
}
}
@@ -55,7 +55,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.DesktopScreen
import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen
import com.vitorpamplona.amethyst.desktop.SettingsScreen
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -494,7 +494,7 @@ internal fun RootContent(
DeckColumnType.Settings -> {
val torState = com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState.current
RelaySettingsScreen(
SettingsScreen(
relayManager = relayManager,
account = account,
accountManager = accountManager,
@@ -62,12 +62,6 @@ fun HashtagSpamSettingsSection(
LaunchedEffect(committed) { live = committed.toFloat() }
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = "Hashtag-spam filter",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = enabled, onCheckedChange = settings::setEnabled)
Spacer(Modifier.width(8.dp))
@@ -37,7 +37,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality
import com.vitorpamplona.amethyst.desktop.ImageCompressionStore
@@ -57,13 +56,6 @@ fun ImageCompressionSettings(modifier: Modifier = Modifier) {
val stripExif by ImageCompressionStore.stripExif.collectAsState()
Column(modifier = modifier.fillMaxWidth()) {
Text(
"Image Compression",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(12.dp))
Text(
"Default quality",
style = MaterialTheme.typography.bodyMedium,
@@ -77,14 +77,6 @@ fun LocalRelaySettingsScreen(
val dbSizeBytes by localRelayStore.dbSizeBytes.collectAsState()
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = "Local Relay",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(modifier = Modifier.height(8.dp))
// Status section
StatusSection(
enabled = enabled,
@@ -92,17 +92,9 @@ fun MediaServerSettings(
}
}
// Parent (RelaySettingsScreen) provides the 12dp horizontal gutter, so this
// column no longer adds its own all-sides 16dp which was showing up as an
// extra frame inside the settings screen.
// Parent (the Settings accordion card) provides the horizontal gutter and
// the section title, so this column renders the body only.
Column(modifier = modifier.fillMaxWidth()) {
Text(
"Media Servers (Blossom)",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
Text(
"Configure Blossom servers for media uploads. First server is the default.",
style = MaterialTheme.typography.bodySmall,
@@ -0,0 +1,132 @@
/*
* 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.settings
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.material3.Button
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.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.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
/**
* Relay Settings body: connection count + reconnect, add-relay input, the list of
* connected relays, and a reset-to-defaults action. Extracted from the old inline
* settings screen; the relay list is a plain [Column] (not a nested `LazyColumn`)
* because this now renders inside the outer Settings accordion `LazyColumn`, where
* an unbounded inner lazy list would crash. The list is small, so recycling is not
* needed. The section title is owned by the accordion card header.
*/
@Composable
fun RelaySettingsSection(
relayManager: DesktopRelayConnectionManager,
modifier: Modifier = Modifier,
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
var newRelayUrl by remember { mutableStateOf("") }
Column(modifier = modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"${connectedRelays.size} of ${relayStatuses.size} relays connected",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { relayManager.connect() }) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Reconnect",
tint = MaterialTheme.colorScheme.primary,
)
}
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = newRelayUrl,
onValueChange = { newRelayUrl = it },
label = { Text("Add relay") },
placeholder = { Text("wss://relay.example.com") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Button(
onClick = {
if (newRelayUrl.isNotBlank()) {
relayManager.addRelay(newRelayUrl)
newRelayUrl = ""
}
},
enabled = newRelayUrl.isNotBlank(),
) {
Text("Add")
}
}
Spacer(Modifier.height(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
relayStatuses.values.forEach { status ->
RelayStatusCard(
status = status,
onRemove = { relayManager.removeRelay(status.url) },
)
}
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { relayManager.addDefaultRelays() }) {
Text("Reset to Defaults")
}
}
}
}
@@ -0,0 +1,169 @@
/*
* 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.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
/**
* A collapsible settings card for the Settings accordion. The header row (icon +
* headline + supporting text + trailing chevron) is always shown and toggles the
* body; the body is composed only while [expanded] via [AnimatedVisibility].
*
* Header regions are slots rather than primitive strings so a section can put a
* control (e.g. an enable toggle) in [trailingContent] instead of the default
* chevron. This is the single card pattern for the accordion — mouse affordances
* (hover highlight + hand cursor) live here so every card behaves identically.
*/
@Composable
fun SettingsAccordionCard(
expanded: Boolean,
onToggle: () -> Unit,
headlineContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
leadingContent: (@Composable () -> Unit)? = null,
supportingContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable RowScope.() -> Unit = { SettingsCardDefaults.Chevron(expanded) },
content: @Composable () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val hovered by interactionSource.collectIsHoveredAsState()
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = onToggle,
).pointerHoverIcon(PointerIcon.Hand)
.background(
if (hovered) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f)
} else {
Color.Transparent
},
).padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (leadingContent != null) {
Box(
modifier =
Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) {
leadingContent()
}
}
Column(modifier = Modifier.weight(1f)) {
ProvideTextStyle(
MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
) {
headlineContent()
}
if (supportingContent != null) {
ProvideTextStyle(
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
supportingContent()
}
}
}
trailingContent()
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
content()
}
}
}
}
}
/** Defaults for [SettingsAccordionCard], mirroring Material3's `XxxDefaults` idiom. */
object SettingsCardDefaults {
@Composable
fun Chevron(expanded: Boolean) {
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = LocalContentColor.current.copy(alpha = 0.7f),
)
}
}
@@ -0,0 +1,67 @@
/*
* 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.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import kotlinx.collections.immutable.ImmutableList
/**
* Pure, searchable metadata for one entry in the Settings accordion.
*
* Kept UI-free and free of any Compose slot so [matches] is trivially unit
* testable and the type stays a value-comparable [Immutable] data class. The
* matcher indexes the visible [title], the [subtitle], and a curated [keywords]
* list that also carries action synonyms (e.g. "reconnect", "connect wallet")
* so a user typing an action term still surfaces the card that hosts it.
*/
@Immutable
data class SettingsMeta(
val id: String,
val icon: MaterialSymbol,
val title: String,
val subtitle: String,
val keywords: ImmutableList<String>,
) {
fun matches(query: String): Boolean {
val q = query.trim()
if (q.isBlank()) return true
return title.contains(q, ignoreCase = true) ||
subtitle.contains(q, ignoreCase = true) ||
keywords.any { it.contains(q, ignoreCase = true) }
}
}
/**
* One accordion entry: its searchable [meta] plus the composable [content] that
* renders the section body when the card is expanded.
*
* [Immutable] is an honest promise here — every field except [content] is a
* `val` of a stable type, and a stable holder may carry a composable member.
* That lets the accordion cards skip on unrelated recompositions as long as the
* entry list is remembered (see the screen's builder).
*/
@Immutable
class SettingsEntry(
val meta: SettingsMeta,
val content: @Composable () -> Unit,
)
@@ -0,0 +1,139 @@
/*
* 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.settings
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.material3.Button
import androidx.compose.material3.ButtonDefaults
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.collectAsState
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import kotlinx.coroutines.launch
/**
* Wallet Connect (NWC) settings body. Lets the user connect/disconnect a
* Lightning wallet via a `nostr+walletconnect://` string. Extracted verbatim
* from the old inline settings screen so it can be slotted into the Settings
* accordion; the section title is now owned by the accordion card header.
*/
@Composable
fun WalletConnectSettingsSection(
account: AccountState.LoggedIn,
accountManager: AccountManager,
modifier: Modifier = Modifier,
) {
val nwcConnection by accountManager.nwcConnection.collectAsState()
var nwcInput by remember { mutableStateOf("") }
var nwcError by remember { mutableStateOf<String?>(null) }
val nwcScope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxWidth()) {
Text(
"Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
if (nwcConnection != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
"Wallet Connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Relay: ${nwcConnection!!.relayUri.url}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
onClick = { nwcScope.launch { accountManager.clearNwcConnection(account.npub) } },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Disconnect")
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = nwcInput,
onValueChange = {
nwcInput = it
nwcError = null
},
label = { Text("NWC Connection String") },
placeholder = { Text("nostr+walletconnect://...") },
modifier = Modifier.weight(1f),
singleLine = true,
isError = nwcError != null,
supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
)
Button(
onClick = {
nwcScope.launch {
val result = accountManager.setNwcConnection(account.npub, nwcInput)
result.fold(
onSuccess = { nwcInput = "" },
onFailure = { nwcError = it.message ?: "Invalid connection string" },
)
}
},
enabled = nwcInput.isNotBlank(),
) {
Text("Connect")
}
}
}
}
}
@@ -68,12 +68,6 @@ fun TorSettingsSection(
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Tor",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.width(12.dp))
TorStatusIndicator(status = torStatus)
}
TextButton(onClick = { showDialog = true }) {
@@ -0,0 +1,74 @@
/*
* 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.settings
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import kotlinx.collections.immutable.persistentListOf
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SettingsMetaTest {
private val nwc =
SettingsMeta(
id = "nwc",
icon = MaterialSymbols.Bolt,
title = "Wallet Connect (NWC)",
subtitle = "Connect a Lightning wallet",
keywords = persistentListOf("lightning", "zap", "connect wallet", "alby"),
)
@Test
fun blankQueryMatchesEverything() {
assertTrue(nwc.matches(""))
assertTrue(nwc.matches(" "))
}
@Test
fun matchesByTitleCaseInsensitively() {
assertTrue(nwc.matches("wallet"))
assertTrue(nwc.matches("WALLET"))
assertTrue(nwc.matches("nwc"))
}
@Test
fun matchesBySubtitle() {
assertTrue(nwc.matches("lightning"))
}
@Test
fun matchesByActionKeyword() {
// A user typing an action term surfaces the card that hosts it.
assertTrue(nwc.matches("zap"))
assertTrue(nwc.matches("Alby"))
}
@Test
fun surroundingWhitespaceIsTrimmed() {
assertTrue(nwc.matches(" zap "))
}
@Test
fun nonMatchingQueryReturnsFalse() {
assertFalse(nwc.matches("relay"))
assertFalse(nwc.matches("blossom"))
}
}