From 0bac5c7aca52a8b2dfd02d1b6d1c5df84f0d79bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 15:33:58 +0000 Subject: [PATCH] fix(concord): populate channel community metadata account-wide so the Messages chip shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 37 +++++++++++++++++ .../datasource/ConcordChannelSubscription.kt | 41 +++---------------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f7a4cbd8e5..c0a1d68dcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 1491b5befc..034c31d7ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -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) } - } - } -}