Improves markers

This commit is contained in:
Vitor Pamplona
2026-06-09 13:47:46 -04:00
parent d6603baf1a
commit b21aecdfca
4 changed files with 162 additions and 78 deletions
@@ -31,7 +31,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -41,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
@@ -193,12 +196,12 @@ private fun FeedLoaded(
buildList {
if (!giftWrapsExhausted) {
giftWrapsProgress.forEach { (relay, p) ->
add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(relay) })
add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) })
}
}
if (!nip04Exhausted) {
nip04Progress.forEach { (relay, p) ->
add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) })
add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) })
}
}
}
@@ -208,6 +211,14 @@ private fun FeedLoaded(
// (a live DM bumping a room) no longer re-fire paging. The markers below are pure UI.
RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() }
// The relays behind a tapped in-stream "Relay sync" marker; non-null shows the per-relay popup so the
// terse divider isn't a dead end — every count/name is one tap from the full breakdown (which relays,
// protocol, how far back each paged).
var syncDetail by remember { mutableStateOf<List<RelayReachCursor>?>(null) }
syncDetail?.let { detail ->
RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null }
}
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
@@ -244,7 +255,7 @@ private fun FeedLoaded(
limits,
item.createdAt(),
items.list.getOrNull(index + 1)?.createdAt(),
)
) { syncDetail = it }
}
}
}
@@ -56,17 +56,21 @@
<string name="accessibility_navigate">Navigate</string>
<!-- Relay history paging (shared feed markers + status card) -->
<string name="chats_history_relay_sync">Relay sync:</string>
<string name="chats_history_loading_label">Loading:</string>
<string name="chats_history_fully_loaded_label">Fully loaded:</string>
<string name="chats_history_fully_loaded">(fully loaded)</string>
<string name="chats_history_by_relay">History by relay</string>
<string name="chats_history_stalled_retry">Will retry when you reopen this screen</string>
<string name="chats_history_older">Older %1$s messages</string>
<string name="chats_history_all_caught_up">All caught up</string>
<string name="chats_history_reached_start">Reached the start of your %1$s messages</string>
<string name="chats_history_subtitle">%1$s · %2$s · back to %3$s</string>
<string name="chats_history_subtitle">%1$s · %2$s · loaded since %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">waiting on %1$s</string>
<string name="chats_history_incomplete">Some relays didn\'t respond</string>
<string name="chats_history_incomplete_sub">%1$s unreachable · tap to see which</string>
<string name="chats_history_relays_title">%1$s · history by relay</string>
<string name="chats_history_relay_back">back to %1$s</string>
<string name="chats_history_relay_since">since %1$s</string>
<string name="action_dismiss">Dismiss</string>
<plurals name="chats_history_relays">
<item quantity="one">%1$d relay</item>
@@ -60,14 +60,15 @@ 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.chats_history_all_caught_up
import com.vitorpamplona.amethyst.commons.resources.chats_history_by_relay
import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete
import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete_sub
import com.vitorpamplona.amethyst.commons.resources.chats_history_older
import com.vitorpamplona.amethyst.commons.resources.chats_history_reached_start
import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_back
import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_sync
import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_since
import com.vitorpamplona.amethyst.commons.resources.chats_history_relays
import com.vitorpamplona.amethyst.commons.resources.chats_history_relays_title
import com.vitorpamplona.amethyst.commons.resources.chats_history_stalled_retry
import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle
import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle_no_date
import com.vitorpamplona.amethyst.commons.resources.chats_history_waiting
@@ -277,7 +278,8 @@ fun historySubtitle(
/**
* Popup shown when the history card is tapped: one row per relay with its state glyph (✓ done, … stalled,
* ↓ still reaching) and how far back it has paged ("back to <date>"), deepest-reaching first.
* ↓ still reaching) and how far back it has paged ("since <date>"), deepest-reaching first. A stalled relay
* also gets a one-line hint that it retries when the screen is reopened.
*/
@Composable
fun DmHistoryRelayDialog(
@@ -300,32 +302,30 @@ fun DmHistoryRelayDialog(
.verticalScroll(rememberScrollState()),
) {
rows.forEach { (relay, p) ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = relayStateGlyph(p),
color = relayStateColor(p),
fontWeight = FontWeight.Bold,
modifier = Modifier.width(22.dp),
)
Text(
text = relayShortName(relay),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(Res.string.chats_history_relay_back, formatReachDate(p.reachedUntil)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = relayStateGlyph(p),
color = relayStateColor(p),
fontWeight = FontWeight.Bold,
modifier = Modifier.width(22.dp),
)
Text(
text = relayShortName(relay),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(Res.string.chats_history_relay_since, formatReachDate(p.reachedUntil)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
if (p.stalled) StalledRetryHint()
}
}
}
@@ -333,6 +333,19 @@ fun DmHistoryRelayDialog(
)
}
/** The "stopped early, retries on reopen" caption shown under a stalled relay in the per-relay popups.
* Retry is demand-driven (no timer): reopening the screen re-binds and clears the stalled set, which
* retries it — so that's what we tell the user rather than a countdown we can't honour. */
@Composable
private fun StalledRetryHint() {
Text(
text = stringResource(Res.string.chats_history_stalled_retry),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 22.dp, top = 2.dp),
)
}
private fun relayStateGlyph(p: RelayPagingProgress) =
when {
p.done -> "✓"
@@ -355,10 +368,10 @@ private fun relayShortName(relay: NormalizedRelayUrl): String =
.substringBefore('/')
/**
* Popup shown when an in-stream "Relay sync" marker is tapped: the relays whose window sits at that point
* Popup shown when an in-stream "Loading" marker is tapped: the relays whose window sits at that point
* in the stream, each with its protocol tag, state glyph (✓ done · … stalled · ↓ reaching) and how far
* back it has paged — so the otherwise-terse `Relay sync: ✓ N` divider stops being a dead end and its
* meaning is explorable. Deepest-reaching first.
* back it has paged — so the otherwise-terse `Loading: ↓ N` divider stops being a dead end and its
* meaning is explorable. A stalled relay also gets the retry-on-reopen hint. Deepest-reaching first.
*/
@Composable
fun RelayReachDetailDialog(
@@ -372,7 +385,7 @@ fun RelayReachDetailDialog(
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) }
},
title = { Text(stringResource(Res.string.chats_history_relay_sync)) },
title = { Text(stringResource(Res.string.chats_history_by_relay)) },
text = {
Column(
Modifier
@@ -380,41 +393,39 @@ fun RelayReachDetailDialog(
.verticalScroll(rememberScrollState()),
) {
rows.forEach { c ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = reachGlyph(c.state),
color = reachColor(c.state),
fontWeight = FontWeight.Bold,
modifier = Modifier.width(22.dp),
)
if (c.protocol.isNotEmpty()) {
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = c.protocol,
style = MaterialTheme.typography.labelSmall,
text = reachGlyph(c.state),
color = reachColor(c.state),
fontWeight = FontWeight.Bold,
modifier = Modifier.width(22.dp),
)
if (c.protocol.isNotEmpty()) {
Text(
text = c.protocol,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
Spacer(Modifier.width(6.dp))
}
Text(
text = c.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(Res.string.chats_history_relay_since, formatReachDate(c.reachedUntil)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
Spacer(Modifier.width(6.dp))
}
Text(
text = c.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(Res.string.chats_history_relay_back, formatReachDate(c.reachedUntil)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
if (c.state == RelayReachState.STALLED) StalledRetryHint()
}
}
}
@@ -42,10 +42,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_sync
import com.vitorpamplona.amethyst.commons.resources.chats_history_fully_loaded
import com.vitorpamplona.amethyst.commons.resources.chats_history_fully_loaded_label
import com.vitorpamplona.amethyst.commons.resources.chats_history_loading_label
import com.vitorpamplona.amethyst.commons.resources.chats_history_relays
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
// A relay-reach divider is hair-thin; inlined here so the shared component carries no app-theme dep.
@@ -171,6 +175,13 @@ fun RelayReachSentinels(
* (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end). Pure
* UI: the load driving lives in [RelayReachSentinels], so this can be (re)placed freely per row on
* every feed reorder without triggering any paging.
*
* A [DONE][RelayReachState.DONE] relay has no incompleteness frontier — it has loaded everything it has —
* so it does NOT mark its own history bottom mid-stream (which would read like a false "incomplete below
* here" line). Instead every done relay sinks to the **oldest-end gap** ([olderCreatedAt] null), where it
* renders as one "fully loaded" marker. Only [REACHING][RelayReachState.REACHING] /
* [STALLED][RelayReachState.STALLED] relays — the genuine "below here may still be incomplete" frontiers —
* are placed at their reached cursor.
*/
@Composable
fun RelayReachMarkers(
@@ -183,7 +194,14 @@ fun RelayReachMarkers(
) {
val here =
remember(limits, newerCreatedAt, olderCreatedAt) {
limits.filter { reachedFallsInGap(it.reachedUntil, newerCreatedAt, olderCreatedAt) }
limits.filter {
if (it.state == RelayReachState.DONE) {
// Fully loaded → sink to the oldest end rather than mark a frontier it doesn't have.
newerCreatedAt != null && olderCreatedAt == null
} else {
reachedFallsInGap(it.reachedUntil, newerCreatedAt, olderCreatedAt)
}
}
}
if (here.isEmpty()) return
@@ -196,11 +214,16 @@ fun RelayReachMarkers(
* down (older) in the stream — relays that race ahead leave their marker deep while slower relays'
* markers trail higher up, converging as they catch up.
*
* A leading "Relay sync:" label gives the glyphs context; then each state renders one compact label:
* a relay's host name when it is the only one of its state there (the usual converged case, where each
* relay sits at its own depth), or just a count when several pile up at the same depth (e.g. all nine
* clustered at the live-tail floor on first open) so the line can't grow into an unreadable comma list.
* Reads e.g. "Relay sync: ✓ 8 · ↓ 1" or "Relay sync: ↓ nostr.wine".
* The line is always captioned so it's never a bare glyph cluster: the live frontiers
* ([REACHING][RelayReachState.REACHING] / [STALLED][RelayReachState.STALLED]) read "Loading:"; the
* oldest-end pile of [DONE][RelayReachState.DONE] relays reads "Fully loaded:". Each state then renders
* one compact label: the host name(s) when one — or two short-named — relays sit at that state (the usual
* converged case, where each relay rests at its own depth), or just a count when several pile up at the
* same depth (e.g. all nine clustered at the oldest-end floor) so the line can't grow into an unreadable
* comma list. In the rare mixed line (an active frontier sharing the oldest-end gap with done relays) the
* caption is "Loading:", so the done chip is suffixed "(fully loaded)" to keep its meaning clear. Either
* way the whole marker is tappable for the full per-relay breakdown. Reads e.g. "Loading: ↓ nostr.wine"
* or "Fully loaded: ✓ 8".
*/
@Composable
private fun RelayReachMarker(
@@ -209,14 +232,21 @@ private fun RelayReachMarker(
) {
if (entries.isEmpty()) return
val hasActiveFrontier = entries.any { it.state != RelayReachState.DONE }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(5.dp).then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
) {
HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness)
// Always caption the line so it's never a bare glyph cluster: live frontiers are "Loading:"; the
// oldest-end pile of only-done relays is "Fully loaded:".
Text(
text = stringResource(Res.string.chats_history_relay_sync),
text =
stringResource(
if (hasActiveFrontier) Res.string.chats_history_loading_label else Res.string.chats_history_fully_loaded_label,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
@@ -236,8 +266,21 @@ private fun RelayReachMarker(
if (index > 0) {
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
}
// Spell out 1–2 short host names; otherwise a count. Done relays count as "N relays" so the
// fully-loaded floor reads as a sentence ("✓ 8 relays"), not a bare number; active frontiers
// stay terse ("↓ 1"). Only a mixed line (caption "Loading:") needs the done chip tagged
// "(fully loaded)" — a pure-done line already says so in its "Fully loaded:" caption.
val names = list.map { it.name }
val inlineNames = reachInlineNames(names)
val label =
when {
inlineNames != null -> inlineNames
state == RelayReachState.DONE -> pluralStringResource(Res.plurals.chats_history_relays, names.size, names.size)
else -> names.size.toString()
}
val chip = reachGlyph(state) + " " + label
Text(
text = reachGlyph(state) + " " + if (list.size == 1) list.first().name else list.size.toString(),
text = if (state == RelayReachState.DONE && hasActiveFrontier) chip + " " + stringResource(Res.string.chats_history_fully_loaded) else chip,
color = reachColor(state),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
@@ -249,6 +292,21 @@ private fun RelayReachMarker(
}
}
// Host names short enough to spell out inline on the single-line divider instead of collapsing to a
// bare count: a name up to [INLINE_NAME_MAX] when it's the lone relay of its state, or two names each
// up to [INLINE_TWO_NAMES_MAX] when a pair shares it. Longer hosts, or 3+ relays at one state, return
// null so the caller renders a count instead and the line can't grow unbounded — the tap-through dialog
// always lists them all.
private const val INLINE_NAME_MAX = 16
private const val INLINE_TWO_NAMES_MAX = 12
internal fun reachInlineNames(names: List<String>): String? =
when {
names.size == 1 && names[0].length <= INLINE_NAME_MAX -> names[0]
names.size == 2 && names.all { it.length <= INLINE_TWO_NAMES_MAX } -> names.joinToString(", ")
else -> null
}
internal fun reachGlyph(state: RelayReachState) =
when (state) {
RelayReachState.REACHING -> "↓"