mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+19
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+101
@@ -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
|
||||
}
|
||||
+56
@@ -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('/')
|
||||
|
||||
Reference in New Issue
Block a user