diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index ab351e4692..d4a1cc2737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -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, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt new file mode 100644 index 0000000000..35fceb8470 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -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) { + 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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index e4059b7cac..8878f4e4d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -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, + 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('/')