diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 6059c4cc2..210eb3f58 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 17c58652e..2fcdda0f5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -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>(emptySet()) val followedUsers: StateFlow> = _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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayMaintenance.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayMaintenance.kt new file mode 100644 index 000000000..514c3a417 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayMaintenance.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt new file mode 100644 index 000000000..cf3eb5029 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt @@ -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 = _enabled.asStateFlow() + + private val _writesDisabled = MutableStateFlow(false) + val writesDisabled: StateFlow = _writesDisabled.asStateFlow() + + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError.asStateFlow() + + private val _eventCount = MutableStateFlow(0L) + val eventCount: StateFlow = _eventCount.asStateFlow() + + private val _dbSizeBytes = MutableStateFlow(0L) + val dbSizeBytes: StateFlow = _dbSizeBytes.asStateFlow() + + private val writeBundler = + BasicBundledInsert( + 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(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(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(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(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() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/OfflineBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/OfflineBanner.kt new file mode 100644 index 000000000..fa5baa922 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/components/OfflineBanner.kt @@ -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, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index b9f36a6b9..3dae8973b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -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). diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt index 651db0f91..d05b48730 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt @@ -80,3 +80,8 @@ val LocalRelayManager = compositionLocalOf { null } + +val LocalLocalRelayStore = + compositionLocalOf { + null + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 3e23bc70b..33c8e8df2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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). diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt new file mode 100644 index 000000000..ee7d6a872 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt @@ -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" + }