From b91519157e7907f6cbeb992504219fe6260db2d8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 15 Jun 2026 13:21:32 +0300 Subject: [PATCH 01/10] fix(commons,quartz): RelayHealthStore threading + sleep-resume socket recovery Follow-up to #3186, addressing the unresolved review feedback: - RelayHealthStore.schedulePersist() wrapped the blocking save() in withContext(ioDispatcher) so prefs.flush() no longer sits on the Compose composition thread on Desktop. - close() now fires the final save on a detached IO-bound scope instead of blocking the composition thread for ~50ms during account switch / app exit. - @Volatile on persistJob/tickJob and a closed-flag guard so the relay-network thread and composition thread no longer race on plain vars (and post-close work is dropped). - desktopApp/Main.kt passes Dispatchers.IO to RelayHealthStore so persistence flushes land on the IO dispatcher instead of Dispatchers.Default. Plus a separate-but-related fix to the offline-banner-stuck-after-Mac-sleep issue: NostrClient.keepAliveJob now tracks wall-clock overshoot of its scheduled tick. If the OS suspended us (laptop lid closed, system sleep), delay() returns far past its deadline and the OkHttp websockets we held are dead even though BasicRelayClient.isConnected() still reads true until the next ping fails. On a >5x interval overshoot, force relayPool.disconnect() + connect() instead of trusting needsToReconnect(), so feeds resume without an app restart. --- .../commons/relays/health/RelayHealthStore.kt | 36 ++++- .../health/RelayHealthStoreCloseTest.kt | 123 ++++++++++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 2 + .../nip01Core/relay/client/NostrClient.kt | 23 +++- 4 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt 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 index 5bf6623393..7fba544322 100644 --- 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 @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.concurrent.Volatile /** * Per-account, durable record of relay liveness used to drive the "unhealthy relay" review UI. @@ -55,6 +56,10 @@ class RelayHealthStore( private val persistence: RelayHealthPersistence, private val torEnabledProvider: () -> Boolean = { false }, parentScope: CoroutineScope? = null, + // Caller-supplied dispatcher used for both classification and persistence I/O. + // Default is `Dispatchers.Default` so commonMain stays iOS-compatible — JVM hosts + // (Android/Desktop) should pass `Dispatchers.IO` so `prefs.flush()` doesn't sit on + // a CPU-bound worker. private val ioDispatcher: CoroutineDispatcher = Dispatchers.Default, ) { companion object { @@ -84,8 +89,11 @@ class RelayHealthStore( private val _unhealthy = MutableStateFlow>(persistentListOf()) val unhealthy: StateFlow> = _unhealthy.asStateFlow() - private var persistJob: Job? = null - private var tickJob: Job? = null + @Volatile private var persistJob: Job? = null + + @Volatile private var tickJob: Job? = null + + @Volatile private var closed = false init { // Persist the firstScanAt seed if we just stamped it. @@ -214,20 +222,38 @@ class RelayHealthStore( } private fun schedulePersist() { + if (closed) return persistJob?.cancel() persistJob = scope.launch { delay(PERSIST_DEBOUNCE_MS) val snapshot = state.value - runCatching { persistence.save(snapshot) } + withContext(ioDispatcher) { + runCatching { persistence.save(snapshot) } + } } } + /** + * Tear down internal jobs and fire the final persist off-thread. Safe to call from + * the composition / Main thread: the blocking I/O is dispatched to [ioDispatcher] + * on a detached, self-cancelling scope so the last debounce window isn't lost when + * the parent composition scope is about to cancel. + */ fun close() { - // Flush pending writes synchronously before tearing down. + if (closed) return + closed = true persistJob?.cancel() - runCatching { persistence.save(state.value) } tickJob?.cancel() + val finalSnapshot = state.value + val flushScope = CoroutineScope(SupervisorJob() + ioDispatcher) + flushScope.launch { + try { + runCatching { persistence.save(finalSnapshot) } + } finally { + flushScope.cancel() + } + } if (ownsScope) scope.cancel() } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt new file mode 100644 index 0000000000..7f1be49b0f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt @@ -0,0 +1,123 @@ +/* + * 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.concurrent.Volatile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class RelayHealthStoreCloseTest { + private class CountingPersistence : RelayHealthPersistence { + @Volatile var saves: Int = 0 + + @Volatile var lastSnapshot: RelayHealthSnapshot? = null + + override fun load(): RelayHealthSnapshot = RelayHealthSnapshot() + + override fun save(snapshot: RelayHealthSnapshot) { + saves++ + lastSnapshot = snapshot + } + } + + private val url = RelayUrlNormalizer.normalizeOrNull("wss://example.com")!! + + @Test + fun close_is_idempotent() = + runTest { + val persistence = CountingPersistence() + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = CoroutineScope(SupervisorJob() + dispatcher) + val store = + RelayHealthStore( + persistence = persistence, + parentScope = scope, + ioDispatcher = dispatcher, + ) + + store.close() + store.close() + store.close() + + advanceUntilIdle() + // First close runs a fire-and-forget final save; later closes are no-ops. + assertEquals(1, persistence.saves) + scope.cancel() + } + + @Test + fun recordIncoming_after_close_does_not_schedule_persist() = + runTest { + val persistence = CountingPersistence() + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = CoroutineScope(SupervisorJob() + dispatcher) + val store = + RelayHealthStore( + persistence = persistence, + parentScope = scope, + ioDispatcher = dispatcher, + ) + + advanceUntilIdle() + val baseline = persistence.saves + + store.close() + advanceUntilIdle() + val afterClose = persistence.saves + assertTrue(afterClose >= baseline + 1, "close should run the final save") + + store.recordIncoming(url, atSeconds = 1_700_000_000L) + advanceUntilIdle() + // closed → schedulePersist short-circuits, no extra save. + assertEquals(afterClose, persistence.saves) + scope.cancel() + } + + @Test + fun close_with_internal_scope_owns_its_lifecycle() = + runTest { + val persistence = CountingPersistence() + val store = + RelayHealthStore( + persistence = persistence, + parentScope = null, // store owns its scope + ioDispatcher = Dispatchers.Default, + ) + + // Sanity: schedulePersist on internal scope worked. + store.recordConnect(url, atSeconds = 1_700_000_000L) + + store.close() + // Idempotent + does not throw. + store.close() + } +} 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 5f0def4eac..68780b4d62 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1441,6 +1441,8 @@ fun MainContent( torStateForHealth.settings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF }, parentScope = scope, + // `prefs.flush()` is blocking — keep it off the composition scope's Main dispatcher. + ioDispatcher = kotlinx.coroutines.Dispatchers.IO, ) } DisposableEffect(relayHealthStore, relayManager) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 693aa74661..092ae94eea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -164,12 +165,27 @@ class NostrClient( * error code) would stay disconnected forever in the absence of any * subscription change. The per-relay [BasicRelayClient] backoff still * gates the actual reconnect attempt, so dead relays are not hammered. + * + * Also detects system sleep/resume by tracking wall-clock overshoot of the + * scheduled tick. If the [delay] returned far later than expected the host + * was almost certainly suspended (laptop lid closed, OS sleep), and the + * OkHttp websockets we held are dead even though [isConnected] still reads + * true until the next ping fails. In that case force a hard reconnect. */ private val keepAliveJob = scope.launch { + var lastTickMs = TimeUtils.nowMillis() while (true) { delay(KEEP_ALIVE_INTERVAL_MS) - if (this@NostrClient.isActive) { + if (!this@NostrClient.isActive) continue + val now = TimeUtils.nowMillis() + val elapsed = now - lastTickMs + lastTickMs = now + if (elapsed > KEEP_ALIVE_WAKE_THRESHOLD_MS) { + // System likely resumed from sleep — force a hard reconnect. + relayPool.disconnect() + relayPool.connect() + } else { relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false) } } @@ -177,6 +193,11 @@ class NostrClient( companion object { private const val KEEP_ALIVE_INTERVAL_MS = 60_000L + + // Treat any tick that overshoots the scheduled delay by more than this many + // milliseconds as a probable system-sleep resume. 5x interval (5 min) avoids + // firing on routine GC stalls or brief OS scheduler pauses. + private const val KEEP_ALIVE_WAKE_THRESHOLD_MS = 5 * KEEP_ALIVE_INTERVAL_MS } override fun reconnect( From 988af90763a1f9ba16f3fd14b1175ecd08b3fbbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 22:43:56 +0000 Subject: [PATCH 02/10] feat: render Workout feed with regular NoteCompose Switch the Workouts screen from the custom WorkoutCardCompose card to the standard NoteCompose feed via the default FeedLoaded renderer. NoteCompose already dispatches WorkoutRecordEvent to WorkoutDisplay, so workouts render with the full note chrome (author header, reactions, replies, etc.). Removes the now-unused WorkoutFeedLoaded and WorkoutCardCompose. --- .../loggedIn/workouts/WorkoutCardCompose.kt | 73 ------------------ .../loggedIn/workouts/WorkoutFeedLoaded.kt | 75 ------------------- .../loggedIn/workouts/WorkoutsScreen.kt | 8 -- 3 files changed, 156 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt deleted file mode 100644 index 8c60b9fc3b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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.ui.screen.loggedIn.workouts - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.note.ReactionsRow -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader -import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent - -@Composable -fun WorkoutCardCompose( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val event = (baseNote.event as? WorkoutRecordEvent) ?: return - - Column( - modifier = - Modifier.fillMaxWidth().clickable { - routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } - }, - ) { - UserCardHeader(baseNote, accountViewModel, nav) - - WorkoutDisplay(baseNote) - - if (event.content.isNotBlank()) { - Text( - text = event.content, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - ) - } - - ReactionsRow( - baseNote = baseNote, - showReactionDetail = true, - addPadding = true, - editState = null, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt deleted file mode 100644 index c496e9d54e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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.ui.screen.loggedIn.workouts - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent - -@Composable -fun WorkoutFeedLoaded( - loaded: FeedState.Loaded, - listState: LazyListState, - accountViewModel: AccountViewModel, - nav: INav, -) { - val items by loaded.feed.collectAsStateWithLifecycle() - - LazyColumn( - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - itemsIndexed( - items.list, - key = { _, item -> item.idHex }, - contentType = { _, item -> item.event?.kind ?: -1 }, - ) { _, item -> - if (item.event is WorkoutRecordEvent) { - WorkoutCardCompose( - baseNote = item, - accountViewModel = accountViewModel, - nav = nav, - ) - - HorizontalDivider( - thickness = DividerThickness, - ) - - Spacer(modifier = Modifier.height(8.dp)) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt index 08769e1980..c4d562485f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt @@ -89,14 +89,6 @@ fun WorkoutsScreen( listState = listState, nav = nav, routeForLastRead = "WorkoutsFeed", - onLoaded = { loaded -> - WorkoutFeedLoaded( - loaded = loaded, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - }, ) } } From e39369da8076077b291c8327f9fdc238655970a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 22:47:35 +0000 Subject: [PATCH 03/10] feat: enrich workout display with source, splits-style metrics Surface more of the parsed kind-1301 data in WorkoutDisplay, modeled on how RUNSTR renders workout records: - Source badge (GPS / RUNSTR / HEALTHKIT / MANUAL) in the header - Average speed (km/h or mph) for cycling instead of pace - Elevation loss alongside elevation gain - Max heart rate alongside average heart rate Relabels 'Elevation' to 'Elevation gain' now that loss is shown. --- .../loggedIn/workouts/WorkoutDisplay.kt | 72 +++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 5 +- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index 765fd34d30..8927c2b443 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -30,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +39,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -53,6 +56,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag import kotlin.math.abs +import kotlin.math.round fun ExerciseType?.symbol(): MaterialSymbol = when (this) { @@ -95,18 +99,39 @@ private fun paceMinPerUnit( return "${secondsPerUnit / 60}:${(secondsPerUnit % 60).toString().padStart(2, '0')}" } +/** Average speed, more natural than pace for wheeled/water sports. Returns e.g. `24.5 km/h` or `15.2 mph`. */ +private fun speed( + durationSeconds: Long, + distance: DistanceTag, +): String { + val hours = durationSeconds / 3600.0 + return if (distance.unit == DistanceTag.MILES) { + "${trimToOneDecimal(distance.value / hours)} mph" + } else { + "${trimToOneDecimal(distance.toKilometers() / hours)} km/h" + } +} + +private fun trimToOneDecimal(value: Double): String { + val rounded = round(value * 10.0) / 10.0 + return rounded.trimmed() +} + /** One-shot snapshot of the parsed workout tags, so the feed doesn't re-scan the tag array on every recomposition. */ @Immutable class WorkoutInfo( val title: String?, val type: ExerciseType?, val exerciseRaw: String?, + val source: String?, val durationSeconds: Long?, val distance: DistanceTag?, val elevationGain: Elevation?, + val elevationLoss: Elevation?, val calories: Int?, val steps: Int?, val avgHeartRate: Int?, + val maxHeartRate: Int?, val sets: Int?, val reps: Int?, val weight: WeightTag?, @@ -117,12 +142,15 @@ class WorkoutInfo( title = event.title(), type = event.exerciseType(), exerciseRaw = event.exercise(), + source = event.workoutSource(), durationSeconds = event.durationSeconds(), distance = event.distance(), elevationGain = event.elevationGain(), + elevationLoss = event.elevationLoss(), calories = event.calories(), steps = event.steps(), avgHeartRate = event.avgHeartRate(), + maxHeartRate = event.maxHeartRate(), sets = event.sets(), reps = event.reps(), weight = event.weight(), @@ -148,7 +176,7 @@ fun WorkoutDisplay(baseNote: Note) { Spacer(modifier = Modifier.width(8.dp)) - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = info.title ?: typeLabel, fontWeight = FontWeight.Bold, @@ -162,6 +190,11 @@ fun WorkoutDisplay(baseNote: Note) { ) } } + + info.source?.let { + Spacer(modifier = Modifier.width(8.dp)) + WorkoutSourceBadge(it) + } } WorkoutStatsRow(info) @@ -187,16 +220,25 @@ private fun WorkoutStatsRow(info: WorkoutInfo) { } if (duration != null && distance != null && distance.value > 0.0) { - WorkoutStat( - "${paceMinPerUnit(duration, distance.value)} /${distance.unit}", - stringRes(R.string.workout_pace), - ) + // Cycling is conventionally reported as speed; running/walking/etc. as pace. + if (info.type == ExerciseType.CYCLING) { + WorkoutStat(speed(duration, distance), stringRes(R.string.workout_speed)) + } else { + WorkoutStat( + "${paceMinPerUnit(duration, distance.value)} /${distance.unit}", + stringRes(R.string.workout_pace), + ) + } } info.elevationGain?.let { WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation)) } + info.elevationLoss?.let { + WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation_loss)) + } + info.calories?.let { WorkoutStat("$it kcal", stringRes(R.string.workout_calories)) } @@ -209,6 +251,10 @@ private fun WorkoutStatsRow(info: WorkoutInfo) { WorkoutStat("$it bpm", stringRes(R.string.workout_heart_rate)) } + info.maxHeartRate?.let { + WorkoutStat("$it bpm", stringRes(R.string.workout_max_heart_rate)) + } + info.sets?.let { WorkoutStat("$it", stringRes(R.string.workout_sets)) } @@ -223,6 +269,22 @@ private fun WorkoutStatsRow(info: WorkoutInfo) { } } +/** Small chip showing how the workout was recorded (e.g. GPS, RUNSTR, HEALTHKIT, MANUAL). */ +@Composable +private fun WorkoutSourceBadge(source: String) { + Text( + text = source.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + @Composable private fun WorkoutStat( value: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4a6e63b466..86d82fcc06 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -687,10 +687,13 @@ Duration Distance Pace - Elevation + Speed + Elevation gain + Elevation loss Calories Steps Heart rate + Max heart rate Sets Reps Weight From 830e340ed80874f8ca26c23af31de981d6aa4e5b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 00:14:44 +0000 Subject: [PATCH 04/10] feat: hero metric and fixed grid for workout display Make the workout note render with more visual punch, modeled on RUNSTR's workout cards: - Promote the headline metric (distance for cardio, steps when there is no distance, otherwise duration) to a large hero number, skipping it in the grid below so it is not repeated. - Lay out secondary metrics in a fixed 3-column grid instead of a free-flowing row so values line up in tidy columns. - Give the activity icon a tinted circular chip for more prominence. --- .../loggedIn/workouts/WorkoutDisplay.kt | 227 +++++++++++++----- 1 file changed, 166 insertions(+), 61 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index 8927c2b443..a43ee9645a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt @@ -22,15 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -158,6 +158,16 @@ class WorkoutInfo( } } +/** Which metric is promoted to the hero number, so the grid below can skip repeating it. */ +private enum class HeroKind { DISTANCE, STEPS, DURATION, NONE } + +/** A single secondary metric: a bold value over a muted label. */ +@Immutable +private class Stat( + val value: String, + val label: String, +) + @Composable fun WorkoutDisplay(baseNote: Note) { val event = (baseNote.event as? WorkoutRecordEvent) ?: return @@ -165,16 +175,56 @@ fun WorkoutDisplay(baseNote: Note) { val info = remember(baseNote) { WorkoutInfo.from(event) } val typeLabel = info.type?.let { stringRes(it.labelRes()) } ?: info.exerciseRaw ?: stringRes(R.string.workout) + val duration = info.durationSeconds + val distance = info.distance + val steps = info.steps + + val heroKind = + when { + distance != null && distance.value > 0.0 -> HeroKind.DISTANCE + steps != null -> HeroKind.STEPS + duration != null -> HeroKind.DURATION + else -> HeroKind.NONE + } + + val secondaryStats = + buildSecondaryStats( + info = info, + heroKind = heroKind, + durationLabel = stringRes(R.string.workout_duration), + distanceLabel = stringRes(R.string.workout_distance), + paceLabel = stringRes(R.string.workout_pace), + speedLabel = stringRes(R.string.workout_speed), + elevationGainLabel = stringRes(R.string.workout_elevation), + elevationLossLabel = stringRes(R.string.workout_elevation_loss), + caloriesLabel = stringRes(R.string.workout_calories), + stepsLabel = stringRes(R.string.workout_steps), + heartRateLabel = stringRes(R.string.workout_heart_rate), + maxHeartRateLabel = stringRes(R.string.workout_max_heart_rate), + setsLabel = stringRes(R.string.workout_sets), + repsLabel = stringRes(R.string.workout_reps), + weightLabel = stringRes(R.string.workout_weight), + ) + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - symbol = info.type.symbol(), - contentDescription = typeLabel, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.primary, - ) + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = info.type.symbol(), + contentDescription = typeLabel, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Text( @@ -197,74 +247,128 @@ fun WorkoutDisplay(baseNote: Note) { } } - WorkoutStatsRow(info) + when (heroKind) { + HeroKind.DISTANCE -> + WorkoutHero(distance!!.value.trimmed(), distance.unit, stringRes(R.string.workout_distance)) + HeroKind.STEPS -> + WorkoutHero(steps!!.toString(), null, stringRes(R.string.workout_steps)) + HeroKind.DURATION -> + WorkoutHero(DurationTag.formatTime(duration!!), null, stringRes(R.string.workout_duration)) + HeroKind.NONE -> {} + } + + WorkoutStatsGrid(secondaryStats) } } -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun WorkoutStatsRow(info: WorkoutInfo) { +/** Builds the ordered list of secondary metrics, skipping whichever one is shown as the hero. */ +private fun buildSecondaryStats( + info: WorkoutInfo, + heroKind: HeroKind, + durationLabel: String, + distanceLabel: String, + paceLabel: String, + speedLabel: String, + elevationGainLabel: String, + elevationLossLabel: String, + caloriesLabel: String, + stepsLabel: String, + heartRateLabel: String, + maxHeartRateLabel: String, + setsLabel: String, + repsLabel: String, + weightLabel: String, +): List { val duration = info.durationSeconds val distance = info.distance - FlowRow( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - ) { - duration?.let { - WorkoutStat(DurationTag.formatTime(it), stringRes(R.string.workout_duration)) + return buildList { + if (heroKind != HeroKind.DURATION) { + duration?.let { add(Stat(DurationTag.formatTime(it), durationLabel)) } } - - distance?.let { - WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_distance)) + if (heroKind != HeroKind.DISTANCE) { + distance?.let { add(Stat("${it.value.trimmed()} ${it.unit}", distanceLabel)) } } - if (duration != null && distance != null && distance.value > 0.0) { // Cycling is conventionally reported as speed; running/walking/etc. as pace. if (info.type == ExerciseType.CYCLING) { - WorkoutStat(speed(duration, distance), stringRes(R.string.workout_speed)) + add(Stat(speed(duration, distance), speedLabel)) } else { - WorkoutStat( - "${paceMinPerUnit(duration, distance.value)} /${distance.unit}", - stringRes(R.string.workout_pace), + add(Stat("${paceMinPerUnit(duration, distance.value)} /${distance.unit}", paceLabel)) + } + } + info.elevationGain?.let { add(Stat("${it.value.trimmed()} ${it.unit}", elevationGainLabel)) } + info.elevationLoss?.let { add(Stat("${it.value.trimmed()} ${it.unit}", elevationLossLabel)) } + info.calories?.let { add(Stat("$it kcal", caloriesLabel)) } + if (heroKind != HeroKind.STEPS) { + info.steps?.let { add(Stat("$it", stepsLabel)) } + } + info.avgHeartRate?.let { add(Stat("$it bpm", heartRateLabel)) } + info.maxHeartRate?.let { add(Stat("$it bpm", maxHeartRateLabel)) } + info.sets?.let { add(Stat("$it", setsLabel)) } + info.reps?.let { add(Stat("$it", repsLabel)) } + info.weight?.let { add(Stat("${it.value.trimmed()} ${it.unit}", weightLabel)) } + } +} + +/** The headline metric, shown large above the secondary grid (e.g. `5.2 km`). */ +@Composable +private fun WorkoutHero( + value: String, + unit: String?, + label: String, +) { + Column(modifier = Modifier.padding(top = 10.dp)) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = value, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary, + ) + unit?.let { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(bottom = 6.dp), ) } } + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} - info.elevationGain?.let { - WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation)) - } +/** Fixed-column grid so secondary metrics line up in tidy columns instead of free-flowing. */ +@Composable +private fun WorkoutStatsGrid( + stats: List, + columns: Int = 3, +) { + if (stats.isEmpty()) return - info.elevationLoss?.let { - WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation_loss)) - } - - info.calories?.let { - WorkoutStat("$it kcal", stringRes(R.string.workout_calories)) - } - - info.steps?.let { - WorkoutStat("$it", stringRes(R.string.workout_steps)) - } - - info.avgHeartRate?.let { - WorkoutStat("$it bpm", stringRes(R.string.workout_heart_rate)) - } - - info.maxHeartRate?.let { - WorkoutStat("$it bpm", stringRes(R.string.workout_max_heart_rate)) - } - - info.sets?.let { - WorkoutStat("$it", stringRes(R.string.workout_sets)) - } - - info.reps?.let { - WorkoutStat("$it", stringRes(R.string.workout_reps)) - } - - info.weight?.let { - WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_weight)) + Column( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + stats.chunked(columns).forEach { rowStats -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowStats.forEach { stat -> + WorkoutStat(stat.value, stat.label, Modifier.weight(1f)) + } + // Pad the last row so columns stay aligned across rows. + repeat(columns - rowStats.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } } } } @@ -289,8 +393,9 @@ private fun WorkoutSourceBadge(source: String) { private fun WorkoutStat( value: String, label: String, + modifier: Modifier = Modifier, ) { - Column { + Column(modifier = modifier) { Text( text = value, fontWeight = FontWeight.Bold, From c7f5ab83b010e759d82fcc1959ee0b3400302298 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 00:26:13 +0000 Subject: [PATCH 05/10] feat: pre-load reply threads from the feed When a reply is visible in NoteCompose, eagerly subscribe to its thread root (filter on the root's e/a tag, like the thread screen) so opening the conversation finds it already loaded. The observer resolves the root and keys on its id, so sibling replies share one subscription and root notes are skipped. --- .../amethyst/ui/note/types/Text.kt | 5 ++++ .../ThreadFilterAssemblerSubscription.kt | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 874936fe20..c082b39990 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -82,6 +83,10 @@ fun RenderTextEvent( val noteEvent = note.event ?: return if (unPackReply != ReplyRenderType.NONE) { + // Eagerly pull the rest of this reply's thread while it's on screen, so opening + // the conversation finds it already loaded. No-op when the note is a root itself. + PreloadThreadForReply(note, accountViewModel) + val canShowReply by remember(note) { derivedStateOf { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt index f30c0221c8..75e206a528 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.model.ThreadAssembler import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -52,3 +55,29 @@ fun ThreadFilterAssemblerSubscription( LifecycleAwareKeyDataSourceSubscription(state, filterAssembler) } + +/** + * Eagerly pre-loads the whole thread of a reply that is visible in a feed. + * + * When a reply shows up in `NoteCompose`, this resolves the thread root and opens + * the same root subscription the thread screen uses (a filter on the root's `e`/`a` + * tag, covering NIP-10 and NIP-22 event/addressable roots), so tapping into the + * conversation finds it already loaded. Keying on the resolved root id means every + * visible reply that shares a root collapses onto a single subscription, and a note + * that is itself a root (no parent) is skipped — nothing to pre-load. + */ +@Composable +fun PreloadThreadForReply( + note: Note, + accountViewModel: AccountViewModel, +) { + val rootId = + remember(note) { + val root = ThreadAssembler(LocalCache).findRoot(note.idHex) + if (root != null && root != note) root.idHex else null + } + + if (rootId != null) { + ThreadFilterAssemblerSubscription(rootId, accountViewModel) + } +} From 552540e77d50cff363412e3f1855dffae300c68e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 16 Jun 2026 09:57:22 +0300 Subject: [PATCH 06/10] fix(desktopApp): move sleep/resume detection out of Quartz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Vitor's review of #3221: wake-detection is platform-specific UX, not NostrClient's job. Quartz already exposes `reconnect(onlyIfChanged = false, ignoreRetryDelays = true)` which does the full disconnect + connect — the app layer just needs to call it when it detects a wake. - Revert the keep-alive heuristic in NostrClient.kt; the loop is back to the conservative `reconnectIfNeedsTo` path it had before. - Add `runSleepResumeMonitor` (desktopApp/network/SleepResumeMonitor.kt): a 60s tick that watches for wall-clock overshoot and calls the supplied `onWake` lambda. No native deps. - Wire it in `Main.kt` next to the metrics LaunchedEffect: on >5x overshoot call `relayManager.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)`. Real OS sleep events (NSWorkspace on macOS, D-Bus PrepareForSleep on Linux, WM_POWERBROADCAST on Windows) can be layered in later as platform improvements without touching Quartz again. --- .../vitorpamplona/amethyst/desktop/Main.kt | 12 ++++ .../desktop/network/SleepResumeMonitor.kt | 66 +++++++++++++++++++ .../nip01Core/relay/client/NostrClient.kt | 23 +------ 3 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/SleepResumeMonitor.kt 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 68780b4d62..7b59dc24f7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -839,6 +839,18 @@ fun App( relayManager.startMetricsSnapshot(this) } + // Detect host-machine sleep/wake: after a long delay overshoot the OkHttp + // sockets we held are dead even though needsToReconnect() still reads false, + // so force a hard disconnect+connect. See SleepResumeMonitor.kt. + LaunchedEffect(relayManager) { + com.vitorpamplona.amethyst.desktop.network.runSleepResumeMonitor { + relayManager.client.reconnect( + onlyIfChanged = false, + ignoreRetryDelays = true, + ) + } + } + // Subscriptions coordinator — uses default relay URLs for metadata indexing. // Feed subscriptions (inside MainContent) drive actual relay pool connections. val subscriptionsCoordinator = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/SleepResumeMonitor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/SleepResumeMonitor.kt new file mode 100644 index 0000000000..c2d8d3195d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/SleepResumeMonitor.kt @@ -0,0 +1,66 @@ +/* + * 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.network + +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.coroutines.coroutineContext + +/** + * Detects host-machine sleep/wake transitions by watching wall-clock overshoot + * of a tight delay loop. When the OS suspends the JVM, [delay] returns far past + * its scheduled deadline; the OkHttp websocket connections we held are dead by + * then even though [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient] + * still reports `isConnected() == true` until the next ping fails — so the + * standard keep-alive reconnect path is a no-op and the offline banner stays + * stuck until a manual reload. + * + * Lives here in the desktop app (per Vitor's review of #3221) instead of the + * cross-platform NostrClient because sleep/wake semantics differ across + * platforms — Android has Doze + network change broadcasts, iOS has app + * lifecycle events, and macOS/Linux/Windows desktops can grow real OS-level + * sleep hooks here later (NSWorkspace notifications, D-Bus PrepareForSleep, + * WM_POWERBROADCAST) without touching Quartz. + * + * Real OS sleep events would be more precise, but the wall-clock heuristic + * needs zero native deps and catches the symptom for v1. + */ +suspend fun runSleepResumeMonitor( + intervalMs: Long = DEFAULT_INTERVAL_MS, + wakeThresholdMs: Long = DEFAULT_WAKE_THRESHOLD_MS, + nowMs: () -> Long = { System.currentTimeMillis() }, + onWake: () -> Unit, +) { + var lastTickMs = nowMs() + while (coroutineContext.isActive) { + delay(intervalMs) + val now = nowMs() + val elapsed = now - lastTickMs + lastTickMs = now + if (elapsed > wakeThresholdMs) onWake() + } +} + +private const val DEFAULT_INTERVAL_MS: Long = 60_000L + +// 5x the tick — wide enough to ignore GC stalls / brief scheduler hiccups, tight +// enough to recover quickly after a real sleep. +private const val DEFAULT_WAKE_THRESHOLD_MS: Long = 5 * DEFAULT_INTERVAL_MS diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 092ae94eea..693aa74661 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -165,27 +164,12 @@ class NostrClient( * error code) would stay disconnected forever in the absence of any * subscription change. The per-relay [BasicRelayClient] backoff still * gates the actual reconnect attempt, so dead relays are not hammered. - * - * Also detects system sleep/resume by tracking wall-clock overshoot of the - * scheduled tick. If the [delay] returned far later than expected the host - * was almost certainly suspended (laptop lid closed, OS sleep), and the - * OkHttp websockets we held are dead even though [isConnected] still reads - * true until the next ping fails. In that case force a hard reconnect. */ private val keepAliveJob = scope.launch { - var lastTickMs = TimeUtils.nowMillis() while (true) { delay(KEEP_ALIVE_INTERVAL_MS) - if (!this@NostrClient.isActive) continue - val now = TimeUtils.nowMillis() - val elapsed = now - lastTickMs - lastTickMs = now - if (elapsed > KEEP_ALIVE_WAKE_THRESHOLD_MS) { - // System likely resumed from sleep — force a hard reconnect. - relayPool.disconnect() - relayPool.connect() - } else { + if (this@NostrClient.isActive) { relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false) } } @@ -193,11 +177,6 @@ class NostrClient( companion object { private const val KEEP_ALIVE_INTERVAL_MS = 60_000L - - // Treat any tick that overshoots the scheduled delay by more than this many - // milliseconds as a probable system-sleep resume. 5x interval (5 min) avoids - // firing on routine GC stalls or brief OS scheduler pauses. - private const val KEEP_ALIVE_WAKE_THRESHOLD_MS = 5 * KEEP_ALIVE_INTERVAL_MS } override fun reconnect( From 7b34438b0494644ec239475b03d0f2b641d6248a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Jun 2026 21:50:25 -0400 Subject: [PATCH 07/10] fix: gate Tor-routed relay dials until Tor's SOCKS port is ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before Tor finishes bootstrapping, the relay pool dialed every Tor-routed relay against the not-yet-listening SOCKS proxy. On a cold start this was ~580 doomed dials (all "SOCKS: Connection refused") concentrated in the seconds before Tor went Active, churning sockets/CPU and inflating each relay's backoff. The cost scaled with bootstrap latency, and the same storm recurred on every network switch (which resets and re-bootstraps Arti). Add an optional WebsocketBuilder.canConnect(url) gate (defaults to true, so other implementors are untouched), checked at the top of BasicRelayClient.connect() before the mutex/onConnecting/build — so a gated relay opens no socket, fires no listener events, and grows no backoff. The Android builder gates Tor-routed relays on torManager.isSocksReady(); RelayProxyClientConnector already reconnects them with ignoreRetryDelays=true the instant Tor flips to Active, so they dial as soon as the transport is usable. Measured on-device: pre-ready doomed Tor dials 581 -> 0 across cold starts and WiFi<->Mobile switches; clearnet connections stay untouched and Tor relays self-heal once Tor is Active. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 17 +++++++++++++---- .../amethyst/service/okhttp/OkHttpWebSocket.kt | 4 ++++ .../client/single/basic/BasicRelayClient.kt | 6 ++++++ .../nip01Core/relay/sockets/WebsocketBuilder.kt | 15 +++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 733ec3a8ce..80466dcc26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -427,10 +427,19 @@ class AppModules( // Connects the INostrClient class with okHttp val websocketBuilder = - OkHttpWebSocket.Builder { url -> - val useTor = torEvaluatorFlow.shouldUseTorForRelay(url) - okHttpClientForRelays.getHttpClient(useTor) - } + OkHttpWebSocket.Builder( + httpClient = { url -> + val useTor = torEvaluatorFlow.shouldUseTorForRelay(url) + okHttpClientForRelays.getHttpClient(useTor) + }, + // Don't dial Tor-routed relays until Tor's SOCKS port is up. Otherwise the + // whole Tor-routed relay set is hammered with doomed dials against the dead + // proxy during bootstrap. RelayProxyClientConnector reconnects them (with + // ignoreRetryDelays=true) the instant Tor flips to Active. + canDial = { url -> + !torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady() + }, + ) // Caches all events in Memory val cache: LocalCache = LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 9f2ad864a6..c89de32554 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -133,12 +133,16 @@ class OkHttpWebSocket( class Builder( val httpClient: (NormalizedRelayUrl) -> OkHttpClient, + val canDial: (NormalizedRelayUrl) -> Boolean = { true }, ) : WebsocketBuilder { // Called when connecting. override fun build( url: NormalizedRelayUrl, out: WebSocketListener, ) = OkHttpWebSocket(url, httpClient, out) + + // Gates the dial — false skips it (e.g. a Tor-routed relay before Tor is ready). + override fun canConnect(url: NormalizedRelayUrl) = canDial(url) } override fun disconnect() { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 3ffaca6d92..dfa0863451 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -103,6 +103,12 @@ open class BasicRelayClient( override fun needsToReconnect() = socket?.needsReconnect() ?: true override fun connect() { + // Transport gate: skip the dial when the builder reports this relay's transport + // isn't ready (e.g. a Tor-routed relay while Tor's SOCKS port isn't up). Returning + // here before the mutex/socket/onConnecting means no doomed dial and no backoff + // growth; a later reconnect pass (fired when the transport becomes ready) will dial. + if (!socketBuilder.canConnect(url)) return + // If there is a connection, don't wait. if (connectingMutex.exchange(true)) { return diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt index aef749f774..6e5619cdcd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt @@ -27,4 +27,19 @@ interface WebsocketBuilder { url: NormalizedRelayUrl, out: WebSocketListener, ): WebSocket + + /** + * Whether the transport for [url] is ready to dial right now. Returning false makes + * [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.connect] + * skip the dial entirely — no socket, no backoff growth — until a later reconnect pass + * finds it ready. + * + * The motivating case: a Tor-routed relay while Tor's SOCKS proxy isn't up yet. Without + * this gate the pool hammers the dead proxy with doomed dials during the whole Tor + * bootstrap window. The caller is responsible for re-triggering a reconnect once the + * transport becomes ready (e.g. on the Tor status flipping to Active). + * + * Defaults to true so non-proxied builders need no change. + */ + fun canConnect(url: NormalizedRelayUrl): Boolean = true } From 543419c3ef79c6d26ca6a474f4cb3e8d36ff78b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 13:04:14 +0000 Subject: [PATCH 08/10] refactor: let ThreadFilterSubAssembler own root resolution for reply preload Pass the reply's id straight to the thread subscription instead of resolving the root in composition. ThreadFilterSubAssembler already runs findRoot in updateFilter, so the extra compose-side findRoot was duplicate work; gate on replyTo so only replies (not roots or quotes) pre-load. --- .../ThreadFilterAssemblerSubscription.kt | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt index 75e206a528..78d03ffc58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssemblerSubscription.kt @@ -22,10 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.commons.model.ThreadAssembler import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -59,25 +57,20 @@ fun ThreadFilterAssemblerSubscription( /** * Eagerly pre-loads the whole thread of a reply that is visible in a feed. * - * When a reply shows up in `NoteCompose`, this resolves the thread root and opens - * the same root subscription the thread screen uses (a filter on the root's `e`/`a` - * tag, covering NIP-10 and NIP-22 event/addressable roots), so tapping into the - * conversation finds it already loaded. Keying on the resolved root id means every - * visible reply that shares a root collapses onto a single subscription, and a note - * that is itself a root (no parent) is skipped — nothing to pre-load. + * When a reply shows up in `NoteCompose`, this opens the same root subscription the + * thread screen uses: `ThreadFilterSubAssembler` resolves the thread root from this + * id and subscribes to the root's `e`/`a` tag (covering NIP-10 and NIP-22 event / + * addressable roots), so tapping into the conversation finds it already loaded. + * + * Only replies are pre-loaded — a root post (empty `replyTo`) has no ancestor thread + * to pull, and pure quotes are excluded because citations don't populate `replyTo`. */ @Composable fun PreloadThreadForReply( note: Note, accountViewModel: AccountViewModel, ) { - val rootId = - remember(note) { - val root = ThreadAssembler(LocalCache).findRoot(note.idHex) - if (root != null && root != note) root.idHex else null - } - - if (rootId != null) { - ThreadFilterAssemblerSubscription(rootId, accountViewModel) + if (note.replyTo?.isNotEmpty() == true) { + ThreadFilterAssemblerSubscription(note.idHex, accountViewModel) } } From be5cfc150123f626305b1149738129ab37370f69 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 16 Jun 2026 09:37:55 -0400 Subject: [PATCH 09/10] fix(commons): stop RelayHealthStoreCloseTest livelocking the test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordIncoming_after_close_does_not_schedule_persist called advanceUntilIdle() while RelayHealthStore's init ticker (`while(true){ reclassify(); delay(60s) }`) was still live on the shared StandardTestDispatcher scheduler. advanceUntilIdle() chases that periodic delay forever, so the test spun at 100% CPU and never returned — wedging :commons:jvmTest at "373 tests completed" and hanging the pre-push hook (and leaving orphaned, CPU-pegging Gradle test workers behind). Advance just past PERSIST_DEBOUNCE_MS and runCurrent() instead, so init's debounced save fires for the baseline while the 60s ticker stays parked. The post-close advanceUntilIdle() calls are fine — close() cancels the ticker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commons/relays/health/RelayHealthStoreCloseTest.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt index 7f1be49b0f..52c5332f45 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt @@ -27,7 +27,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.concurrent.Volatile import kotlin.test.Test @@ -87,7 +89,12 @@ class RelayHealthStoreCloseTest { ioDispatcher = dispatcher, ) - advanceUntilIdle() + // NB: the store's init launches an always-on `while(true){ reclassify(); delay(60s) }` + // ticker on this shared test scheduler. advanceUntilIdle() would chase that periodic + // delay forever (livelock). Advance just past the persist debounce instead so init's + // debounced save fires while the ticker stays parked at its 60s mark. + advanceTimeBy(RelayHealthStore.PERSIST_DEBOUNCE_MS + 1) + runCurrent() val baseline = persistence.saves store.close() From 67a39ac51ee015f7364a0389a9a373aea27954c6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 16 Jun 2026 19:39:20 +0200 Subject: [PATCH 10/10] update cs,sv,de,pt --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 3 +++ amethyst/src/main/res/values-de-rDE/strings.xml | 3 +++ amethyst/src/main/res/values-pt-rBR/strings.xml | 3 +++ amethyst/src/main/res/values-sv-rSE/strings.xml | 3 +++ 4 files changed, 12 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6947218548..5345c1339c 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -3274,4 +3274,7 @@ Nowhere Drop Nowhere Umění Nowhere Fórum + Klesání + Maximální tepová frekvence + Rychlost diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 3b4dee70cb..910c167fd7 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3213,4 +3213,7 @@ anz der Bedingungen ist erforderlich Nowhere Drop Nowhere Kunst Nowhere Forum + Höhenverlust + Maximale Herzfrequenz + Geschwindigkeit diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 3e14b557bf..8402bf8666 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3208,4 +3208,7 @@ Nowhere Drop Nowhere Arte Nowhere Fórum + Perda de elevação + Frequência cardíaca máxima + Velocidade diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 5abfd28e60..e20f44e44f 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3207,4 +3207,7 @@ Nowhere Drop Nowhere Konst Nowhere Forum + Höjdminskning + Maxpuls + Hastighet