From 2b4148ef194ac645e926368dc97a7c2c72f49706 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:04:21 -0400 Subject: [PATCH] feat(concord): scroll-back history pagination for channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord channels only showed the recent tail the relay served for the channel plane and never loaded older messages on scroll. Add backward `until`+`limit` paging, per relay, on demand — the same model as the NIP-04 per-conversation history. Reuses the shared paging stack as-is (BackwardRelayPager, RelayLoadingCursors, the RelayReach* markers/sentinels and DmHistoryLoadingCard); only the Concord-specific data layer is new: - ConcordChannel holds a per-channel RelayLoadingCursors (`history`), so cursors share the channel's cache lifetime. - ConcordChannelHistory{FilterAssembler,SubAssembler} binds a pager to the open channel, builds `{kinds:[1059], authors:[planePk], until, limit}` per armed relay, and forwards relay callbacks; registered in RelaySubscriptionsCoordinator and mounted by the channel screen. - ConcordCommunitySession.channelPlaneAddress() resolves a channel's REQ author from the fold. - ConcordChannelScreen wires the olderBoundary/markersInGap/sentinels feed hooks and bootstraps an empty channel. The history floor is `now` (not the DM 7-day tail): the Concord live sub isn't a strict recent-tail (it asks the plane author unbounded and the relay caps the result), so paging must walk the whole history from the top to reach recent-but-capped messages. Overlap with the live tail is harmless — wraps dedup by id on ingest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../RelaySubscriptionsCoordinator.kt | 6 + .../concord/ConcordChannelScreen.kt | 103 ++++++++++ .../ConcordChannelHistoryFilterAssembler.kt | 191 ++++++++++++++++++ .../ConcordChannelHistorySubscription.kt | 54 +++++ .../commons/model/concord/ConcordChannel.kt | 9 + .../model/concord/ConcordCommunitySession.kt | 3 + 6 files changed, 366 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt 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 027fedeba5..741c99f1eb 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 @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.P import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler @@ -134,6 +135,10 @@ class RelaySubscriptionsCoordinator( // control + channel planes live (kind-1059 by derived stream address). val concordChannels = ConcordChannelFilterAssembler(client) + // On-demand backward history pager for whichever Concord Channel screen is open (older wraps by + // until+limit, per relay), the Concord analog of the per-conversation NIP-04 history. + val concordChannelHistory = ConcordChannelHistoryFilterAssembler(client) + val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -201,6 +206,7 @@ class RelaySubscriptionsCoordinator( relayGroupWarmup, relayGroupsDiscovery, concordChannels, + concordChannelHistory, account, accountForeground, home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index cc0744eea2..9cdd239308 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -43,8 +44,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -54,6 +63,9 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel @@ -68,7 +80,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -88,6 +106,7 @@ fun ConcordChannelScreen( nav: INav, ) { ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + ConcordChannelHistorySubscription(communityId, channelId, accountViewModel.dataSources().concordChannelHistory, accountViewModel) val account = accountViewModel.account val channel = remember(account, communityId, channelId) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelId)) } @@ -99,6 +118,24 @@ fun ConcordChannelScreen( ) WatchLifecycleAndUpdateModel(feedViewModel) + // Backward history pager for this open channel (older wraps by until+limit, per relay), mirroring + // the NIP-04 per-conversation history: markers drive paging while on screen, a status card sits at + // the oldest end, and an empty channel bootstraps one page so there is something to scroll from. + val history = remember(accountViewModel) { accountViewModel.dataSources().concordChannelHistory.history } + val loadingHistory by history.loadingMore.collectAsStateWithLifecycle() + val historyStatus by history.status.collectAsStateWithLifecycle() + val limits = + remember(historyStatus) { + buildList { + if (!historyStatus.exhausted) { + historyStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("cord:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "Concord") { history.advance(relay) }) + } + } + } + } + ConcordBootstrapHistoryWhenEmpty(feedViewModel.feedState, history) + val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel") newMessageModel.init(accountViewModel) newMessageModel.load(communityId, channelId) @@ -131,6 +168,37 @@ fun ConcordChannelScreen( routeForLastRead = "Concord/$communityId/$channelId", onWantsToReply = { newMessageModel.reply(it) }, onWantsToEditDraft = {}, + // A status card at the oldest end: shows what it's reaching for while it pages and + // crossfades to "All caught up" when every relay runs dry. + olderBoundary = { + DmHistoryLoadingCard( + "Concord", + "Concord", + loadingHistory, + historyStatus.exhausted, + historyStatus.relayCount, + historyStatus.stalledCount, + historyStatus.reachedBack, + historyStatus.relayProgress, + ::formatHistoryReachDate, + ) + }, + // Each relay's window-limit marker at its reached cursor (pure UI). Hidden when exhausted. + markersInGap = + if (limits.isEmpty()) { + null + } else { + { newer, older -> RelayReachMarkers(limits, newer, older) {} } + }, + // Pulls each relay's next page while its marker is on screen, off viewport visibility. + sentinels = + if (limits.isEmpty()) { + null + } else { + { items, listState -> + RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + } + }, ) } @@ -147,6 +215,41 @@ fun ConcordChannelScreen( } } +/** + * When the channel opens empty, kick a single history page so there's something to scroll from — from + * there paging is purely demand-driven by the markers' visibility. Debounced so the transient empty + * feed that navigation flashes through doesn't trigger a hunt. Mirrors the DM `BootstrapHistoryWhenEmpty`. + */ +@Composable +private fun ConcordBootstrapHistoryWhenEmpty( + feedContentState: FeedContentState, + history: ConcordChannelHistorySubAssembler, +) { + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + val needsBootstrap = feedState is FeedState.Empty + LaunchedEffect(needsBootstrap, history) { + if (!needsBootstrap) return@LaunchedEffect + delay(1200L) + combine(history.loadingMore, history.status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { history.advanceAll() } + } +} + +private fun reachState(p: RelayPagingProgress): RelayReachState = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .removePrefix("wss://") + .removePrefix("ws://") + .removeSuffix("/") + @Composable private fun ConcordMessageComposer( newMessageModel: ConcordNewMessageViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt new file mode 100644 index 0000000000..a8f32ad938 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt @@ -0,0 +1,191 @@ +/* + * 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.concord.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.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +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.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** One open Concord Channel whose older history the screen wants paged in. */ +class ConcordChannelHistoryQueryState( + val account: Account, + val communityId: String, + val channelId: String, +) + +/** + * Mounts the on-demand **history** pager for whichever Concord Channel screen is open. The live + * [ConcordChannelFilterAssembler] only holds the recent tail the relay serves for each channel plane; + * this pages older messages backward by `until`+`limit` per relay, exactly like the NIP-04 per- + * conversation history ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomNip04HistorySubAssembler]). + */ +class ConcordChannelHistoryFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val history = ConcordChannelHistorySubAssembler(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 Concord Channel's older wraps by `until`+`limit`, per relay, on demand. The per-relay + * cursors live on the channel's [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel] (so + * reopening keeps progress); this binds the single-active [BackwardRelayPager] to the open channel on + * [newSub], builds the kind-1059 channel-plane REQ per armed relay, and forwards relay callbacks into + * the pager. Decryption + landing happen on the normal ingest path (the wraps flow through + * `concordSessions.ingest`); the pager only needs each wrap's `createdAt`. + */ +class ConcordChannelHistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + // Floor at `now` (liveTailSeconds = 0), NOT the DM 7-day tail: the Concord live subscription isn't a + // strict recent-tail (it asks the plane author unbounded and the relay caps the result), so paging + // must walk the WHOLE history from the top to reach "recent but capped" messages. Overlap with the + // live tail is harmless — wraps dedup by id on ingest. + private val pager = BackwardRelayPager("concord.channel.history", liveTailSeconds = 0) + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun id(key: ConcordChannelHistoryQueryState) = ConcordChannelId(key.communityId, key.channelId) + + // This channel's persistent paging cursors, held on its LocalCache ConcordChannel. + private fun cursorsFor(key: ConcordChannelHistoryQueryState) = LocalCache.getOrCreateConcordChannel(id(key)).history + + /** The community's bootstrap relays — a channel plane may be mirrored on all of them. */ + private fun relaysFor(key: ConcordChannelHistoryQueryState): Set = + key.account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == key.communityId } + ?.relays + ?.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + ?: emptySet() + + /** The channel's derived Chat Plane pubkey — the REQ author. Null until the Control Plane folds it. */ + private fun planePkFor(key: ConcordChannelHistoryQueryState): String? = + key.account.concordSessions + .sessionFor(key.communityId) + ?.channelPlaneAddress(key.channelId) + + override fun updateFilter( + key: ConcordChannelHistoryQueryState, + since: SincePerRelayMap?, + ): List? { + val planePk = planePkFor(key) ?: return emptyList() + val relays = relaysFor(key) + // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked + // relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it. + val armed = pager.armedRelays(relays) + if (armed.isEmpty()) return emptyList() + return armed.mapNotNull { relay -> + val until = pager.requestedUntilFor(relay) ?: return@mapNotNull null + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + authors = listOf(planePk), + until = until, + limit = pager.pageLimit, + ), + ) + } + } + + /** Steps a single [relay] to its next, older page for the open channel. 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 channel too short to scroll. */ + fun advanceAll() { + if (pager.advanceAll()) invalidateFilters() + } + + override fun newSub(key: ConcordChannelHistoryQueryState): Subscription { + // Repoint the single-active orchestrator at this channel's cursors and its community relays. + pager.bind(cursorsFor(key), key.account.scope) { relaysFor(key) } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ConcordChannelHistoryQueryState): SubscriptionListener { + // A just-backgrounded channel's subscription can still deliver after the orchestrator rebinds to + // another channel; gate the pager (single-active) on whether it's still bound to THIS channel's + // cursors so a late callback can't move another channel'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/concord/datasource/ConcordChannelHistorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt new file mode 100644 index 0000000000..409d157e4b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt @@ -0,0 +1,54 @@ +/* + * 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.concord.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * Mount on the open Concord Channel screen to keep its backward-history pager bound and armed. The + * channel's Chat Plane pubkey (the REQ author) is only known once the Control Plane folds, so — like + * [ConcordChannelSubscription] — we re-derive the history filter whenever + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] advances. + */ +@Composable +fun ConcordChannelHistorySubscription( + communityId: String, + channelId: String, + dataSource: ConcordChannelHistoryFilterAssembler, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val state = remember(account, communityId, channelId) { ConcordChannelHistoryQueryState(account, communityId, channelId) } + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + LaunchedEffect(revision) { + // A fold can reveal this channel's plane pubkey (the REQ author) for the first time. + dataSource.invalidateFilters() + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt index 5854f1fc0c..2d4f36c597 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -72,6 +73,14 @@ class ConcordChannel( var membership: ConcordMembership = ConcordMembership.MEMBER private set + /** + * Per-relay backward-pagination cursors for this channel's history (CORD-03). The live + * subscription only holds the recent tail the relay serves for the channel plane; older + * messages are paged in on demand by `until`+`limit` as the user scrolls, exactly like the + * NIP-04 per-conversation history. Held here so the cursors share the channel's cache lifetime. + */ + val history = RelayLoadingCursors() + /** * Refresh this channel's metadata from a freshly-folded community [state] plus * the community's [relays] and this account's [myPubKey]. Cheap and idempotent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index e92b73cf9f..a3b2574ed3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -107,6 +107,9 @@ class ConcordCommunitySession( /** The current Chat Plane addresses to subscribe to, one per folded channel. */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } + /** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */ + fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key } + /** The base-rotation rekey [GroupKey] a member opens an inbound Refounding under. */ fun nextBaseRekeyKey(): GroupKey = nextBaseRekeyKey