From c8f43a1bd4eb54fadbfb79cac795cd434c5d11d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:36:43 +0000 Subject: [PATCH] feat(notifications): auto-retry faulty relays + actionable relay detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve the notifications history UX around slow/unreachable relays, keeping the per-relay markers (they let users notice their own bad relays) but making recovery automatic and the tap-through actionable. - Auto-retry stalled relays with backoff (~3s→30s): once the buffer driver stops (every relay done-or-stalled) but some are merely stalled, keep re-advancing them so recovery no longer depends on the user scrolling to the marker or reopening the screen. One non-restarting effect so the backoff survives the transient in-flight blips each retry causes; cancels on leave. - Add a "Try Again" action to RelayReachDetailDialog (shared): when a caller passes onRetry and a relay is stalled, the tapped marker's detail popup offers an active retry and drops the now-inaccurate "retries on reopen" hint. Notifications wire it to advanceAll; DM callers pass nothing (unchanged). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f --- .../loggedIn/notifications/CardFeedView.kt | 29 ++++++++++++++++++- .../commons/ui/feeds/DmHistoryLoadingCard.kt | 25 ++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 6f43b4624a..605cade9b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -92,6 +92,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first @Composable fun RenderCardFeed( @@ -204,6 +205,26 @@ private fun FeedLoaded( if (shouldLoadMore && !loadingMore && !exhausted) history.advanceAll() } + // Auto-retry faulty relays with backoff. The buffer driver above stops once every relay is done-or- + // stalled (exhausted); when some are merely stalled (a slow/unreachable relay, not a real end) this + // keeps re-advancing them so recovery doesn't depend on the user scrolling to the marker or reopening. + // A single non-restarting effect so the backoff survives the transient in-flight blips each retry causes. + LaunchedEffect(history) { + var backoffMs = STALLED_RETRY_MIN_MS + while (true) { + history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay + while (true) { + delay(backoffMs) + val s = history.status.value + if (!(s.exhausted && s.stalledCount > 0)) break // recovered (a relay answered, or scroll retried) + history.advanceAll() + history.loadingMore.first { !it } // let the retry settle before escalating + backoffMs = (backoffMs * 2).coerceAtMost(STALLED_RETRY_MAX_MS) + } + backoffMs = STALLED_RETRY_MIN_MS // reset for the next stall + } + } + // One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls // its next page. A done relay's marker sinks to the oldest end reading "fully loaded". val limits = @@ -228,7 +249,8 @@ private fun FeedLoaded( // The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup. var syncDetail by remember { mutableStateOf?>(null) } syncDetail?.let { detail -> - RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + // Tap-through offers a Try Again on stalled relays, so a user who sees a bad relay can act on it. + RelayReachDetailDialog(detail, ::formatHistoryReachDate, onRetry = { history.advanceAll() }) { syncDetail = null } } StickToTopOnPrepend(listState, items.list.firstOrNull()?.id()) @@ -383,6 +405,11 @@ private const val BOOTSTRAP_DEBOUNCE_MS = 1200L // Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom. private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100 +// Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall, +// doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own. +private const val STALLED_RETRY_MIN_MS = 3_000L +private const val STALLED_RETRY_MAX_MS = 30_000L + private fun reachState(p: RelayPagingProgress): RelayReachState = when { p.done -> RelayReachState.DONE diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt index e32444f27c..bc308cc97b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.action_dismiss +import com.vitorpamplona.amethyst.commons.resources.action_try_again import com.vitorpamplona.amethyst.commons.resources.chats_history_all_caught_up import com.vitorpamplona.amethyst.commons.resources.chats_history_by_relay import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete @@ -377,14 +378,32 @@ private fun relayShortName(relay: NormalizedRelayUrl): String = fun RelayReachDetailDialog( cursors: List, formatReachDate: (epochSeconds: Long) -> String, + // When provided and at least one relay is stalled, the dialog offers a "Try Again" action that + // re-advances the stalled relays on demand (and suppresses the "retries on reopen" hint, since the + // caller retries actively). Null keeps the passive, dismiss-only dialog (the DM callers). + onRetry: (() -> Unit)? = null, onDismiss: () -> Unit, ) { val rows = remember(cursors) { cursors.sortedBy { it.reachedUntil } } + val showRetry = onRetry != null && rows.any { it.state == RelayReachState.STALLED } AlertDialog( onDismissRequest = onDismiss, confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + if (showRetry) { + TextButton(onClick = { + onRetry?.invoke() + onDismiss() + }) { Text(stringResource(Res.string.action_try_again)) } + } else { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + } }, + dismissButton = + if (showRetry) { + { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } } + } else { + null + }, title = { Text(stringResource(Res.string.chats_history_by_relay)) }, text = { Column( @@ -425,7 +444,9 @@ fun RelayReachDetailDialog( maxLines = 1, ) } - if (c.state == RelayReachState.STALLED) StalledRetryHint() + // The passive "retries on reopen" caption only applies without an active Try Again + // action; with one, the button (and the caller's auto-retry) covers it. + if (c.state == RelayReachState.STALLED && onRetry == null) StalledRetryHint() } } }