diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 0a778af9a5..96824b0e33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelayFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler @@ -129,7 +130,8 @@ class RelaySubscriptionsCoordinator( // assembler above (same as NIP-28 public chats), so only the group-specific surfaces get their // own here. val relayGroupsOnRelay = RelayGroupsOnRelayFilterAssembler(client) // browsing one relay's channel list - val relayGroupOpenThreads = RelayGroupOpenThreadsFilterAssembler(client) // a group's forum-threads tab + val relayGroupOpenThreads = RelayGroupOpenThreadsFilterAssembler(client) // a group's forum-threads tab (recent tail) + val relayGroupOpenThreadsHistory = RelayGroupOpenThreadsHistoryFilterAssembler(client) // the Threads tab's backward history pager val relayGroupCardWarmup = RelayGroupCardWarmupFilterAssembler(client) // prefetching a group before it's opened val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed @@ -211,6 +213,7 @@ class RelaySubscriptionsCoordinator( listOf( relayGroupsOnRelay, relayGroupOpenThreads, + relayGroupOpenThreadsHistory, relayGroupCardWarmup, relayGroupsDiscovery, relayGroupJoinedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index d961a88482..20d640e620 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -30,7 +30,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -38,11 +40,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -58,6 +63,8 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistorySubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistorySubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp @@ -65,6 +72,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter /** * A group's forum-style threads (kind 11) — the secondary content type kept out of @@ -93,9 +103,19 @@ private fun RelayGroupThreads( accountViewModel: AccountViewModel, nav: INav, ) { + // Recent live tail + on-demand backward history, the Threads analog of the chat stack. Without the + // pager a group with more threads than the relay's default result cap would silently hide the older ones. RelayGroupOpenThreadsSubscription(channel, accountViewModel.dataSources().relayGroupOpenThreads, accountViewModel) + val historySource = accountViewModel.dataSources().relayGroupOpenThreadsHistory + RelayGroupOpenThreadsHistorySubscription(channel.groupId, historySource, accountViewModel) val threads by channel.threads.collectAsStateWithLifecycle() + val history = remember(historySource) { historySource.history } + val loadingOlder by history.loadingMore.collectAsStateWithLifecycle() + val status by history.status.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + RelayGroupThreadsPaging(threadCount = { threads.size }, listState = listState, history = history) // Only members can post a thread (the relay rejects a non-member's kind-11), so the // compose FAB is hidden for everyone else. @@ -156,18 +176,80 @@ private fun RelayGroupThreads( ) } } else { - LazyColumn(modifier = Modifier.padding(padding)) { + LazyColumn(state = listState, modifier = Modifier.padding(padding)) { itemsIndexed(threads, key = { _, thread -> thread.idHex }) { index, thread -> if (index > 0) { HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) } } + item(key = "threads-history-footer") { + RelayGroupThreadsHistoryFooter(loadingOlder, status.exhausted) + } } } } } +/** How many threads to eagerly backfill on open before paging goes demand-driven, and the scroll lead. */ +private const val RELAY_GROUP_THREADS_TARGET = 30 +private const val RELAY_GROUP_THREADS_PREFETCH_AHEAD = 5 + +/** + * Drives the Threads backward pager: eagerly backfill to a window on open (so a group with deep history + * doesn't show just its last few threads), then page older content demand-driven as the list nears its end. + * Mirrors the chat screen's `RelayGroupBackfillHistoryToWindow` + reach sentinels, on the plain thread list. + */ +@Composable +private fun RelayGroupThreadsPaging( + threadCount: () -> Int, + listState: LazyListState, + history: RelayGroupOpenThreadsHistorySubAssembler, +) { + LaunchedEffect(history) { + combine(snapshotFlow { threadCount() }, history.loadingMore, history.status) { count, loading, s -> + count < RELAY_GROUP_THREADS_TARGET && !loading && !s.exhausted + }.distinctUntilChanged() + .filter { it } + .collect { history.advanceAll() } + } + LaunchedEffect(history, listState) { + snapshotFlow { + val last = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 + val total = threadCount() + total > 0 && last >= total - RELAY_GROUP_THREADS_PREFETCH_AHEAD + }.distinctUntilChanged() + .filter { it } + .collect { + if (!history.status.value.exhausted && !history.loadingMore.value) history.advanceAll() + } + } +} + +/** A quiet footer at the bottom of the thread list: what the pager is doing, or nothing when idle. */ +@Composable +private fun RelayGroupThreadsHistoryFooter( + loadingOlder: Boolean, + exhausted: Boolean, +) { + val text = + when { + loadingOlder -> stringRes(R.string.relay_group_threads_loading_older) + exhausted -> stringRes(R.string.relay_group_threads_all_caught_up) + else -> return + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + ) +} + @Composable private fun ThreadRow( thread: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index f430c0b331..dfcfbee1dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -191,6 +191,31 @@ fun buildRelayGroupDirectoryFilter( ), ) +/** + * Backward-history page(s) for a group's **Threads** tab: one `#h` filter per **armed** relay at its own + * `until`, capped by [limit], over the thread kinds (11/1111). The forum analog of + * [buildRelayGroupHistoryFilters]; a parked relay (no requested `until`) contributes nothing. + */ +fun buildRelayGroupThreadsHistoryFilters( + groupId: GroupId, + armedRelays: Collection, + untilForRelay: (NormalizedRelayUrl) -> Long?, + limit: Int, +): List = + armedRelays.mapNotNull { relay -> + val until = untilForRelay(relay) ?: return@mapNotNull null + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RELAY_GROUP_THREAD_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + until = until, + limit = limit, + ), + ) + } + /** The Threads-tab feed for a single open group: kind-11/1111 `#h`-scoped on the host relay. */ fun buildRelayGroupThreadsFilter( groupId: GroupId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt new file mode 100644 index 0000000000..6eb149347f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt @@ -0,0 +1,159 @@ +/* + * 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.publicChannels.relayGroup.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** One open NIP-29 group whose older forum threads the Threads tab wants paged in. */ +class RelayGroupOpenThreadsHistoryQueryState( + val account: Account, + val groupId: GroupId, +) + +/** + * Mounts the on-demand **history** pager for whichever NIP-29 group's Threads tab is open. The Threads + * live tail ([RelayGroupOpenThreadsFilterAssembler]) holds the recent window each host relay serves; this + * pages older kind-11/1111 thread content backward by `until`+`limit` on the group's host relay, exactly + * like the chat history pager ([RelayGroupOpenChatHistoryFilterAssembler]) but on the group's separate + * [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel.threadsHistory] cursors — so + * a group with more threads than the relay's default result cap doesn't silently hide the older ones. + */ +class RelayGroupOpenThreadsHistoryFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val history = RelayGroupOpenThreadsHistorySubAssembler(client, ::allKeys) + + val group = listOf(history) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +/** + * Pages one group's older threads by `until`+`limit`, on the single host relay, on demand. The per-relay + * cursors live on the group's [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel.threadsHistory] + * (so reopening keeps progress); this binds the single-active [BackwardRelayPager] to the open group on + * [newSub], builds the `#h`-scoped kind-11/1111 REQ at the relay's requested cursor, and forwards relay + * callbacks in. Landing happens on the normal ingest path (kind-11 → `addThread`, kind-1111 → its thread + * tree); the pager only needs each event's `createdAt`. + */ +class RelayGroupOpenThreadsHistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + private val pager = BackwardRelayPager("relayGroup.threads.history") + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun id(key: RelayGroupOpenThreadsHistoryQueryState) = key.groupId + + // This group's persistent thread-paging cursors, held on its LocalCache RelayGroupChannel. + private fun cursorsFor(key: RelayGroupOpenThreadsHistoryQueryState) = LocalCache.getOrCreateRelayGroupChannel(key.groupId).threadsHistory + + /** A relay group lives on exactly one relay: its host. */ + private fun relaysFor(key: RelayGroupOpenThreadsHistoryQueryState): Set = setOf(key.groupId.relayUrl) + + override fun updateFilter( + key: RelayGroupOpenThreadsHistoryQueryState, + since: SincePerRelayMap?, + ): List? { + val armed = pager.armedRelays(relaysFor(key)) + if (armed.isEmpty()) return emptyList() + return buildRelayGroupThreadsHistoryFilters(key.groupId, armed, { pager.requestedUntilFor(it) }, pager.pageLimit) + } + + /** Steps a single [relay] to its next, older page for the open group. Driven by its on-screen marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For a short list / eager backfill. */ + fun advanceAll() { + if (pager.advanceAll()) invalidateFilters() + } + + override fun newSub(key: RelayGroupOpenThreadsHistoryQueryState): Subscription { + // Repoint the single-active orchestrator at this group's thread cursors and its host relay. + pager.bind(cursorsFor(key), key.account.scope) { relaysFor(key) } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: RelayGroupOpenThreadsHistoryQueryState): SubscriptionListener { + // A just-backgrounded group's subscription can still deliver after the orchestrator rebinds to + // another group; gate the pager (single-active) on whether it's still bound to THIS group's + // cursors so a late callback can't move another group's cursors. newEose runs regardless. + val myCursors = cursorsFor(key) + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEose(relay) + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsSubscription.kt index 1258259334..154db5d648 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsSubscription.kt @@ -25,8 +25,9 @@ import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId -/** Mount on a group's Threads screen to stream its kind-11 threads + 1111 comments. */ +/** Mount on a group's Threads screen to stream its kind-11 threads + 1111 comments (the recent live tail). */ @Composable fun RelayGroupOpenThreadsSubscription( channel: RelayGroupChannel, @@ -40,3 +41,18 @@ fun RelayGroupOpenThreadsSubscription( LifecycleAwareKeyDataSourceSubscription(state, dataSource) } + +/** + * Mount on a group's Threads screen to keep its backward-history pager bound and armed (older kind-11/1111 + * by `until`+`limit` on the host relay), the Threads analog of [RelayGroupOpenChatHistorySubscription]. + */ +@Composable +fun RelayGroupOpenThreadsHistorySubscription( + groupId: GroupId, + dataSource: RelayGroupOpenThreadsHistoryFilterAssembler, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val state = remember(account, groupId) { RelayGroupOpenThreadsHistoryQueryState(account, groupId) } + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 67af627ae0..51ce5654aa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2346,6 +2346,8 @@ No groups found for this filter yet. Favorite this relay No threads yet. Start one with the + button. + Loading older threads… + No older threads New thread Untitled Title diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt index c7d15d5bfd..2434214f12 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt @@ -145,6 +145,28 @@ class RelayGroupFilterBuildersTest { assertEquals(7L, f.filter.since) } + // --- Threads history (backward pager): only armed relays, each at its own until, over thread kinds --- + + @Test + fun `threads history emits only armed relays at their until over thread kinds`() { + val untilByRelay = mapOf(relayA to 300L) // relayB not armed → no cursor + val filters = buildRelayGroupThreadsHistoryFilters(g1OnA, listOf(relayA, relayB), { untilByRelay[it] }, 40) + + val f = filters.single() + assertEquals(relayA, f.relay) + assertEquals(threadKinds, f.filter.kinds) + assertEquals(300L, f.filter.until) + assertEquals(40, f.filter.limit) + assertEquals(listOf("g1"), f.filter.tags!!["h"]) + assertNull("backward paging is until-anchored, not since", f.filter.since) + assertNull(f.filter.authors) + } + + @Test + fun `threads history with nothing armed builds no filters`() { + assertTrue(buildRelayGroupThreadsHistoryFilters(g1OnA, emptyList(), { 1L }, 40).isEmpty()) + } + // --- Directory (browse a relay): kinds 39000-39003, no d/h scope, limit 500 --- @Test diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 93515eae72..aaed4b3e9a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -63,6 +63,14 @@ class RelayGroupChannel( */ val history = RelayLoadingCursors() + /** + * Per-relay backward-pagination cursors for this group's **Threads** tab (kind-11 roots + kind-1111 + * comments), kept apart from [history] so paging the forum doesn't move the chat's cursor. The Threads + * live tail holds the recent window; this pages older thread content by `until`+`limit` on the host relay + * so a group with more threads than the relay's default result cap doesn't silently hide the older ones. + */ + val threadsHistory = RelayLoadingCursors() + /** The latest relay-signed kind 39000 metadata event, when known. */ var event: GroupMetadataEvent? = null diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt index 1a65551596..1bf3edf89c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt @@ -280,6 +280,41 @@ class RelayGroupFilterServingRelayTest : RelayClientTest() { assertEquals(setOf("g1", "g2"), metadataGroups, "the directory lists both groups the relay hosts") } + // --- Threads history: a backward #h + thread-kinds walk covers every thread exactly once --- + + @Test + fun threadsHistoryFilterPagesEveryThreadBackwardExactlyOnce() = + runBlocking { + // 120 thread roots (older than any window), paged backward by #h + kind-11/1111, limit 50 — the + // shape the RelayGroupOpenThreadsHistory pager puts on the wire. Proves no older thread is lost + // to the relay's default result cap. + defaultRelay.preload((1..120).map { content(it, ThreadEvent.KIND, "g1", at = it.toLong()) }) + + val seen = mutableSetOf() + var until: Long? = null + var pages = 0 + while (pages < 10) { + val (events, eose) = + client.collectUntilEose( + defaultRelayUrl, + Filter( + kinds = listOf(ThreadEvent.KIND, CommentEvent.KIND), + tags = mapOf(GroupIdTag.TAG_NAME to listOf("g1")), + until = until, + limit = 50, + ), + ) + assertTrue(eose) + if (events.isEmpty()) break + pages++ + events.forEach { seen.add(it.id) } + until = events.minOf { it.createdAt } - 1 + } + + assertEquals(120, seen.size, "the threads pager must cover every thread exactly once") + assertEquals(3, pages) // 50 + 50 + 20, then an empty page stops the walk + } + private fun Event.groupTag(): String? = tags.firstOrNull { it.size >= 2 && it[0] == GroupIdTag.TAG_NAME }?.get(1) private fun Event.dTagValue(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)