feat(notifications): auto-retry faulty relays + actionable relay detail

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
This commit is contained in:
Claude
2026-07-20 20:36:43 +00:00
parent cc94ef9103
commit c8f43a1bd4
2 changed files with 51 additions and 3 deletions
@@ -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<List<RelayReachCursor>?>(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
@@ -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<RelayReachCursor>,
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()
}
}
}