feat: paginate the NIP-29 group Threads tab with a backward history pager

The Threads tab loaded kind-11/1111 with a single #h since-filter and no
limit, so a group with more threads than the relay's default result cap
silently lost the older ones. Mirror the chat history stack for threads:

- RelayGroupChannel.threadsHistory: separate RelayLoadingCursors so paging
  the forum doesn't move the chat's cursor.
- buildRelayGroupThreadsHistoryFilters: per-armed-relay #h + kind-11/1111
  until+limit page, the forum analog of buildRelayGroupHistoryFilters.
- RelayGroupOpenThreadsHistoryFilterAssembler: the on-demand BackwardRelayPager
  ("relayGroup.threads.history"), bound to the open group's threadsHistory
  cursors, landing on the normal ingest path (kind-11 -> addThread).
- Threads screen: mount the history subscription, eagerly backfill to a
  window on open, page older content as the list nears its end, and show a
  quiet loading/caught-up footer.

Tests: RelayGroupFilterBuildersTest gains the threads-history filter shape;
RelayGroupFilterServingRelayTest gains a geode backward #h + thread-kinds
walk proving every thread is covered exactly once and the walk terminates.

The screen wiring is device-untested (flagged with the other Tier-D items).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
This commit is contained in:
Claude
2026-07-18 18:54:18 +00:00
parent 3214095b99
commit b852a163c7
9 changed files with 355 additions and 3 deletions
@@ -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,
@@ -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,
@@ -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<NormalizedRelayUrl>,
untilForRelay: (NormalizedRelayUrl) -> Long?,
limit: Int,
): List<RelayBasedFilter> =
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,
@@ -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<RelayGroupOpenThreadsHistoryQueryState>() {
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<RelayGroupOpenThreadsHistoryQueryState>,
) : PerUniqueIdEoseManager<RelayGroupOpenThreadsHistoryQueryState, GroupId>(client, allKeys) {
private val pager = BackwardRelayPager("relayGroup.threads.history")
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = 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<NormalizedRelayUrl> = setOf(key.groupId.relayUrl)
override fun updateFilter(
key: RelayGroupOpenThreadsHistoryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEose(relay)
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
}
@@ -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)
}
+2
View File
@@ -2346,6 +2346,8 @@
<string name="relay_group_discovery_empty_filtered">No groups found for this filter yet.</string>
<string name="relay_group_favorite_relay">Favorite this relay</string>
<string name="relay_group_threads_empty">No threads yet. Start one with the + button.</string>
<string name="relay_group_threads_loading_older">Loading older threads…</string>
<string name="relay_group_threads_all_caught_up">No older threads</string>
<string name="relay_group_thread_new">New thread</string>
<string name="relay_group_thread_untitled">Untitled</string>
<string name="relay_group_thread_title_label">Title</string>
@@ -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
@@ -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
@@ -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<String>()
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)