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/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..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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -52,3 +53,24 @@ 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 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, +) { + if (note.replyTo?.isNotEmpty() == true) { + ThreadFilterAssemblerSubscription(note.idHex, accountViewModel) + } +} 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/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index 765fd34d30..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 @@ -20,16 +20,18 @@ */ 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 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(), @@ -130,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 @@ -137,18 +175,58 @@ 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 { + Column(modifier = Modifier.weight(1f)) { Text( text = info.title ?: typeLabel, fontWeight = FontWeight.Bold, @@ -162,73 +240,162 @@ fun WorkoutDisplay(baseNote: Note) { ) } } + + info.source?.let { + Spacer(modifier = Modifier.width(8.dp)) + WorkoutSourceBadge(it) + } } - 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) { - 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) { + add(Stat(speed(duration, distance), speedLabel)) + } else { + 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.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.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)) + } + } } } } +/** 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, label: String, + modifier: Modifier = Modifier, ) { - Column { + Column(modifier = modifier) { Text( text = value, fontWeight = FontWeight.Bold, 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, - ) - }, ) } } 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 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 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..52c5332f45 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStoreCloseTest.kt @@ -0,0 +1,130 @@ +/* + * 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.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +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, + ) + + // 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() + 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..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 = @@ -1441,6 +1453,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/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/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 }