mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
feat(concord): scroll-back history pagination for channels
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
691adb361c
commit
2b4148ef19
+6
@@ -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,
|
||||
|
||||
+103
@@ -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,
|
||||
|
||||
+191
@@ -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<ConcordChannelHistoryQueryState>() {
|
||||
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<ConcordChannelHistoryQueryState>,
|
||||
) : PerUniqueIdEoseManager<ConcordChannelHistoryQueryState, ConcordChannelId>(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<Boolean> = pager.loadingMore
|
||||
val status: StateFlow<PagingStatus> = 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<NormalizedRelayUrl> =
|
||||
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<RelayBasedFilter>? {
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
+9
@@ -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
|
||||
|
||||
+3
@@ -107,6 +107,9 @@ class ConcordCommunitySession(
|
||||
/** The current Chat Plane addresses to subscribe to, one per folded channel. */
|
||||
fun channelAddresses(): Set<HexKey> = 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user