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:
Vitor Pamplona
2026-07-13 14:04:21 -04:00
co-authored by Claude Opus 4.8
parent 691adb361c
commit 2b4148ef19
6 changed files with 366 additions and 0 deletions
@@ -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,
@@ -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,
@@ -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)
}
}
}
}
@@ -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)
}
@@ -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
@@ -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