diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealth.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealth.kt new file mode 100644 index 0000000000..53cc260ae4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealth.kt @@ -0,0 +1,93 @@ +/* + * 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.commons.relays.health + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet + +const val RELAY_HEALTH_THRESHOLD_SECONDS: Long = TimeUtils.ONE_WEEK.toLong() + +/** Lists whose membership counts toward "is this relay in the user's set." */ +val DETECTION_LISTS: Set = + setOf(RelayListKind.Nip65, RelayListKind.DmInbox, RelayListKind.Search) + +/** + * Pure classifier. Returns the list of relays the UI should surface as unhealthy. + * + * Inputs: + * - records: durable per-relay timestamps (events received, connects, snoozes) + * - listMembership: which lists each relay currently belongs to + * - firstScanAt: when the local store first started observing this account. + * Used as a global newcomer/first-run grace gate. + * If `now - firstScanAt < threshold` (e.g. 7d), nothing is flagged. + * - lastSeenAny: most recent any-relay activity. Used as an offline-grace gate. + * If `now - lastSeenAny > threshold`, nothing is flagged (we're offline). + * - torEnabled: when true (Tor mode), classification is skipped entirely (relay timing + * is intentionally lossy through Tor; v2 may be smarter). + * - now: clock injection (epoch seconds). + */ +fun classifyRelayHealth( + records: Map, + listMembership: Map>, + firstScanAt: Long, + lastSeenAny: Long, + torEnabled: Boolean, + now: Long = TimeUtils.now(), + threshold: Long = RELAY_HEALTH_THRESHOLD_SECONDS, +): PersistentList { + if (torEnabled) return persistentListOf() + + // First-run/newcomer grace: don't flag until we've been observing for `threshold` seconds. + if (firstScanAt == 0L || now - firstScanAt < threshold) return persistentListOf() + + // Offline grace: if nothing-at-all has responded in `threshold`, we're probably offline. + if (lastSeenAny != 0L && now - lastSeenAny > threshold) return persistentListOf() + + val flagged = mutableListOf() + for ((url, lists) in listMembership) { + // Skip relays not in any detection list (e.g. only in Blocked). + val detectionLists = lists.intersect(DETECTION_LISTS) + if (detectionLists.isEmpty()) continue + + val rec = records[url] ?: RelayHealthRecord() + if (rec.snoozedUntil > now) continue + + val gap = now - rec.lastSeenAt() + if (gap <= threshold) continue + + flagged.add( + UnhealthyRelay( + url = url, + lastConnectAt = rec.lastConnectAt, + lastIncomingAt = rec.lastIncomingAt, + lists = lists.toPersistentSet(), + ), + ) + } + return flagged.toPersistentList() +} + +fun emptyHealthLists(): Set = persistentSetOf() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthListener.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthListener.kt new file mode 100644 index 0000000000..b90d9e1502 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthListener.kt @@ -0,0 +1,62 @@ +/* + * 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.commons.relays.health + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Wires the relay-network lifecycle into [RelayHealthStore]. Install once per process via + * [installInto] using the same INostrClient that drives the rest of the app. + * + * The listener runs on relay-network threads; [RelayHealthStore.recordConnect] and + * [RelayHealthStore.recordIncoming] are non-suspending and safe from any thread. + */ +class RelayHealthListener( + private val store: RelayHealthStore, +) : RelayConnectionListener { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + store.recordConnect(relay.url, TimeUtils.now()) + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + store.recordIncoming(relay.url, TimeUtils.now()) + } + + fun installInto(client: INostrClient) { + client.addConnectionListener(this) + } + + fun uninstallFrom(client: INostrClient) { + client.removeConnectionListener(this) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthPersistence.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthPersistence.kt new file mode 100644 index 0000000000..e16f6779f2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthPersistence.kt @@ -0,0 +1,62 @@ +/* + * 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.commons.relays.health + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.concurrent.Volatile + +/** + * Snapshot persisted to disk. Decoupled from the in-memory store so the same + * persistence backend can swap (e.g. Android AccountSettings JSON vs Desktop + * java.util.prefs.Preferences) without rippling into commons. + */ +data class RelayHealthSnapshot( + val records: Map = emptyMap(), + val firstScanAt: Long = 0, + val lastSeenAny: Long = 0, +) + +/** + * Per-account durable storage for relay health timestamps. + * + * Implementations must be safe to call from a background dispatcher. + * Loads/saves are best-effort — corrupted state should be returned as + * an empty snapshot so the store can re-bootstrap and the user simply + * gets a fresh 7-day grace window. + */ +interface RelayHealthPersistence { + fun load(): RelayHealthSnapshot + + fun save(snapshot: RelayHealthSnapshot) +} + +/** No-op fallback for tests and CLI-style hosts that don't persist anything. */ +class InMemoryRelayHealthPersistence( + initial: RelayHealthSnapshot = RelayHealthSnapshot(), +) : RelayHealthPersistence { + @Volatile private var current: RelayHealthSnapshot = initial + + override fun load(): RelayHealthSnapshot = current + + override fun save(snapshot: RelayHealthSnapshot) { + current = snapshot + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthRecord.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthRecord.kt new file mode 100644 index 0000000000..e6bb9f30ca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthRecord.kt @@ -0,0 +1,34 @@ +/* + * 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.commons.relays.health + +/** + * Durable per-relay health timestamps (epoch seconds). 0 = never observed. + * + * `snoozedUntil` is set by the UI when the user dismisses a flag. + */ +data class RelayHealthRecord( + val lastConnectAt: Long = 0, + val lastIncomingAt: Long = 0, + val snoozedUntil: Long = 0, +) { + fun lastSeenAt(): Long = maxOf(lastConnectAt, lastIncomingAt) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStore.kt new file mode 100644 index 0000000000..5bf6623393 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStore.kt @@ -0,0 +1,233 @@ +/* + * 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.commons.relays.health + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Per-account, durable record of relay liveness used to drive the "unhealthy relay" review UI. + * + * Records are fed in from the quartz relay listener (via [recordIncoming] / [recordConnect]) + * and the user's list-membership StateFlow (via [setListMembership]). The classifier runs: + * - whenever inputs change, OR + * - once every [TICK_SECONDS] so snoozes expire and lastSeenAny stays fresh. + * + * Persistence writes are debounced [PERSIST_DEBOUNCE_MS]ms. + * + * Lifecycle: the host (Android Account wiring / Desktop App() scope) is responsible + * for calling [close] when switching accounts so the internal scope cancels. + */ +class RelayHealthStore( + private val persistence: RelayHealthPersistence, + private val torEnabledProvider: () -> Boolean = { false }, + parentScope: CoroutineScope? = null, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.Default, +) { + companion object { + const val PERSIST_DEBOUNCE_MS: Long = 5_000L + const val TICK_SECONDS: Long = 60L + } + + private val scope: CoroutineScope = + parentScope ?: CoroutineScope(SupervisorJob() + ioDispatcher) + + private val ownsScope = parentScope == null + + private val state = + MutableStateFlow( + persistence.load().let { loaded -> + // First-time observation: stamp firstScanAt now so newcomer-grace starts ticking. + if (loaded.firstScanAt == 0L) { + loaded.copy(firstScanAt = TimeUtils.now()) + } else { + loaded + } + }, + ) + + private val listMembership = MutableStateFlow>>(emptyMap()) + + private val _unhealthy = MutableStateFlow>(persistentListOf()) + val unhealthy: StateFlow> = _unhealthy.asStateFlow() + + private var persistJob: Job? = null + private var tickJob: Job? = null + + init { + // Persist the firstScanAt seed if we just stamped it. + schedulePersist() + + tickJob = + scope.launch { + while (true) { + reclassify() + delay(TICK_SECONDS * 1_000) + } + } + } + + /** Called from RelayConnectionListener.onIncomingMessage. Non-suspending. */ + fun recordIncoming( + url: NormalizedRelayUrl, + atSeconds: Long = TimeUtils.now(), + ) { + var changed = false + state.update { s -> + val now = atSeconds + val rec = s.records[url] ?: RelayHealthRecord() + // Bump only on real progress to avoid noise. + if (rec.lastIncomingAt >= now) return@update s + changed = true + val newRec = rec.copy(lastIncomingAt = now) + s.copy( + records = s.records + (url to newRec), + lastSeenAny = maxOf(s.lastSeenAny, now), + ) + } + if (changed) { + reclassifyAsync() + schedulePersist() + } + } + + /** Called from RelayConnectionListener.onConnected. Non-suspending. */ + fun recordConnect( + url: NormalizedRelayUrl, + atSeconds: Long = TimeUtils.now(), + ) { + var changed = false + state.update { s -> + val now = atSeconds + val rec = s.records[url] ?: RelayHealthRecord() + if (rec.lastConnectAt >= now) return@update s + changed = true + val newRec = rec.copy(lastConnectAt = now) + s.copy( + records = s.records + (url to newRec), + lastSeenAny = maxOf(s.lastSeenAny, now), + ) + } + if (changed) { + reclassifyAsync() + schedulePersist() + } + } + + /** Called by the UI when the user's monitored relay lists change. */ + fun setListMembership(membership: Map>) { + listMembership.value = membership + reclassifyAsync() + } + + /** Per-relay snooze (default 7d). */ + fun snooze( + url: NormalizedRelayUrl, + untilSeconds: Long = TimeUtils.now() + RELAY_HEALTH_THRESHOLD_SECONDS, + ) { + state.update { s -> + val rec = s.records[url] ?: RelayHealthRecord() + s.copy(records = s.records + (url to rec.copy(snoozedUntil = untilSeconds))) + } + reclassifyAsync() + schedulePersist() + } + + /** Snooze every relay currently flagged. */ + fun snoozeAllCurrent(untilSeconds: Long = TimeUtils.now() + RELAY_HEALTH_THRESHOLD_SECONDS) { + val urls = _unhealthy.value.map { it.url } + if (urls.isEmpty()) return + state.update { s -> + val updates = + urls.associateWith { url -> + (s.records[url] ?: RelayHealthRecord()).copy(snoozedUntil = untilSeconds) + } + s.copy(records = s.records + updates) + } + reclassifyAsync() + schedulePersist() + } + + /** Run once at app start. Equivalent to a `recordIncoming`-driven reclassification. */ + fun scanNow() { + reclassifyAsync() + } + + private fun reclassifyAsync() { + scope.launch { reclassify() } + } + + private suspend fun reclassify() { + val s = state.value + val membership = listMembership.value + val torEnabled = torEnabledProvider() + val now = TimeUtils.now() + + // Allow lastSeenAny to "look offline" only if there are records at all. + val effectiveLastSeenAny = if (s.lastSeenAny == 0L) now else s.lastSeenAny + + val flagged = + withContext(ioDispatcher) { + classifyRelayHealth( + records = s.records, + listMembership = membership, + firstScanAt = s.firstScanAt, + lastSeenAny = effectiveLastSeenAny, + torEnabled = torEnabled, + now = now, + ) + } + _unhealthy.value = flagged + } + + private fun schedulePersist() { + persistJob?.cancel() + persistJob = + scope.launch { + delay(PERSIST_DEBOUNCE_MS) + val snapshot = state.value + runCatching { persistence.save(snapshot) } + } + } + + fun close() { + // Flush pending writes synchronously before tearing down. + persistJob?.cancel() + runCatching { persistence.save(state.value) } + tickJob?.cancel() + if (ownsScope) scope.cancel() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListKind.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListKind.kt new file mode 100644 index 0000000000..a77a3e1622 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListKind.kt @@ -0,0 +1,34 @@ +/* + * 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.commons.relays.health + +import androidx.compose.runtime.Immutable + +/** User-managed relay list kinds we consider for the health-review feature. */ +@Immutable +enum class RelayListKind( + val kind: Int, +) { + Nip65(10002), + DmInbox(10050), + Search(10007), + Blocked(10006), +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListMutator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListMutator.kt new file mode 100644 index 0000000000..7143077fbe --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListMutator.kt @@ -0,0 +1,57 @@ +/* + * 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.commons.relays.health + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** Result of attempting to remove a relay from every list it currently appears in. */ +sealed interface RelayRemovalResult { + /** All targeted lists were successfully edited + broadcast (or had no entry to remove). */ + data object Success : RelayRemovalResult + + /** Some lists succeeded, others failed (e.g. signer timeout). */ + data class Partial( + val failedLists: Set, + ) : RelayRemovalResult + + /** Nothing could be persisted (account locked, signer unavailable, etc.). */ + data class Failure( + val message: String?, + ) : RelayRemovalResult +} + +/** + * Platform-specific: edits the user's NIP-65-style relay lists and broadcasts the new versions. + * + * Android impl delegates to Account.send*RelayList methods. + * Desktop impl delegates to commons *State.saveRelayList + relayManager.broadcastToAll. + * + * `removeFromAllUserLists` must issue sign requests in parallel (multi-list users on a slow + * NIP-46 bunker otherwise wait N*RTT). + */ +interface RelayListMutator { + suspend fun removeFromAllUserLists(url: NormalizedRelayUrl): RelayRemovalResult +} + +/** No-op for previews/tests/headless hosts that don't actually mutate. */ +class NoopRelayListMutator : RelayListMutator { + override suspend fun removeFromAllUserLists(url: NormalizedRelayUrl): RelayRemovalResult = RelayRemovalResult.Success +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/UnhealthyRelay.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/UnhealthyRelay.kt new file mode 100644 index 0000000000..8e604f2b5d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/UnhealthyRelay.kt @@ -0,0 +1,37 @@ +/* + * 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.commons.relays.health + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.collections.immutable.PersistentSet + +/** + * One row in the unhealthy-relay list. Carries the originating timestamps so the UI can + * show "last seen 9d ago" without re-deriving from the store. + */ +@Immutable +data class UnhealthyRelay( + val url: NormalizedRelayUrl, + val lastConnectAt: Long, + val lastIncomingAt: Long, + val lists: PersistentSet, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayBanner.kt new file mode 100644 index 0000000000..fa70e267f2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayBanner.kt @@ -0,0 +1,98 @@ +/* + * 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.commons.relays.health.ui + +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.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 + +/** + * Soft warning banner. Color: `errorContainer @ 50% alpha` to differentiate from the + * full-strength offline banner (matches the `ChessSyncBanner` / `ProfileBroadcastBanner` + * conventions for non-fatal warnings). + * + * Caller passes pre-resolved text since commons does not yet have a `` setup — + * Android & Desktop each format their own count strings. + */ +@Composable +fun UnhealthyRelayBanner( + visible: Boolean, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + modifier = modifier, + ) { + val containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + val contentColor = MaterialTheme.colorScheme.onErrorContainer + + Surface( + color = containerColor, + contentColor = contentColor, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = contentColor, + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = contentColor, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayRow.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayRow.kt new file mode 100644 index 0000000000..9a4e1e0a62 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ui/UnhealthyRelayRow.kt @@ -0,0 +1,147 @@ +/* + * 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.commons.relays.health.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +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.commons.relays.health.RelayListKind +import com.vitorpamplona.amethyst.commons.relays.health.UnhealthyRelay + +/** + * Single row inside the unhealthy-relays sheet / popup. Carries three actions: + * - Remove (destructive, no confirmation) + * - Open in Relay Dashboard + * - Snooze 7d + * + * Caller resolves all human-readable strings (last-seen text, list-kind labels) + * so platform-specific plural/i18n logic stays out of commons. + */ +@Composable +fun UnhealthyRelayRow( + relay: UnhealthyRelay, + lastSeenLabel: String, + listKindLabel: (RelayListKind) -> String, + removeLabel: String, + openLabel: String, + snoozeLabel: String, + onRemove: () -> Unit, + onOpenDashboard: () -> Unit, + onSnooze: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = relay.url.url, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = lastSeenLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (relay.lists.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + relay.lists.forEach { kind -> + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Text( + text = listKindLabel(kind), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton(onClick = onSnooze) { + Icon( + symbol = MaterialSymbols.Schedule, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text( + text = snoozeLabel, + modifier = Modifier.padding(start = 4.dp), + style = MaterialTheme.typography.labelMedium, + ) + } + TextButton(onClick = onOpenDashboard) { + Icon( + symbol = MaterialSymbols.AutoMirrored.OpenInNew, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text( + text = openLabel, + modifier = Modifier.padding(start = 4.dp), + style = MaterialTheme.typography.labelMedium, + ) + } + TextButton( + onClick = onRemove, + colors = + androidx.compose.material3.ButtonDefaults + .textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text( + text = removeLabel, + modifier = Modifier.padding(start = 4.dp), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealthTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealthTest.kt new file mode 100644 index 0000000000..12e3ca4741 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealthTest.kt @@ -0,0 +1,172 @@ +/* + * 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.commons.relays.health + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ClassifyRelayHealthTest { + private val now: Long = 1_700_000_000L + private val week: Long = TimeUtils.ONE_WEEK.toLong() + private val dead = RelayUrlNormalizer.normalizeOrNull("wss://dead.example.com")!! + private val alive = RelayUrlNormalizer.normalizeOrNull("wss://alive.example.com")!! + + @Test + fun firstRunQuiet_noFlagsWithinSevenDaysOfFirstScan() { + val out = + classifyRelayHealth( + records = mapOf(dead to RelayHealthRecord(0, 0, 0)), + listMembership = mapOf(dead to setOf(RelayListKind.Nip65)), + firstScanAt = now - 60, + lastSeenAny = now - 60, + torEnabled = false, + now = now, + ) + assertEquals(0, out.size) + } + + @Test + fun torModeSkipsClassificationEntirely() { + val out = + classifyRelayHealth( + records = mapOf(dead to RelayHealthRecord(0, 0, 0)), + listMembership = mapOf(dead to setOf(RelayListKind.Nip65)), + firstScanAt = now - 2 * week, + lastSeenAny = now - 60, + torEnabled = true, + now = now, + ) + assertEquals(0, out.size) + } + + @Test + fun offlineGraceSuppressesFlagsWhenNoRelayRespondedRecently() { + val out = + classifyRelayHealth( + records = mapOf(dead to RelayHealthRecord(0, 0, 0)), + listMembership = mapOf(dead to setOf(RelayListKind.Nip65)), + firstScanAt = now - 2 * week, + lastSeenAny = now - 2 * week, + torEnabled = false, + now = now, + ) + assertEquals(0, out.size) + } + + @Test + fun deadRelayInMonitoredListIsFlagged() { + val out = + classifyRelayHealth( + records = + mapOf( + dead to RelayHealthRecord(0, 0, 0), + alive to RelayHealthRecord(now - 60, now - 30, 0), + ), + listMembership = + mapOf( + dead to setOf(RelayListKind.Nip65), + alive to setOf(RelayListKind.Nip65), + ), + firstScanAt = now - 2 * week, + lastSeenAny = now - 60, + torEnabled = false, + now = now, + ) + assertEquals(1, out.size) + assertEquals(dead, out[0].url) + assertTrue(out[0].lists.contains(RelayListKind.Nip65)) + } + + @Test + fun snoozedRelayIsHiddenUntilSnoozeExpires() { + val out = + classifyRelayHealth( + records = mapOf(dead to RelayHealthRecord(0, 0, snoozedUntil = now + 60)), + listMembership = mapOf(dead to setOf(RelayListKind.Nip65)), + firstScanAt = now - 2 * week, + lastSeenAny = now - 60, + torEnabled = false, + now = now, + ) + assertEquals(0, out.size) + + val outAfter = + classifyRelayHealth( + records = mapOf(dead to RelayHealthRecord(0, 0, snoozedUntil = now - 60)), + listMembership = mapOf(dead to setOf(RelayListKind.Nip65)), + firstScanAt = now - 2 * week, + lastSeenAny = now - 60, + torEnabled = false, + now = now, + ) + assertEquals(1, outAfter.size) + } + + @Test + fun blockedOnlyRelaysAreNotFlagged() { + val out = + classifyRelayHealth( + records = mapOf(dead to RelayHealthRecord(0, 0, 0)), + listMembership = mapOf(dead to setOf(RelayListKind.Blocked)), + firstScanAt = now - 2 * week, + lastSeenAny = now - 60, + torEnabled = false, + now = now, + ) + assertEquals(0, out.size) + } + + @Test + fun multiListMembershipPreservedInOutput() { + val out = + classifyRelayHealth( + records = mapOf(dead to RelayHealthRecord(0, 0, 0)), + listMembership = + mapOf( + dead to setOf(RelayListKind.Nip65, RelayListKind.DmInbox, RelayListKind.Blocked), + ), + firstScanAt = now - 2 * week, + lastSeenAny = now - 60, + torEnabled = false, + now = now, + ) + assertEquals(1, out.size) + assertEquals(3, out[0].lists.size) + assertTrue(out[0].lists.contains(RelayListKind.Blocked)) + } + + @Test + fun recentlySeenRelaysAreNotFlagged() { + val out = + classifyRelayHealth( + records = mapOf(dead to RelayHealthRecord(now - 60, 0, 0)), + listMembership = mapOf(dead to setOf(RelayListKind.Nip65)), + firstScanAt = now - 2 * week, + lastSeenAny = now - 60, + torEnabled = false, + now = now, + ) + assertEquals(0, out.size) + } +} 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 fe9d78fa6d..e768c9ee95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1427,11 +1427,68 @@ fun MainContent( val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current + // Relay-health store: per-account, persists liveness + snooze; installs a + // RelayConnectionListener so quartz lifecycle drives the timestamps. + val torStateForHealth = com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState.current + val relayHealthStore = + remember(account.pubKeyHex) { + com.vitorpamplona.amethyst.commons.relays.health.RelayHealthStore( + persistence = + com.vitorpamplona.amethyst.desktop.model.PreferencesRelayHealthPersistence( + userPubKeyHex = account.pubKeyHex, + ), + torEnabledProvider = { + torStateForHealth.settings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF + }, + parentScope = scope, + ) + } + DisposableEffect(relayHealthStore, relayManager) { + val listener = + com.vitorpamplona.amethyst.commons.relays.health + .RelayHealthListener(relayHealthStore) + listener.installInto(relayManager.client) + onDispose { + listener.uninstallFrom(relayManager.client) + relayHealthStore.close() + } + } + // Build the relay-list mutator once per account (uses signer + the per-list states). + val relayListMutator = + remember(iAccount, accountRelays, relayManager) { + com.vitorpamplona.amethyst.desktop.model.DesktopRelayListMutator( + signer = iAccount.signer, + nip65State = iAccount.nip65RelayList, + accountRelays = accountRelays, + relayManager = relayManager, + ) + } + // Push list-membership changes into the store so the classifier knows which relays count. + LaunchedEffect(relayHealthStore, iAccount.nip65RelayList, accountRelays) { + kotlinx.coroutines.flow + .combine( + iAccount.nip65RelayList.allFlowNoDefaults, + accountRelays.dmRelayList, + accountRelays.searchRelayList, + accountRelays.blockedRelayList, + ) { nip65, dm, search, blocked -> + com.vitorpamplona.amethyst.desktop.model + .computeListMembership(nip65, dm, search, blocked) + }.collect { membership -> + relayHealthStore.setListMembership(membership) + } + } + LaunchedEffect(relayHealthStore) { + relayHealthStore.scanNow() + } + CompositionLocalProvider( LocalRelayCategories provides relayCategories, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, + com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayHealthStore provides relayHealthStore, + com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayListMutator provides relayListMutator, ) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayListMutator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayListMutator.kt new file mode 100644 index 0000000000..d704b2b7d6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayListMutator.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.model + +import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.amethyst.commons.relays.health.RelayListKind +import com.vitorpamplona.amethyst.commons.relays.health.RelayListMutator +import com.vitorpamplona.amethyst.commons.relays.health.RelayRemovalResult +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +/** + * Desktop implementation of [RelayListMutator]. Edits the user's NIP-65-style + * relay lists in parallel (one signing+broadcast op per kind the relay is in), + * then re-publishes via the connection manager so connected relays see the new + * versions. + * + * Sign requests run in parallel via async/awaitAll so that a slow NIP-46 bunker + * doesn't multiply latency by 4 lists. + */ +class DesktopRelayListMutator( + private val signer: NostrSigner, + private val nip65State: Nip65RelayListState, + private val accountRelays: DesktopAccountRelays, + private val relayManager: RelayConnectionManager, +) : RelayListMutator { + override suspend fun removeFromAllUserLists(url: NormalizedRelayUrl): RelayRemovalResult = + coroutineScope { + val failed = mutableSetOf() + val attempts = mutableListOf() + + val jobs = mutableListOf>() + + val nip65Current = + nip65State + .getNIP65RelayList() + ?.relays() + ?.toMutableList() + if (nip65Current != null && nip65Current.any { it.relayUrl == url }) { + attempts += RelayListKind.Nip65 + jobs += + async { + runCatching { + val newRelays = nip65Current.filterNot { it.relayUrl == url } + val event = nip65State.saveRelayList(newRelays) + relayManager.broadcastToAll(event) + }.fold(onSuccess = { null }, onFailure = { RelayListKind.Nip65 }) + } + } + + val dmRelays = accountRelays.dmRelayList.value + if (url in dmRelays) { + attempts += RelayListKind.DmInbox + jobs += + async { + runCatching { + val newRelays = (dmRelays - url).toList() + val event = ChatMessageRelayListEvent.create(newRelays, signer) + accountRelays.consumePublishedEvent(event) + relayManager.broadcastToAll(event) + }.fold(onSuccess = { null }, onFailure = { RelayListKind.DmInbox }) + } + } + + val searchRelays = accountRelays.searchRelayList.value + if (url in searchRelays) { + attempts += RelayListKind.Search + jobs += + async { + runCatching { + val newRelays = (searchRelays - url).toList() + val event = SearchRelayListEvent.create(newRelays, signer) + accountRelays.consumePublishedEvent(event) + relayManager.broadcastToAll(event) + }.fold(onSuccess = { null }, onFailure = { RelayListKind.Search }) + } + } + + val blockedRelays = accountRelays.blockedRelayList.value + if (url in blockedRelays) { + attempts += RelayListKind.Blocked + jobs += + async { + runCatching { + val newRelays = (blockedRelays - url).toList() + val event = BlockedRelayListEvent.create(newRelays, signer) + accountRelays.consumePublishedEvent(event) + relayManager.broadcastToAll(event) + }.fold(onSuccess = { null }, onFailure = { RelayListKind.Blocked }) + } + } + + if (attempts.isEmpty()) return@coroutineScope RelayRemovalResult.Success + jobs.awaitAll().filterNotNull().forEach { failed += it } + + when { + failed.isEmpty() -> RelayRemovalResult.Success + failed.size == attempts.size -> RelayRemovalResult.Failure("All lists failed to publish") + else -> RelayRemovalResult.Partial(failed) + } + } +} + +/** Compute which monitored lists each relay currently lives in. */ +fun computeListMembership( + nip65: Set, + dm: Set, + search: Set, + blocked: Set, +): Map> { + val all = nip65 + dm + search + blocked + return all.associateWith { url -> + buildSet { + if (url in nip65) add(RelayListKind.Nip65) + if (url in dm) add(RelayListKind.DmInbox) + if (url in search) add(RelayListKind.Search) + if (url in blocked) add(RelayListKind.Blocked) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/PreferencesRelayHealthPersistence.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/PreferencesRelayHealthPersistence.kt new file mode 100644 index 0000000000..e04301ff69 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/PreferencesRelayHealthPersistence.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.model + +import com.vitorpamplona.amethyst.commons.relays.health.RelayHealthPersistence +import com.vitorpamplona.amethyst.commons.relays.health.RelayHealthRecord +import com.vitorpamplona.amethyst.commons.relays.health.RelayHealthSnapshot +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import java.util.prefs.Preferences + +/** + * Desktop-side persistence for the relay-health feature. Backed by the same + * `java.util.prefs.Preferences` node DesktopAccountRelays uses (different keys); + * scoped per-account via the 8-char pubkey prefix that matches the rest of the + * desktop persistence layer. + * + * Storage shape (single string value, line-delimited): + * first scan timestamp (seconds) + * last-seen-any timestamp (seconds) + * relay\tlastConnect\tlastIncoming\tsnoozedUntil + * ... + * + * 8 KB Preferences ceiling is generous (~150 relays at 50 B/row); if a user + * has more, the tail is dropped silently and the classifier just sees fewer + * records — no functional break. + */ +class PreferencesRelayHealthPersistence( + private val userPubKeyHex: HexKey, +) : RelayHealthPersistence { + private val prefs = Preferences.userNodeForPackage(PreferencesRelayHealthPersistence::class.java) + + private val storageKey: String = "health_${userPubKeyHex.take(8)}" + + override fun load(): RelayHealthSnapshot { + return try { + val raw = prefs.get(storageKey, null) ?: return RelayHealthSnapshot() + val lines = raw.split('\n') + if (lines.size < 2) return RelayHealthSnapshot() + val firstScanAt = lines[0].toLongOrNull() ?: 0L + val lastSeenAny = lines[1].toLongOrNull() ?: 0L + val records = + buildMap { + for (i in 2 until lines.size) { + val parts = lines[i].split('\t') + if (parts.size < 4) continue + val url = RelayUrlNormalizer.normalizeOrNull(parts[0]) ?: continue + val rec = + RelayHealthRecord( + lastConnectAt = parts[1].toLongOrNull() ?: 0L, + lastIncomingAt = parts[2].toLongOrNull() ?: 0L, + snoozedUntil = parts[3].toLongOrNull() ?: 0L, + ) + put(url, rec) + } + } + RelayHealthSnapshot(records, firstScanAt, lastSeenAny) + } catch (_: Exception) { + RelayHealthSnapshot() + } + } + + override fun save(snapshot: RelayHealthSnapshot) { + try { + val sb = StringBuilder() + sb.append(snapshot.firstScanAt).append('\n') + sb.append(snapshot.lastSeenAny) + for ((url, rec) in snapshot.records) { + sb.append('\n') + sb.append(url.url).append('\t') + sb.append(rec.lastConnectAt).append('\t') + sb.append(rec.lastIncomingAt).append('\t') + sb.append(rec.snoozedUntil) + if (sb.length > MAX_PREFS_VALUE_LENGTH) break + } + prefs.put(storageKey, sb.toString().take(MAX_PREFS_VALUE_LENGTH)) + prefs.flush() + } catch (_: Exception) { + } + } + + companion object { + private const val MAX_PREFS_VALUE_LENGTH = 8000 + } +} 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 7b673bf0e5..9bb7ea8d41 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 @@ -197,6 +197,11 @@ fun DeckColumnContainer( hasLocalData = hasLocalData, ) + // Unhealthy relays banner — flags relays unresponsive >7d, opens review popup + com.vitorpamplona.amethyst.desktop.ui.relay.health.UnhealthyRelayBannerHost( + onOpenDashboard = onNavigateToRelays, + ) + // Content runs edge-to-edge; each screen adds its own header padding Box(modifier = Modifier.fillMaxSize()) { // Always keep RootContent composed so state survives navigation 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 d05b487306..9e57736ae5 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 @@ -85,3 +85,13 @@ val LocalLocalRelayStore = compositionLocalOf { null } + +val LocalRelayHealthStore = + compositionLocalOf { + null + } + +val LocalRelayListMutator = + 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 29e7779a3b..65517a0d8b 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 @@ -102,6 +102,11 @@ fun SinglePaneLayout( hasLocalData = hasLocalData, ) + // Unhealthy relays banner — flags relays unresponsive >7d, opens review popup + com.vitorpamplona.amethyst.desktop.ui.relay.health.UnhealthyRelayBannerHost( + onOpenDashboard = { singlePaneState.navigate(DeckColumnType.Relays) }, + ) + // 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/relay/health/UnhealthyRelayBannerHost.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelayBannerHost.kt new file mode 100644 index 0000000000..d2bd172b06 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelayBannerHost.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.ui.relay.health + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.relays.health.ui.UnhealthyRelayBanner +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayHealthStore +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayListMutator + +/** + * Wraps the shared [UnhealthyRelayBanner] with desktop-specific count formatting + * and the popup that opens when the banner is tapped. Renders nothing when the + * health store is not provided (e.g. before login). + */ +@Composable +fun UnhealthyRelayBannerHost( + onOpenDashboard: () -> Unit, + onShowMessage: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val store = LocalRelayHealthStore.current ?: return + val mutator = LocalRelayListMutator.current ?: return + + val unhealthy by store.unhealthy.collectAsState() + val countText by remember { + derivedStateOf { + val n = unhealthy.size + if (n == 1) { + "1 relay unresponsive — Review" + } else { + "$n relays unresponsive — Review" + } + } + } + + var popupOpen by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + UnhealthyRelayBanner( + visible = unhealthy.isNotEmpty(), + text = countText, + onClick = { popupOpen = true }, + ) + if (popupOpen) { + UnhealthyRelaysPopup( + store = store, + mutator = mutator, + onDismiss = { popupOpen = false }, + onOpenDashboard = onOpenDashboard, + onShowMessage = onShowMessage, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelaysPopup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelaysPopup.kt new file mode 100644 index 0000000000..2e14ec43a7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelaysPopup.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.relay.health + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.vitorpamplona.amethyst.commons.relays.health.RelayHealthStore +import com.vitorpamplona.amethyst.commons.relays.health.RelayListKind +import com.vitorpamplona.amethyst.commons.relays.health.RelayListMutator +import com.vitorpamplona.amethyst.commons.relays.health.RelayRemovalResult +import com.vitorpamplona.amethyst.commons.relays.health.ui.UnhealthyRelayRow +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.launch + +/** + * Desktop-style popup that lists currently-unhealthy relays. Anchored at top-center, + * dismissable on outside click; per-row Remove / Open / Snooze plus a footer "Snooze all". + */ +@Composable +fun UnhealthyRelaysPopup( + store: RelayHealthStore, + mutator: RelayListMutator, + onDismiss: () -> Unit, + onOpenDashboard: () -> Unit, + onShowMessage: (String) -> Unit = {}, +) { + val unhealthy by store.unhealthy.collectAsState() + val coScope = rememberCoroutineScope() + + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, 32), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + ElevatedCard( + modifier = + Modifier + .widthIn(min = 420.dp, max = 560.dp) + .heightIn(max = 560.dp), + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Unresponsive relays", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { + store.snoozeAllCurrent() + onShowMessage("Snoozed all for 7 days") + onDismiss() + }) { + Text("Snooze all 7d", style = MaterialTheme.typography.labelMedium) + } + } + Text( + text = "These relays haven't responded in over 7 days. Removing them publishes new relay-list events.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + unhealthy.forEach { relay -> + UnhealthyRelayRow( + relay = relay, + lastSeenLabel = lastSeenLabel(relay.lastIncomingAt, relay.lastConnectAt), + listKindLabel = ::desktopListKindLabel, + removeLabel = "Remove", + openLabel = "Dashboard", + snoozeLabel = "Snooze 7d", + onRemove = { + val url = relay.url + coScope.launch { + val result = mutator.removeFromAllUserLists(url) + when (result) { + is RelayRemovalResult.Success -> + onShowMessage("Removed ${url.url}") + is RelayRemovalResult.Partial -> + onShowMessage("Removed from some lists; ${result.failedLists.joinToString { it.name }} failed") + is RelayRemovalResult.Failure -> + onShowMessage("Remove failed: ${result.message ?: "unknown"}") + } + } + }, + onOpenDashboard = { + onDismiss() + onOpenDashboard() + }, + onSnooze = { + store.snooze(relay.url) + onShowMessage("Snoozed for 7 days") + }, + ) + HorizontalDivider() + } + } + } + } +} + +private fun desktopListKindLabel(kind: RelayListKind): String = + when (kind) { + RelayListKind.Nip65 -> "Read/Write" + RelayListKind.DmInbox -> "DMs" + RelayListKind.Search -> "Search" + RelayListKind.Blocked -> "Blocked" + } + +private fun lastSeenLabel( + lastIncomingAt: Long, + lastConnectAt: Long, +): String { + val ts = maxOf(lastIncomingAt, lastConnectAt) + if (ts == 0L) return "Never seen" + val gapSec = TimeUtils.now() - ts + val days = gapSec / TimeUtils.ONE_DAY + return when { + days < 1 -> "Last seen <1d ago" + else -> "Last seen ${days}d ago" + } +} diff --git a/docs/plans/2026-06-10-feat-unhealthy-relay-review-plan.md b/docs/plans/2026-06-10-feat-unhealthy-relay-review-plan.md new file mode 100644 index 0000000000..306e892eef --- /dev/null +++ b/docs/plans/2026-06-10-feat-unhealthy-relay-review-plan.md @@ -0,0 +1,373 @@ +--- +title: Unhealthy Relay Review (banner + sheet/popover) +type: feat +status: active +date: 2026-06-10 +origin: docs/brainstorms/2026-06-10-unhealthy-relay-review-brainstorm.md +--- + +# Unhealthy Relay Review + +## Enhancement Summary (deepen-plan, 2026-06-10) + +Seven parallel review agents critiqued the original plan. The implementation below follows the refined design; the original sections are kept for context but **superseded** where they conflict. + +### Critical fixes +1. **Bug — `firstSeenAt == 0L` newcomer-grace bypass**: drop the per-relay `firstSeenAt` field. Use a single global `firstScanAt` (set once at first store init for the account). Newcomer grace = "global firstScanAt within 7d ⇒ skip all flagging." +2. **Bug — `didScan by remember` doesn't reset on account switch**: key the LaunchedEffect on `pubKeyHex` so each account gets one scan; no separate guard needed. +3. **Bug — snooze never expires without a periodic ticker**: feed a `flow { while(true){emit(Unit); delay(60_000)} }` into `combine(records, userLists, ticker)` so the snapshot reclassifies once per minute and expired snoozes drop. +4. **Stability — `Set` is unstable**: use `kotlinx.collections.immutable.PersistentSet` and annotate the value types `@Immutable`. +5. **Visual — `tertiaryContainer` is purple in this theme** (`PlatformColorScheme.kt:50-53,88-91`): use `MaterialTheme.colorScheme.errorContainer.copy(alpha=0.5f)` (matches `ChessSyncBanner`, `ProfileBroadcastBanner` precedent for soft warnings). + +### Architecture changes +6. **Drop `expect class RelayHealthStore`**: replace with a single `class RelayHealthStore(persistence: RelayHealthPersistence, …)` in `commonMain`. Persistence interface lives in commonMain; platform impls (`AccountSettingsRelayHealthPersistence`, `PreferencesRelayHealthPersistence`) live in `amethyst/` and `desktopApp/` respectively. +7. **Drop `RelayListMutator` expect/actual**: define `interface RelayListMutator` in commons with `suspend fun removeFromAllUserLists(url): RemovalResult`. Android impl in `amethyst/` (delegates to `Account.send*RelayList`); Desktop impl in `desktopApp/` (delegates to `*State.saveRelayList` + `broadcastToAll`). Multi-list sign requests run in parallel via `async { … }.awaitAll()`. +8. **Package placement**: move `commons/.../relayhealth/` → `commons/.../relays/health/` per `ARCHITECTURE.md`. Files: `commons/.../relays/health/` (non-UI) + `commons/.../relays/health/ui/` (banner, row). +9. **Tor mode gap**: `TorRelayEvaluation` exists; v1 conservative behavior is to skip classification entirely whenever `TorSettings.torType != OFF`. Documented as a Risk row; v2 can be smarter. + +### Simplifications (YAGNI) +10. Drop `UnhealthyRelaysSnapshot` wrapper — emit `PersistentList` directly. Drop `slowCount` (no v1 reader). Drop `pruneRemovedRelays` (classifier already gates on "in user list"). Drop `observeRelay(url)` (no longer needed without `firstSeenAt`). Drop `RemovalPlan` (mutator returns `RemovalResult` directly). Drop 8KB Preferences fallback `.dat` file (speculative). + +### Performance +11. **Debounce 5s** for persistence (was 1s; timestamps are seconds-granularity). **Parallel** sign-requests on remove. `@Immutable` on all UI state classes. `derivedStateOf` for banner count read. `flowOn(Dispatchers.Default)` for `classifyRelayHealth`. + +### Other +12. **No `pluralStringResource` in commons** (zero precedent + zero `` entries). Pass pre-resolved count text into the shared banner; platforms format using their native plural infra. Banner accepts `text: String` + `onClick` only. +13. **`pubKeyHex.take(8)`** for keying (matches existing precedent ``; was 16 in original plan, inconsistent). +14. **`RelayHealthStore` lifecycle**: scope owned by store (`SupervisorJob + Dispatchers.Default`), cancelled on account switch via `close()`. One debouncer per store instance. + +## Overview + +Surface relays that have not been responsive in 7+ days so users can review and remove them in two taps, across Android and Desktop. UI = a persistent banner above the main content area whenever ≥1 unhealthy relay exists; tapping it opens a per-relay list (Android `ModalBottomSheet`, Desktop anchored `Popup`) with `Remove`, `Open Relay Dashboard`, and `Snooze 7d` actions, plus a banner-level `Snooze all 7d`. Detection runs once per app launch from persisted "last activity" timestamps. + +Scope is the user's NIP-65-style relay lists: kinds **10002** (read/write), **10050** (DMs), **10007** (search). **10006** (blocked) is intentionally excluded from detection but included in the "remove from all lists" action (see brainstorm: docs/brainstorms/2026-06-10-unhealthy-relay-review-brainstorm.md — deviation noted under Risks). + +## Problem Statement + +Users accumulate relays over time. When a relay goes offline permanently (operator shuts down, domain expires, infra rot), the client still tries to connect, wasting connection budget, polluting metrics, and silently degrading event reach. The existing Relay Dashboard exposes per-relay state but requires the user to *go look* — there is no proactive prompt to clean up dead entries. Result: stale relay lists drift indefinitely. + +## Proposed Solution + +A non-modal banner that appears whenever any relay in the user's monitored lists has been silent for ≥ 7 days, with a one-tap drill-in surface that turns the maintenance task into a couple of taps. The detection layer extends `RelayStat` with two new timestamps; persistence mirrors existing account-scoped patterns per platform; the banner reuses `OfflineBanner`'s structure; the sheet/popover reuses existing templates (`AddToCalendarSheet` on Android, `Popup` from `NoteActions.kt` on Desktop). + +## Technical Approach + +### Architecture + +``` +quartz/ commons/ amethyst/ desktopApp/ +───────── ────────────────────────── ────────────── ────────────── +RelayStat (extend) RelayHealthStore (new, expect/actual) HomeScaffold (wire) DeckColumnContainer (wire) + ↓ updates from ├── classify(): UnhealthyRelay set ├── UnhealthyBanner ├── UnhealthyBanner +RelayStats listener ├── snooze APIs │ (commons) │ (commons) + ├── persists to disk via actual ├── UnhealthyRelaySheet ├── UnhealthyRelaysPopup + │ ├── jvmMain → java.util.prefs │ (Android-specific) │ (Desktop-specific) + │ └── androidMain → AccountSettings └── nav to EditRelays └── set DeckColumnType.Relays + └── RemoveFromAllLists helper + (uses *State.saveRelayList APIs) +``` + +Three layers: + +1. **Tracking layer (quartz)** — extend `RelayStat` with `lastEventAt` and `lastConnectAt`. Wire from existing `RelayStats` listener (already has `onConnected` + `onIncomingMessage` taps). +2. **Health state layer (commons)** — new `RelayHealthStore` (`expect class` with `actual` on Android via `AccountSettings`, on Desktop via `java.util.prefs.Preferences`). Owns persistence, classification (`classify(relayList): Set`), and snooze map. Exposes `StateFlow`. +3. **UI layer** — banner shared in `commons/`, sheet/popover platform-specific, wired into the existing scaffold slots. + +### Detection algorithm (v1) + +A relay `R` is **unhealthy** iff **all** of: + +1. `R` appears in at least one of the *monitored* user lists: kinds 10002, 10050, 10007. (10006 excluded.) +2. `now - max(R.lastEventAt, R.lastConnectAt) > 7d`. +3. `now - lastSeenAny > 7d` is **false** — i.e. at least one relay in the user's set responded within the last 7d (offline-grace gate). +4. `R` was first seen in any list at least 7d ago (newcomer grace). +5. `R.snoozedUntil < now`. + +**Removed from v1 (require new tracking infra):** + +- "Persistent errors" — needs a windowed error counter. RelayStat's `errorCounter` is lifetime-cumulative; can't distinguish "30 errors yesterday" from "30 errors over 2 years." +- "No EOSE / high latency" — EOSE is per-subscription, not per-relay. `pingInMs` is tracked but slow ≠ dead; surfacing it in the banner would be noisy. Both deferred to v2; see Future Considerations. + +(See brainstorm: docs/brainstorms/2026-06-10-unhealthy-relay-review-brainstorm.md — brainstorm listed all four signals; v1 reduces to the two timestamp-based ones for honesty + simplicity. Documented as v1 scope.) + +### Persistence schema + +Per-account, keyed by `NormalizedRelayUrl`: + +| Field | Type | Purpose | +|---|---|---| +| `lastEventAt` | `Long` (epoch ms) | Updated on `EventMessage` received from this relay. | +| `lastConnectAt` | `Long` (epoch ms) | Updated on `onConnected` for this relay. | +| `firstSeenAt` | `Long` (epoch ms) | First time we observed this relay in any user list. Newcomer-grace gate. | +| `snoozedUntil` | `Long` (epoch ms) | `0` = not snoozed. | + +Plus a single global `lastSeenAny: Long` for offline-grace. + +**Desktop** (`actual` in jvmMain): `java.util.prefs.Preferences.userNodeForPackage(RelayHealthStore::class)`, key `"health_${pubKeyHex.take(16)}_${fieldName}"`. Map serialized as `url|lastEvent|lastConnect|firstSeen|snoozedUntil` lines joined with `\n` (8KB Preferences limit → ~150 relays at 50B/row; if hit, fall back to a `.dat` file under `~/.amethyst/accounts//relay_health.dat`). + +**Android** (`actual` in androidMain): `AccountSettings.relayHealth: MutableStateFlow>` mirroring the `viewedPollResultNoteIds: Map` precedent (`amethyst/.../model/AccountSettings.kt:1135-1155`). Persisted via existing `EncryptedSharedPreferences`-backed `LocalPreferences` flow. + +Writes are **debounced 1s** in commons to avoid disk thrash from a noisy `onIncomingMessage` storm. Pattern: same `BasicBundledInsert` already used by `LocalRelayStore`. + +### "Remove from all lists" helper + +Lives in `commons/` as `RelayListMutator` (new). Inputs: `NormalizedRelayUrl`, the set of lists it's in. Outputs: a `RemovalPlan` listing the signed events that need publishing. + +Per platform: +- **Android**: `RelayListMutator.execute()` delegates to `Account.sendNip65RelayList` / `saveDMRelayList` / `saveSearchRelayList` / `saveBlockedRelayList` (`amethyst/.../model/Account.kt:3274/3294/3349/3424`). +- **Desktop**: calls the relevant commons `*State.saveRelayList(...)` and forwards each signed event to `relayManager.broadcastToAll(event)` (mirrors `DeckColumnContainer.kt:476` pattern). + +Hidden behind a single `suspend fun removeRelayFromAllUserLists(url, account, accountRelays): RemovalResult` so the sheet/popover doesn't branch on platform. + +### Implementation Phases + +#### Phase 1 — Tracking layer (`quartz/`) + +Pure-data extension; no behavior change. + +- **Files**: + - `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt` — add `@Volatile var lastConnectAt: Long = 0` and `@Volatile var lastEventAt: Long = 0`. + - `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt` — in the existing `RelayConnectionListener`: + - `onConnected(...)` → `relayStat.lastConnectAt = TimeUtils.nowInMs()`. + - `onIncomingMessage(msgStr, msg)` → on `msg is EventMessage` → `relayStat.lastEventAt = TimeUtils.nowInMs()`. +- **Tests**: `quartz/src/commonTest/.../RelayStatTest.kt` — unit-cover both setters fire on simulated messages. + +#### Phase 2 — Health state (`commons/`) + +- **New files**: + - `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayhealth/RelayHealthRecord.kt` — data class (4 timestamps). + - `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayhealth/UnhealthyRelay.kt` — { url, lastEventAt, lastConnectAt, lists: Set }. + - `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayhealth/UnhealthyRelaysSnapshot.kt` — { unhealthy: List, slowCount: Int }. + - `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayhealth/RelayHealthClassifier.kt` — pure function `classify(records, userLists, now): UnhealthyRelaysSnapshot`. + - `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayhealth/RelayHealthStore.kt` — `expect class`, exposes: + ```kotlin + val snapshot: StateFlow + fun observeRelay(url: NormalizedRelayUrl) // marks firstSeenAt + fun recordEvent(url: NormalizedRelayUrl, atMs: Long) // bumps lastEventAt + fun recordConnect(url: NormalizedRelayUrl, atMs: Long) // bumps lastConnectAt + fun snooze(url: NormalizedRelayUrl, until: Long) + fun snoozeAll(until: Long) + fun pruneRemovedRelays(currentUrls: Set) + suspend fun scanNow(): UnhealthyRelaysSnapshot // recompute + emit + ``` + - `commons/src/androidMain/.../RelayHealthStore.kt` — actual, backed by `AccountSettings.relayHealth` JSON map (mirror `viewedPollResultNoteIds`). + - `commons/src/jvmMain/.../RelayHealthStore.kt` — actual, backed by `java.util.prefs.Preferences`. + - `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayhealth/RelayListMutator.kt` — `suspend fun removeRelayFromAllUserLists(url, account, accountRelays)` (expect/actual). +- **Wire-up**: + - In `commons` ViewModel or service that already observes `RelayStats`, watch each `RelayStat` and forward `lastEventAt` / `lastConnectAt` changes into `RelayHealthStore`. Debounce 1s. Closest existing host: the place that already constructs `RelayStats` (cite during impl). + - `observeRelay(url)` called from the place that adds a relay to any list (Android `Account.send*RelayList`, Desktop `DesktopAccountRelays` setters). +- **Tests**: + - `commons/src/commonTest/.../RelayHealthClassifierTest.kt` — table-driven cases covering: dead relay, snoozed relay, newcomer (within 7d of firstSeenAt), offline-grace (lastSeenAny > 7d → nothing flagged), 10006-only relay (excluded), relay in multiple lists. + - `commons/src/jvmTest/.../RelayHealthStoreJvmTest.kt` — write → read round-trip via Preferences. + +#### Phase 3 — Shared UI in `commons/` + +- **New files**: + - `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayhealth/ui/UnhealthyRelayBanner.kt` — visual twin of `OfflineBanner`. Yellow-amber (`tertiaryContainer`) to differentiate from `OfflineBanner`'s red. Copy via `pluralStringResource`. + - `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayhealth/ui/UnhealthyRelayRow.kt` — reusable row with `Remove`, `Open Dashboard`, `Snooze 7d` slots. Used by both Android sheet + Desktop popover. +- **String resources**: add to `commons/src/commonMain/composeResources/values/strings.xml`: + - `unhealthy_relays_banner_title` (plural) + - `unhealthy_relays_review_action` + - `unhealthy_relay_remove` + - `unhealthy_relay_open_dashboard` + - `unhealthy_relay_snooze_7d` + - `unhealthy_relays_snooze_all_7d` + - `unhealthy_relay_lists_label` (e.g. "in: Read/Write, DMs") +- **Tests**: snapshot/composable tests can wait for first-render review. + +#### Phase 4 — Android wiring + +- **Sheet**: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/health/UnhealthyRelaysSheet.kt` — modeled on `AddToCalendarSheet.kt:64-80`. `ModalBottomSheet(skipPartiallyExpanded = true)` + scrollable `Column` of `UnhealthyRelayRow`s + footer `Snooze all 7d` button. +- **Placement**: inside `DisappearingScaffold` content slot, above the feed content. Banner is sticky-top sibling to the feed `LazyColumn`. Concrete site: above the `LazyColumn` in `HomeScreen.kt:224`. Reuse for other top-level screens deferred (banner shown only on Home for v1; matches "review at app start" UX). +- **App-start trigger**: `LaunchedEffect(accountViewModel.account)` inside `AppNavigation` near `AppNavigation.kt:220-239` — call `relayHealthStore.scanNow()` once per process (guard with `var didScan by remember`). +- **Nav**: "Open Relay Dashboard" → `nav.nav(Route.EditRelays)` (existing route — `Routes.kt:366`). +- **Snackbar feedback** (for Remove): use existing `accountViewModel.toast(...)` channel; copy: "Removed `relay.url`". + +#### Phase 5 — Desktop wiring + +- **Popup**: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/health/UnhealthyRelaysPopup.kt` — anchored under the banner via `androidx.compose.ui.window.Popup` with `PopupProperties(focusable = true)` (pattern from `NoteActions.kt:73-74` + `:492-496`). Scrollable column, max height `400.dp`, dismiss on outside click. +- **Banner placement**: inside `DeckColumnContainer.kt` directly below the per-column header (above `OfflineBanner` if both present), and inside `SinglePaneLayout.kt:100-103` for the single-pane mode. Show only on home/feed columns to avoid noise on settings. +- **App-start trigger**: `Main.kt:860 LaunchedEffect(accountState)` — inside the `is LoggedIn` branch already there, call `relayHealthStore.scanNow()` once. Guard with `var didScan by remember`. +- **Nav**: "Open Relay Dashboard" → set the active sidebar item / column type to `DeckColumnType.Relays` (existing — see `DeckColumnContainer.kt:468-477`). +- **Feedback**: existing Desktop `SnackbarHost`. + +#### Phase 6 — Polish + +- Spotless + lint. +- Manual smoke test matrix (see Quality Gates). +- Update relay-related docs in `commons/ARCHITECTURE.md` if new package warrants it. + +## Alternative Approaches Considered + +| Approach | Why rejected | +|---|---| +| Modal launch dialog | Too intrusive for periodic housekeeping; dismissable means it's gone on relaunch. (see brainstorm — Approach A) | +| Snackbar + dashboard badge | Auto-dismisses; multi-step "review and remove" defeats the easy-maintenance goal. (see brainstorm — Approach C) | +| Add windowed error counter to RelayStat now | Adds infrastructure for a marginal v1 signal; the two timestamp checks already capture "dead" cleanly. Deferred to v2. | +| Track lastEoseAt per subscription | Out of scope — EOSE is per-subscription, not per-relay; signal is noisy for relays the user rarely queries from. | +| Encrypt health timestamps | No PII / no secret material — timestamps of public relay URLs. Skip the overhead. | + +## System-Wide Impact + +### Interaction Graph + +``` +Relay receives event + → RelayStats.RelayConnectionListener.onIncomingMessage + → RelayStat.lastEventAt = now [new] + → (debounced 1s) RelayHealthStore.recordEvent + → updates StateFlow + → UnhealthyRelayBanner recomposes (count - 1 if previously flagged) + +User taps banner + → opens ModalBottomSheet (Android) / Popup (Desktop) + → tap Remove + → RelayListMutator.removeRelayFromAllUserLists + → 1..4 *State.saveRelayList(...) calls + → Account.send*RelayList (Android) / relayManager.broadcastToAll (Desktop) + → DesktopAccountRelays / Account StateFlow emits new list + → RelayHealthStore.pruneRemovedRelays(currentUrls) + → banner recomposes +``` + +### Error & Failure Propagation + +| Failure | Where caught | Behavior | +|---|---|---| +| Sign fails (NIP-46 bunker timeout) on remove | `RelayListMutator.removeRelayFromAllUserLists` | Returns `RemovalResult.Failure(lists: List)`; sheet shows "Removed from X of Y lists" snackbar. No partial-remove undo (matches "no undo" decision). | +| Broadcast fails (no relays connected) | `relayManager.broadcastToAll` | Signed event still persisted locally; will replay when online (existing behavior). | +| Persistence write fails (Preferences full / IO error) | `RelayHealthStore.actual` | Log + swallow. Next scan recomputes from in-memory state. | +| Snooze write fails | same | Snooze degrades to in-memory for this session — acceptable. | + +No new exception classes. All existing relay-edit error paths re-used. + +### State Lifecycle Risks + +- **Orphan timestamps** when a relay is removed from all lists → `pruneRemovedRelays` drops them. Triggered in the StateFlow collector of the user's list set. +- **Account switch mid-action** → `RelayHealthStore.snapshot` is per-account-scoped via the same scoping as `AccountSettings` / `DesktopAccountRelays`. New instance per account. +- **Multiple relays flapping** → debounce on write side; classifier is pure → idempotent on read. +- **First-run grace** — `firstSeenAt` is set lazily on first observation. If we ship into an empty store, every existing relay gets `firstSeenAt = now` on first launch → no flags for 7d, matching the brainstorm decision (see brainstorm: Resolved Questions). +- **Partial remove** → if 2 of 3 list-edits succeed and the third fails (signer crash), local state shows the relay only in the remaining list. Next health scan will still flag it (or not) based on its timestamps. User can re-tap Remove. + +### API Surface Parity + +| Surface | Status | +|---|---| +| Android Home (`HomeScreen`) | Banner shown | +| Android other feeds (Notifications, DMs, Discover) | Not in v1 — Home only | +| Desktop deck columns | Banner shown only on feed columns (Home, Notifications, DMs) | +| Desktop SinglePaneLayout | Banner shown | +| `RelayDashboardScreen` (Desktop) / `AllRelayListScreen` (Android) | No banner inside — would be redundant with the screen content. Optional follow-up: an inline "Unhealthy" section/filter. | +| `amy` CLI | New `amy relays health` subcommand — listed under Future Considerations, not v1. | + +### Integration Test Scenarios + +1. **Dead relay flagged on next launch**: seed records so `lastEventAt = lastConnectAt = now - 8d` and `lastSeenAny = now - 1h`; assert snapshot contains 1 relay; assert banner composable renders with count = 1. +2. **Snooze hides relay then re-shows**: snooze for 1ms; advance clock 2ms; assert it reappears. +3. **Offline-grace gate**: seed all relays as `lastEventAt = now - 10d`, `lastSeenAny = now - 10d`; assert snapshot is empty (we appear to be offline; not the relays' fault). +4. **Multi-list Remove publishes 1..4 events**: seed relay in 10002 + 10050; assert mutator publishes exactly 2 signed events; assert `pruneRemovedRelays` drops the record. +5. **Newcomer grace**: `firstSeenAt = now - 1d`; even if `lastEventAt = now - 8d`, not flagged. + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Banner appears on Android Home (`HomeScreen`) and on Desktop feed columns whenever ≥1 relay is unhealthy (per detection algorithm above). +- [ ] Banner copy uses plural string resource and shows count. +- [ ] Tapping banner opens `ModalBottomSheet` (Android) / `Popup` (Desktop) listing each unhealthy relay with: URL, list-membership chips, last-seen relative time, `Remove`, `Open Dashboard`, `Snooze 7d`. +- [ ] Banner footer (or sheet-top) has `Snooze all 7d`. +- [ ] Remove deletes the relay from every list it appears in (10002 / 10050 / 10007 / 10006). No confirmation, no undo. +- [ ] Open Dashboard navigates to existing relay dashboard for the platform (no pre-focus). +- [ ] Snooze (per-relay) suppresses that relay's flag until `now + 7d`. +- [ ] Snooze all suppresses every currently-flagged relay until `now + 7d`. +- [ ] Detection runs once on app start (per-process) and recomputes whenever the user's list set changes or a snooze expires. +- [ ] First-run: no relay flagged for 7d after install (no `firstSeenAt` history). +- [ ] Offline grace: if `lastSeenAny > 7d` (i.e. no relay anywhere has responded recently), nothing is flagged. + +### Non-Functional Requirements + +- [ ] Persistence write debounced ≥ 1s; no measurable disk-write hot loop under sustained relay traffic. +- [ ] RelayStat extension adds ≤ 16 bytes per relay (two Longs). +- [ ] Classifier is pure and side-effect free (testable without I/O). +- [ ] Health store actuals account-scoped — switching accounts loads a fresh store within 1 frame. +- [ ] No new permissions, no network changes. + +### Quality Gates + +- [ ] Unit tests for classifier (table-driven, ≥ 8 scenarios incl. the 5 listed in Integration Test Scenarios). +- [ ] Unit tests for `RelayStat` setter wiring (Phase 1). +- [ ] Round-trip tests for both `actual` persistence implementations. +- [ ] `./gradlew spotlessApply` clean. +- [ ] Manual smoke matrix: + | Platform | Steps | + |---|---| + | Android | Seed dead relay → relaunch → banner shows → tap → sheet → Remove → snackbar → relay gone from `AllRelayListScreen`. | + | Android | Seed dead relay → Snooze → banner gone → advance clock 7d → banner returns. | + | Desktop | Same flows with `Popup` + `DeckColumnContainer`. | + | Desktop | Account switch with stale records on prior account → fresh account shows none / its own. | + +## Success Metrics + +- (Telemetry-light project — qualitative.) Anecdotal: users report cleaner relay lists / removed dead URLs on first 1–2 launches after upgrade. +- Zero crash reports from `RelayListMutator` over 30d post-ship. +- No regressions in `OfflineBanner` placement / paint cost (banner is sibling; if both present they should stack cleanly). + +## Dependencies & Prerequisites + +- None new. Reuses existing `RelayStats`, `RelayConnectionListener`, `AccountSettings`, `DesktopAccountRelays`, `Account.send*RelayList`, `*State.saveRelayList`, `OfflineBanner`, `AddToCalendarSheet`, `Popup` patterns. +- `pluralStringResource` already available via Compose Multiplatform. + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| User offline for >7d → all relays flagged on relaunch | Medium | High (mass false positive) | **Offline-grace gate** in classifier (`lastSeenAny > 7d` → skip). | +| Newly-added relays get flagged before they have history | High without mitigation | Medium | **Newcomer-grace gate** via `firstSeenAt`. | +| Bunker signer slow → 1–4 sign requests for Remove pile up | Medium | Medium | Issue sign requests sequentially; first failure short-circuits remaining and the `RemovalResult.Failure(lists)` snackbar tells user which lists remain. | +| Persistence layer corruption | Low | Low | Treat as empty store on parse failure (existing pattern in `AccountSettings`); user gets newcomer-grace and rebuilds. | +| 10006 (blocked) excluded from detection but included in Remove may surprise users | Low | Low | Sheet row chips show *all* lists the relay is in, including "Blocked" — Remove behavior is transparent. (Deviation from brainstorm — see Open Questions resolved.) | +| Banner stacks with `OfflineBanner` and consumes feed height | Medium | Low | Both use the same compact height (~36 dp). Acceptable. If a third banner ever needs stacking, refactor to a `BannerStack` then. Not now. | +| `Preferences` 8 KB limit hit for users with 100+ relays | Very low | Medium | Fall back to a flat file under `~/.amethyst/accounts//relay_health.dat`. Implemented in Phase 2. | + +## Future Considerations + +- **v2 signals**: windowed error counter (`errorsLast24h`), per-subscription EOSE latency. Adds a "Slow" classification surfaced in the dashboard, not the banner. +- **Inline "Unhealthy" section** in `RelayDashboardScreen` / `AllRelayListScreen` for users who go looking before the banner triggers. +- **`amy relays health`** CLI command — fits the thin-assembly-layer rule (calls into `RelayHealthClassifier` from commons; produces JSON under `--json`). Per the `amy-expert` skill. +- **Configurable threshold** in settings (3d / 7d / 14d / 30d) — deliberately deferred to v1. +- **Replacement suggestions** — surface "popular healthy relays" from observed metrics when removing a relay. Separate feature. +- **Background scan when the app is open** — current trigger is launch-only; could re-scan every N hours via a coroutine. Not needed for v1 because `recordEvent` / `recordConnect` already update `lastSeenAt` live, so a flagged relay coming back drops out of the banner immediately. + +## Documentation Plan + +- Update `commons/ARCHITECTURE.md` to mention the new `commons/.../relayhealth/` package and its CLI-safe / UI split. +- Brief note in the CLAUDE.md "feed-patterns" / "account-state" sections if maintainers want it surfaced (optional). +- No user-facing changelog beyond the standard release notes. + +## Sources & References + +### Origin + +- **Brainstorm document**: [docs/brainstorms/2026-06-10-unhealthy-relay-review-brainstorm.md](../brainstorms/2026-06-10-unhealthy-relay-review-brainstorm.md) — Carried forward: banner+sheet/popover approach (vs modal dialog / snackbar), all-platforms shared via commons, scope=10002/10050/10007/10006 (with 10006 detection-exclusion adjustment), immediate-Remove-no-undo, per-relay + global snooze, plain Open Dashboard (no pre-focus), first-run 7d quiet period. + +### Internal References + +- Tracking: `quartz/src/commonMain/.../client/listeners/RelayConnectionListener.kt:27-73`, `quartz/.../stats/RelayStat.kt:27-86`, `quartz/.../stats/RelayStats.kt:37-127`, `quartz/.../commands/toClient/EventMessage.kt:25`. +- Relay-list mutation: `commons/.../nip65RelayList/Nip65RelayListState.kt:127`, `amethyst/.../model/Account.kt:3274/3294/3349/3424`, `desktopApp/.../ui/relay/Nip65RelayEditor.kt:73,239`, `desktopApp/.../DeckColumnContainer.kt:476`. +- Persistence: `desktopApp/.../model/DesktopAccountRelays.kt:38,61,92,108,237`, `amethyst/.../LocalPreferences.kt:169,296`, `amethyst/.../model/AccountSettings.kt:1135-1155` (snooze precedent: `viewedPollResultNoteIds`). +- Banner: `desktopApp/.../ui/components/OfflineBanner.kt:44-101`, placement at `desktopApp/.../SinglePaneLayout.kt:100-103`, `desktopApp/.../DeckColumnContainer.kt:195-198`. +- Sheet/Popup: `amethyst/.../calendars/detail/AddToCalendarSheet.kt:64-80`, `desktopApp/.../ui/NoteActions.kt:73-74,492-496`. +- Nav: `amethyst/.../navigation/routes/Routes.kt:366,474`, `amethyst/.../AppNavigation.kt:391,412`, `desktopApp/.../DeckColumnContainer.kt:468-477`. +- App-start hooks: `desktopApp/.../desktop/Main.kt:860-870`, `amethyst/.../AppNavigation.kt:220-239`. +- Keys: `quartz/.../nip01Core/relay/normalizer/NormalizedRelayUrl.kt:25-30`. + +### External References + +- Material 3 — Banner & BottomSheet usage guidance: https://m3.material.io/components/banners/overview, https://m3.material.io/components/bottom-sheets/overview +- NIP-65 (Relay List Metadata): https://github.com/nostr-protocol/nips/blob/master/65.md +- NIP-17 / NIP-51 relay list kinds context (10050 DM relays, 10007 search, 10006 blocked). + +### Related Work + +- Embedded Local Relay plan: `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md` — `OfflineBanner` and `BasicBundledInsert` debounce pattern come from this work. +- User memory notes: `~/.claude-account1/projects/.../memory/MEMORY.md` — `java.util.prefs.Preferences` desktop persistence, `rememberSubscription`-inside-AlertDialog caveat, relay-callbacks-on-background-threads note (informs the debounce design). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index f5b01d7016..5eb3d61cfc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.concurrent.Volatile @Stable class RelayStat( @@ -35,6 +36,12 @@ class RelayStat( var connectionTentatives: Int = 0, var connectionCompleted: Int = 0, ) { + // Best-effort liveness signals (epoch seconds, 0 = never). The enclosing RelayStats LruCache + // can evict; durable per-relay history lives in commons RelayHealthStore. + @Volatile var lastConnectAt: Long = 0 + + @Volatile var lastIncomingAt: Long = 0 + val messages = LruCache(100) fun newNotice(notice: String?) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index b6548e1a2c..2b1fceb9d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory class RelayStats( @@ -64,6 +65,7 @@ class RelayStats( pingInMs = pingMillis compression = compressed connectionCompleted() + lastConnectAt = TimeUtils.now() } } @@ -91,6 +93,7 @@ class RelayStats( val stat = get(relay.url) stat.addBytesReceived(msgStr.bytesUsedInMemory()) + stat.lastIncomingAt = TimeUtils.now() when (msg) { is NoticeMessage -> {