mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+4
-1
@@ -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,
|
||||
|
||||
+83
-1
@@ -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,
|
||||
|
||||
+25
@@ -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,
|
||||
|
||||
+159
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+22
@@ -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
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
|
||||
+35
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user