feat: in-stream per-relay paging markers for NIP-04 conversations

Visualises the per-relay-independent engine: each relay gets a thin
marker in the message stream at the depth (createdAt) it has paged down
to, sitting just below the oldest message it has loaded. As a relay
pages older its reached-back cursor drops and the marker slides down; a
relay that races ahead leaves its marker deep while slower relays' trail
higher and converge as they catch up — ✓ done (empty-EOSE), … stalled
(auth CLOSE / unreachable, still trying), ↓ reaching. Hidden once the
conversation is fully converged (every relay done or stalled).

Adds an optional markersInGap slot to the shared chat feed view
(no-op for public-chat callers) invoked per message gap with its
createdAt bounds; ChatroomView renders the markers from the
relayProgress flow.

https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
This commit is contained in:
Claude
2026-06-03 00:39:00 +00:00
parent cbee966bb7
commit afa02df3a0
3 changed files with 176 additions and 0 deletions
@@ -64,6 +64,10 @@ fun RefreshingChatroomFeedView(
// Optional footer rendered at the oldest end of the thread (a "load more" / spinner affordance).
// Null for callers that load their whole history at once (public chats / channels).
olderBoundary: (@Composable () -> Unit)? = null,
// Optional per-gap hook: invoked between each message and its next-older neighbour with their
// createdAt bounds, so a caller (private DMs) can draw per-relay paging markers at the depth each
// relay has reached. No-op for callers without per-relay progress.
markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null,
) {
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
listStateObserver(listState)
@@ -77,6 +81,7 @@ fun RefreshingChatroomFeedView(
onWantsToEditDraft,
avoidDraft,
olderBoundary,
markersInGap,
)
}
}
@@ -92,6 +97,7 @@ fun RenderChatFeedView(
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: DraftTagState? = null,
olderBoundary: (@Composable () -> Unit)? = null,
markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null,
) {
val feedState by feed.feedContent.collectAsStateWithLifecycle()
@@ -120,6 +126,7 @@ fun RenderChatFeedView(
onWantsToEditDraft,
avoidDraft,
olderBoundary,
markersInGap,
)
}
}
@@ -137,6 +144,7 @@ fun ChatFeedLoaded(
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: DraftTagState? = null,
olderBoundary: (@Composable () -> Unit)? = null,
markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -180,6 +188,17 @@ fun ChatFeedLoaded(
)
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
// Per-relay paging markers belonging in the gap toward the next-older message. With the
// reverse layout this draws just above the message (the older side), so a relay's marker
// appears right below the oldest message it has reached and slides down as it pages.
markersInGap?.invoke(
item.event?.createdAt,
items.list
.getOrNull(index + 1)
?.event
?.createdAt,
)
}
}
@@ -0,0 +1,101 @@
/*
* 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.chats.feed.layouts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
/** How far one relay has paged into the conversation, for an in-stream progress marker. */
enum class RelayReachState {
// Still paging older — its marker slides down (older) as it advances.
REACHING,
// Accepted but not answering right now (auth CLOSE / unreachable / slow); kept open, still trying.
STALLED,
// Hit an empty page: nothing older on this relay, it has reached the bottom of its window.
DONE,
}
/** One relay's marker entry within a gap. */
data class RelayReach(
val name: String,
val state: RelayReachState,
)
/**
* A thin divider drawn between two messages marking the point one or more relays have paged down to.
* As a relay loads older history its [RelayReach.reachedUntil][reached cursor] drops, so the caller
* places this marker further 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.
*/
@Composable
fun RelayReachMarker(entries: List<RelayReach>) {
if (entries.isEmpty()) return
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = HalfPadding,
) {
HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness)
// Group by state so a gap shared by several relays reads as e.g. "✓ vitor, nos.lol ↓ wine".
entries
.groupBy { it.state }
.toSortedMap(compareBy { it.ordinal })
.forEach { (state, list) ->
Text(
text = glyph(state) + " " + list.joinToString(", ") { it.name },
color = color(state),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
)
}
HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness)
}
}
private fun glyph(state: RelayReachState) =
when (state) {
RelayReachState.REACHING -> ""
RelayReachState.STALLED -> ""
RelayReachState.DONE -> ""
}
@Composable
private fun color(state: RelayReachState): Color =
when (state) {
RelayReachState.REACHING -> MaterialTheme.colorScheme.onSurfaceVariant
RelayReachState.STALLED -> MaterialTheme.colorScheme.error
RelayReachState.DONE -> MaterialTheme.colorScheme.primary
}
@@ -50,12 +50,17 @@ import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReach
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachMarker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.RelayPagingProgress
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.utils.Log
@@ -230,6 +235,7 @@ fun ChatroomViewUI(
val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle()
val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle()
val nip04Reached by nip04History.reachedBack.collectAsStateWithLifecycle()
val nip04Progress by nip04History.relayProgress.collectAsStateWithLifecycle()
val nip17Name = stringResource(R.string.chats_history_proto_nip17)
val nip04Name = stringResource(R.string.chats_history_proto_nip04)
@@ -259,6 +265,15 @@ fun ChatroomViewUI(
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached)
}
},
// While NIP-04 is still converging, drop a marker into each gap for every relay whose
// reached-back cursor falls there: it sits below the oldest message that relay has loaded
// and slides down as the relay pages older. Hidden once every relay is done or stalled.
markersInGap =
if (nip04Exhausted) {
null
} else {
{ newer, older -> RelayReachMarkersInGap(nip04Progress, newer, older) }
},
listStateObserver = { listState ->
LoadOlderMessagesWhenScrolling(listState, accountViewModel)
},
@@ -282,3 +297,44 @@ fun ChatroomViewUI(
)
}
}
/**
* Renders the NIP-04 paging markers that belong between a message (at [newerCreatedAt]) and its
* next-older neighbour (at [olderCreatedAt], null at the oldest end): every relay whose reached-back
* cursor falls in `(olderCreatedAt, newerCreatedAt]`. A relay sits below the oldest message it has
* loaded, so as it pages older its cursor drops and the marker moves down the stream toward the others.
*/
@Composable
private fun RelayReachMarkersInGap(
progress: Map<NormalizedRelayUrl, RelayPagingProgress>,
newerCreatedAt: Long?,
olderCreatedAt: Long?,
) {
val here =
remember(progress, newerCreatedAt, olderCreatedAt) {
progress.mapNotNull { (relay, p) ->
val reached = p.reachedUntil
val belongsHere = newerCreatedAt != null && newerCreatedAt > reached && (olderCreatedAt == null || olderCreatedAt <= reached)
if (!belongsHere) {
null
} else {
RelayReach(
name = relayShortName(relay),
state =
when {
p.done -> RelayReachState.DONE
p.stalled -> RelayReachState.STALLED
else -> RelayReachState.REACHING
},
)
}
}
}
RelayReachMarker(here)
}
private fun relayShortName(relay: NormalizedRelayUrl): String =
relay.url
.substringAfter("://")
.trimEnd('/')
.substringBefore('/')