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:
@@ -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(
|
||||
|
||||
Vendored
+9
-1
@@ -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(
|
||||
|
||||
+80
@@ -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
|
||||
}
|
||||
}
|
||||
+285
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+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.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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).
|
||||
|
||||
+5
@@ -80,3 +80,8 @@ val LocalRelayManager =
|
||||
compositionLocalOf<DesktopRelayConnectionManager?> {
|
||||
null
|
||||
}
|
||||
|
||||
val LocalLocalRelayStore =
|
||||
compositionLocalOf<com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore?> {
|
||||
null
|
||||
}
|
||||
|
||||
+15
@@ -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).
|
||||
|
||||
+400
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user