Merge pull request #2841 from nrobi144/feat/desktop-embedded-local-relay

feat(desktop): embedded local relay with SQLite event persistence
This commit is contained in:
Vitor Pamplona
2026-05-11 08:16:17 -04:00
committed by GitHub
9 changed files with 955 additions and 1 deletions
@@ -185,6 +185,8 @@ sealed class DesktopScreen {
data object Drafts : DesktopScreen()
data object Settings : DesktopScreen()
data object LocalRelaySettings : DesktopScreen()
}
/** Reference to active Tor manager for shutdown hook. Set by App composable. */
@@ -720,6 +722,23 @@ fun App(
val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
// Local relay store — persists events to SQLite per account
val localRelayStore =
remember {
com.vitorpamplona.amethyst.desktop.relay
.LocalRelayStore(scope)
}
val localRelayMaintenance =
remember {
com.vitorpamplona.amethyst.desktop.relay
.LocalRelayMaintenance(localRelayStore, scope)
}
// Wire local relay store to cache for write-through
LaunchedEffect(localRelayStore) {
localCache.localRelayStore = localRelayStore
}
// Build TorRelayEvaluation for per-relay routing
val torRelayEvaluation =
remember(torSettings) {
@@ -795,6 +814,8 @@ fun App(
is AccountState.LoggedOut -> {
subscriptionsCoordinator.clear()
localCache.clear()
localRelayMaintenance.stop()
localRelayStore.close()
previousAccountPubKey = null
}
@@ -804,8 +825,16 @@ fun App(
// Account switched — clear old data so new feed loads fresh
subscriptionsCoordinator.clear()
localCache.clear()
localRelayMaintenance.stop()
localRelayStore.close()
subscriptionsCoordinator.start()
}
// Open local relay store for the current account and hydrate cache
localRelayStore.openForAccount(currentPubKey)
localRelayMaintenance.start()
scope.launch(Dispatchers.IO) {
localRelayStore.hydrate(localCache)
}
previousAccountPubKey = currentPubKey
}
@@ -883,6 +912,8 @@ fun App(
runBlocking { accountManager.disconnectNip46Client() }
subscriptionsCoordinator.clear()
relayManager.disconnect()
localRelayMaintenance.stop()
localRelayStore.close()
scope.cancel()
}
}
@@ -901,6 +932,7 @@ fun App(
CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore,
) {
when (accountState) {
is AccountState.LoggedOut -> {
@@ -1686,6 +1718,19 @@ fun RelaySettingsScreen(
}
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))
}
val logoutScope = rememberCoroutineScope()
OutlinedButton(
@@ -79,6 +79,9 @@ class DesktopLocalCache : ICacheProvider {
val eventStream = DesktopCacheEventStream()
/** Local relay store for persisting events to SQLite. Set from Main.kt on account login. */
var localRelayStore: com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore? = null
/** Cached follow set for the logged-in user. Thread-safe + Compose-observable. */
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
@@ -204,7 +207,12 @@ class DesktopLocalCache : ICacheProvider {
wasVerified: Boolean = false,
): Boolean {
if (!wasVerified && !justVerify(event)) return false
return route(event, relay)
val consumed = route(event, relay)
// Write-through to local store, but skip if event came from local store (hydration)
if (consumed && relay != com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore.LOCAL_RELAY_URL) {
localRelayStore?.enqueue(event)
}
return consumed
}
private fun route(
@@ -0,0 +1,80 @@
/*
* 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.relay
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.prefs.Preferences
class LocalRelayMaintenance(
private val store: LocalRelayStore,
private val scope: CoroutineScope,
) {
private var maintenanceJob: Job? = null
fun start() {
maintenanceJob?.cancel()
maintenanceJob =
scope.launch(Dispatchers.IO) {
// Startup maintenance
try {
store.deleteExpiredEvents()
maybeVacuum()
store.checkDiskSpace()
} catch (e: Exception) {
Log.w("LocalRelayMaintenance") { "Startup maintenance: ${e.message}" }
}
// Periodic maintenance (every 6 hours)
while (isActive) {
delay(6 * 60 * 60 * 1000L)
try {
store.deleteExpiredEvents()
store.pruneOldEvents(maxAgeDays = 30)
store.checkDiskSpace()
store.refreshStats()
} catch (e: Exception) {
Log.w("LocalRelayMaintenance") { "Periodic maintenance: ${e.message}" }
}
}
}
}
private suspend fun maybeVacuum() {
val prefs = Preferences.userRoot().node("amethyst/localrelay")
val lastVacuum = prefs.getLong("lastVacuum", 0)
val sevenDays = 7 * 24 * 60 * 60 * 1000L
if (System.currentTimeMillis() - lastVacuum > sevenDays) {
store.vacuum()
prefs.putLong("lastVacuum", System.currentTimeMillis())
}
}
fun stop() {
maintenanceJob?.cancel()
maintenanceJob = null
}
}
@@ -0,0 +1,285 @@
/*
* 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.relay
import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
class LocalRelayStore(
private val scope: CoroutineScope,
) : AutoCloseable {
companion object {
val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
private fun dbDir(pubKeyHex: String): File = File(System.getProperty("user.home"), ".amethyst/accounts/${pubKeyHex.take(8)}")
fun dbFile(pubKeyHex: String): File = File(dbDir(pubKeyHex), "events.db")
}
private val lock = Any()
@Volatile
private var store: EventStore? = null
@Volatile
private var currentPubKey: String? = null
private val _enabled = MutableStateFlow(true)
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
private val _writesDisabled = MutableStateFlow(false)
val writesDisabled: StateFlow<Boolean> = _writesDisabled.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
private val _eventCount = MutableStateFlow(0L)
val eventCount: StateFlow<Long> = _eventCount.asStateFlow()
private val _dbSizeBytes = MutableStateFlow(0L)
val dbSizeBytes: StateFlow<Long> = _dbSizeBytes.asStateFlow()
private val writeBundler =
BasicBundledInsert<Event>(
delay = 250,
dispatcher = Dispatchers.IO,
scope = scope,
)
fun openForAccount(pubKeyHex: String) {
synchronized(lock) {
if (currentPubKey == pubKeyHex && store != null) return
close()
currentPubKey = pubKeyHex
val dir = dbDir(pubKeyHex)
dir.mkdirs()
val path = File(dir, "events.db").absolutePath
try {
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
_lastError.value = null
refreshStats()
} catch (e: Exception) {
Log.w("LocalRelayStore") { "DB open failed, recreating: ${e.message}" }
try {
deleteDbFiles(path)
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
_lastError.value = "Database was recreated: ${e.message}"
} catch (e2: Exception) {
_lastError.value = "Cannot open local store: ${e2.message}"
Log.e("LocalRelayStore", "Failed to recreate DB", e2)
}
}
}
}
fun enqueue(event: Event) {
if (!_enabled.value || _writesDisabled.value) return
val s = store ?: return
writeBundler.invalidateList(event) { batch ->
try {
s.transaction {
batch.forEach { insert(it) }
}
} catch (e: Exception) {
Log.w("LocalRelayStore") { "Batch insert failed: ${e.message}" }
}
}
}
suspend fun hydrate(cache: DesktopLocalCache) {
val s = store ?: return
val relay = LOCAL_RELAY_URL
Log.d("LocalRelayStore") { "Starting hydration..." }
try {
// Phase 1: Contact list (kind 3) — need follow list for filtering
val contactFilter = Filter(kinds = listOf(3), limit = 10)
s.query<Event>(contactFilter).forEach { event ->
cache.consume(event, relay, wasVerified = true)
}
// Phase 2: Metadata (kind 0) for followed users
val followed = cache.followedUsers.value
if (followed.isNotEmpty()) {
followed.chunked(500).forEach { chunk ->
val metaFilter =
Filter(
kinds = listOf(0),
authors = chunk.toList(),
limit = chunk.size,
)
s.query<Event>(metaFilter).forEach { event ->
cache.consume(event, relay, wasVerified = true)
}
}
}
// Phase 3: Recent content events (last 7 days, max 5000)
val since = (System.currentTimeMillis() / 1000) - (7 * 24 * 3600)
val contentFilter =
Filter(
kinds = listOf(1, 6, 7, 16, 1111, 9735),
since = since,
limit = 5000,
)
s.query<Event>(contentFilter).forEach { event ->
cache.consume(event, relay, wasVerified = true)
}
refreshStats()
Log.d("LocalRelayStore") { "Hydration complete. Events: ${_eventCount.value}" }
} catch (e: Exception) {
_lastError.value = "Hydration failed: ${e.message}"
Log.w("LocalRelayStore") { "Hydration error: ${e.message}" }
}
}
suspend fun deleteExpiredEvents() {
try {
store?.deleteExpiredEvents()
} catch (e: Exception) {
Log.w("LocalRelayStore") { "deleteExpiredEvents failed: ${e.message}" }
}
}
suspend fun pruneOldEvents(maxAgeDays: Int = 30) {
try {
val cutoff = (System.currentTimeMillis() / 1000) - (maxAgeDays.toLong() * 24 * 3600)
store?.delete(Filter(until = cutoff))
refreshStats()
} catch (e: Exception) {
_lastError.value = "Prune failed: ${e.message}"
}
}
suspend fun vacuum() {
try {
store?.store?.vacuum()
refreshStats()
} catch (e: Exception) {
_lastError.value = "Vacuum failed: ${e.message}"
}
}
suspend fun clearAll() {
val pk = currentPubKey ?: return
close()
val path = dbFile(pk).absolutePath
deleteDbFiles(path)
openForAccount(pk)
}
fun setEnabled(value: Boolean) {
_enabled.value = value
}
fun disableWrites() {
_writesDisabled.value = true
}
fun enableWrites() {
_writesDisabled.value = false
}
fun clearError() {
_lastError.value = null
}
fun refreshStats() {
val pk = currentPubKey ?: return
scope.launch(Dispatchers.IO) {
try {
_eventCount.value = store?.count(Filter())?.toLong() ?: 0
_dbSizeBytes.value = dbFile(pk).length()
} catch (e: Exception) {
Log.w("LocalRelayStore") { "Stats refresh failed: ${e.message}" }
}
}
}
fun checkDiskSpace(): Boolean {
val pk = currentPubKey ?: return true
val usable = dbFile(pk).parentFile?.usableSpace ?: return true
val low = usable < 100 * 1024 * 1024 // < 100MB
if (low && !_writesDisabled.value) {
disableWrites()
_lastError.value = "Disk space low (${usable / 1024 / 1024}MB). Writes disabled."
}
return !low
}
suspend fun exportEvents(outputFile: File) {
val s = store ?: return
outputFile.bufferedWriter().use { writer ->
s.query<Event>(Filter()) { event ->
writer.write(event.toJson())
writer.newLine()
}
}
}
suspend fun importEvents(inputFile: File) {
val s = store ?: return
val lines = inputFile.readLines()
s.transaction {
lines.forEach { line ->
if (line.isNotBlank()) {
try {
val event = Event.fromJson(line)
insert(event)
} catch (e: Exception) {
Log.w("LocalRelayStore") { "Import skip: ${e.message}" }
}
}
}
}
refreshStats()
}
override fun close() {
synchronized(lock) {
try {
store?.close()
} catch (e: Exception) {
Log.w("LocalRelayStore") { "Close error: ${e.message}" }
}
store = null
}
}
private fun deleteDbFiles(path: String) {
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
File(path + suffix).delete()
}
}
}
@@ -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.ui.components
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.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
@Composable
fun OfflineBanner(
connectedRelayCount: Int,
hasLocalData: Boolean,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = connectedRelayCount == 0,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
modifier = modifier,
) {
val containerColor =
if (hasLocalData) {
MaterialTheme.colorScheme.surfaceContainerHigh
} else {
MaterialTheme.colorScheme.errorContainer
}
val contentColor =
if (hasLocalData) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onErrorContainer
}
Surface(
color = containerColor,
contentColor = contentColor,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol =
if (hasLocalData) {
MaterialSymbols.CloudSync
} else {
MaterialSymbols.Warning
},
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = contentColor,
)
Text(
text =
if (hasLocalData) {
"Offline \u2014 showing cached events"
} else {
"Offline \u2014 no cached events available"
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@@ -131,6 +131,21 @@ fun DeckColumnContainer(
HorizontalDivider()
// Offline banner — shows when no remote relays connected
val connectedRelays by relayManager.connectedRelays.collectAsState()
val localRelay = LocalLocalRelayStore.current
val hasLocalData =
if (localRelay != null) {
val count by localRelay.eventCount.collectAsState()
count > 0
} else {
false
}
com.vitorpamplona.amethyst.desktop.ui.components.OfflineBanner(
connectedRelayCount = connectedRelays.size,
hasLocalData = hasLocalData,
)
// Content runs edge-to-edge; each screen adds its own header padding
// to match the Messages pattern (padding(horizontal = 12, vertical = 8)
// on the title row, no outer wrapper).
@@ -80,3 +80,8 @@ val LocalRelayManager =
compositionLocalOf<DesktopRelayConnectionManager?> {
null
}
val LocalLocalRelayStore =
compositionLocalOf<com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore?> {
null
}
@@ -265,6 +265,21 @@ fun SinglePaneLayout(
}
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
// Offline banner — shows when no remote relays connected
val connectedRelays by relayManager.connectedRelays.collectAsState()
val localRelay = LocalLocalRelayStore.current
val hasLocalData =
if (localRelay != null) {
val count by localRelay.eventCount.collectAsState()
count > 0
} else {
false
}
com.vitorpamplona.amethyst.desktop.ui.components.OfflineBanner(
connectedRelayCount = connectedRelays.size,
hasLocalData = hasLocalData,
)
// Content extends to the window edges; individual screens add their
// own internal padding where appropriate (Messages uses full-bleed
// panes to match native two-column chat apps).
@@ -0,0 +1,400 @@
/*
* 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.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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
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.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@Composable
fun LocalRelaySettingsScreen(
localRelayStore: LocalRelayStore,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val enabled by localRelayStore.enabled.collectAsState()
val writesDisabled by localRelayStore.writesDisabled.collectAsState()
val lastError by localRelayStore.lastError.collectAsState()
val eventCount by localRelayStore.eventCount.collectAsState()
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,
writesDisabled = writesDisabled,
onToggle = { localRelayStore.setEnabled(it) },
)
Spacer(modifier = Modifier.height(16.dp))
// Disk full warning
DiskFullWarning(
writesDisabled = writesDisabled,
onPrune = {
scope.launch(Dispatchers.IO) {
localRelayStore.pruneOldEvents()
localRelayStore.enableWrites()
}
},
onClear = {
scope.launch(Dispatchers.IO) {
localRelayStore.clearAll()
localRelayStore.enableWrites()
}
},
)
// Statistics section
CollapsibleSection(title = "Statistics", initiallyExpanded = true) {
StatisticsContent(eventCount = eventCount, dbSizeBytes = dbSizeBytes)
}
Spacer(modifier = Modifier.height(8.dp))
// Storage management
CollapsibleSection(title = "Storage", initiallyExpanded = false) {
StorageContent(
onPrune = { scope.launch(Dispatchers.IO) { localRelayStore.pruneOldEvents() } },
onVacuum = { scope.launch(Dispatchers.IO) { localRelayStore.vacuum() } },
onClearAll = { scope.launch(Dispatchers.IO) { localRelayStore.clearAll() } },
)
}
Spacer(modifier = Modifier.height(8.dp))
// Export / Import
CollapsibleSection(title = "Export / Import", initiallyExpanded = false) {
ExportImportContent(
onExport = { file -> scope.launch(Dispatchers.IO) { localRelayStore.exportEvents(file) } },
onImport = { file -> scope.launch(Dispatchers.IO) { localRelayStore.importEvents(file) } },
)
}
Spacer(modifier = Modifier.height(8.dp))
// Errors section
CollapsibleSection(title = "Errors", initiallyExpanded = false) {
ErrorsContent(lastError = lastError, onClear = { localRelayStore.clearError() })
}
}
}
@Composable
private fun StatusSection(
enabled: Boolean,
writesDisabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol =
if (enabled && !writesDisabled) {
MaterialSymbols.CheckCircle
} else {
MaterialSymbols.Storage
},
contentDescription = null,
tint =
if (enabled && !writesDisabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = "Local Event Cache", style = MaterialTheme.typography.titleSmall)
Text(
text =
when {
!enabled -> "Disabled"
writesDisabled -> "Active (writes paused)"
else -> "Active"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = enabled, onCheckedChange = onToggle)
}
}
}
@Composable
private fun DiskFullWarning(
writesDisabled: Boolean,
onPrune: () -> Unit,
onClear: () -> Unit,
) {
AnimatedVisibility(
visible = writesDisabled,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small,
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(18.dp),
)
Text(
text = "Writes paused \u2014 disk space low or error occurred",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onPrune) { Text("Prune Old Events") }
OutlinedButton(onClick = onClear) { Text("Clear Cache") }
}
}
}
}
}
@Composable
private fun StatisticsContent(
eventCount: Long,
dbSizeBytes: Long,
) {
Column(modifier = Modifier.padding(start = 8.dp, top = 8.dp)) {
StatRow("Total events", "$eventCount")
StatRow("Database size", formatBytes(dbSizeBytes))
}
}
@Composable
private fun StatRow(
label: String,
value: String,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(text = value, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun StorageContent(
onPrune: () -> Unit,
onVacuum: () -> Unit,
onClearAll: () -> Unit,
) {
Column(
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(onClick = onPrune, modifier = Modifier.fillMaxWidth()) {
Text("Prune events older than 30 days")
}
OutlinedButton(onClick = onVacuum, modifier = Modifier.fillMaxWidth()) {
Text("Reclaim disk space (VACUUM)")
}
HorizontalDivider()
Button(
onClick = onClearAll,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) {
Icon(symbol = MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Clear all cached events")
}
}
}
@Composable
private fun ExportImportContent(
onExport: (File) -> Unit,
onImport: (File) -> Unit,
) {
Column(
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
val dialog = FileDialog(null as Frame?, "Export Events", FileDialog.SAVE)
dialog.file = "events.jsonl"
dialog.isVisible = true
val dir = dialog.directory
val file = dialog.file
if (dir != null && file != null) onExport(File(dir, file))
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.Download, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Export events (JSONL)")
}
OutlinedButton(
onClick = {
val dialog = FileDialog(null as Frame?, "Import Events", FileDialog.LOAD)
dialog.isVisible = true
val dir = dialog.directory
val file = dialog.file
if (dir != null && file != null) onImport(File(dir, file))
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.Upload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Import events (JSONL)")
}
}
}
@Composable
private fun ErrorsContent(
lastError: String?,
onClear: () -> Unit,
) {
Column(modifier = Modifier.padding(start = 8.dp, top = 8.dp)) {
if (lastError != null) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(
text = lastError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = onClear) { Text("Clear errors") }
} else {
Text(
text = "No errors",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun CollapsibleSection(
title: String,
initiallyExpanded: Boolean = true,
content: @Composable () -> Unit,
) {
var expanded by remember { mutableStateOf(initiallyExpanded) }
Column {
Row(
modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = title, style = MaterialTheme.typography.titleSmall)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
content()
}
}
}
private fun formatBytes(bytes: Long): String =
when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
bytes < 1024 * 1024 * 1024 -> "${"%.1f".format(bytes / (1024.0 * 1024))} MB"
else -> "${"%.2f".format(bytes / (1024.0 * 1024 * 1024))} GB"
}