fix(concord): populate channel community metadata account-wide so the Messages chip shows

Each Concord channel row in the Messages tab has a chip naming its parent
community (tap → opens the community), mirroring the NIP-29 relay chip — but it
only renders when ConcordChannel.communityName is set, and that was populated
solely by refreshConcordChannelIndex() inside the Concord hub screen's
subscription composable. On the Messages tab that screen isn't mounted, so the
channel objects there never got their community name/icon and the chip was
absent.

Moves the channel-index refresh to an account-scoped collector on the
ConcordSessionManager revision, so community metadata (name/icon, channel flags,
membership) and per-community ban pruning apply across the whole app the moment a
Control Plane folds — not only while the hub screen is open. The hub subscription
now just re-derives its filters; the shared refresh lives in Account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-12 15:33:58 +00:00
parent bd42b1695b
commit 0bac5c7aca
2 changed files with 42 additions and 36 deletions
@@ -137,6 +137,7 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
@@ -447,6 +448,35 @@ class Account(
cache.consumeConcordRumor(communityId, channelIdHex, rumor)
}
/**
* Copies each folded community's metadata (name/icon, channel flags, this account's
* membership) onto its [ConcordChannel] objects in the cache, and drops messages from
* authors banned since they loaded. Runs account-wide on every
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager] revision —
* NOT gated behind the Concord hub screen — so every surface (the Messages-tab
* community chip, the chat screen title) reflects the current fold, and bans apply,
* even when the hub was never opened.
*/
fun refreshConcordChannelIndex() {
val myPubKey = signer.pubKey
val relaysByCommunity =
concordChannelList.liveCommunities.value.associate { entry ->
entry.id to entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
}
for (session in concordSessions.sessions()) {
val state = session.state.value ?: continue
val communityId = session.entry.id
val relays = relaysByCommunity[communityId] ?: emptySet()
for (channelIdHex in state.channels.keys) {
val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
channel.updateFrom(state, relays, myPubKey)
channel.notes
.filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true }
.forEach { channel.removeNote(it) }
}
}
}
val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer)
val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings)
@@ -4713,6 +4743,13 @@ class Account(
}
}
// Keep Concord channel metadata (community name/icon, membership) live across the whole
// app — not just the hub screen — so the Messages tab renders each channel's community
// chip, and per-community bans apply, as soon as a Control Plane folds.
scope.launch {
concordSessions.revision.collect { refreshConcordChannelIndex() }
}
scope.launch {
cache.antiSpam.flowSpam.collect {
it.cache.spamMessages.snapshot().values.forEach { spammer ->
@@ -26,16 +26,12 @@ 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.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* Mount on any screen that lists the user's joined Concord Channels (the Messages
* tab, the Concord home) to keep their planes live and their folded metadata in
* the [LocalCache] channel index.
* the LocalCache channel index.
*
* The query state is keyed on the account (stable), so the assembler wouldn't
* re-run its filter derivation on its own when a community folds or the joined set
@@ -57,39 +53,12 @@ fun ConcordChannelSubscription(
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
LaunchedEffect(revision) {
refreshConcordChannelIndex(account)
// The channel-index refresh (community name/icon, membership, ban pruning) runs
// account-wide from Account on this same revision, so the Messages tab has chips even
// when this screen was never opened. Here we only need to re-derive the subscription
// filters, since a newly-folded channel plane must now be subscribed.
dataSource.invalidateFilters()
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
/**
* Projects each folded community session into the shared LocalCache channel index
* so the Messages list and chat screens render an up-to-date [ConcordChannel]
* (name, voice/private flags, community name/relays, this account's membership).
*/
private fun refreshConcordChannelIndex(account: Account) {
val myPubKey = account.signer.pubKey
val relaysByCommunity =
account.concordChannelList.liveCommunities.value
.associate { entry ->
entry.id to entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
}
for (session in account.concordSessions.sessions()) {
val state = session.state.value ?: continue
val communityId = session.entry.id
val relays = relaysByCommunity[communityId] ?: emptySet()
for (channelIdHex in state.channels.keys) {
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
channel.updateFrom(state, relays, myPubKey)
// A member banned since these notes loaded: drop their messages now (the
// ingest gate stops future ones). removeNote invalidates the feed, so the
// ban is reflected live rather than only on the next feed pass.
channel.notes
.filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true }
.forEach { channel.removeNote(it) }
}
}
}