From 4bb44de5e26bd5733cb72f0b5fb5f42bda2647f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:10:08 +0000 Subject: [PATCH] feat: explain every reason a chat room blocks posting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boolean `canPost()` can hide the composer but cannot say why, so each new gate degraded into blank space above the keyboard. That is how a banned Concord member came to get an empty slot and no explanation: the screen's only explanatory branch tested `dissolved`. Model the reason instead. `PostingGate` is a sealed interface — Allowed, or a Blocked subtype (Banned, Dissolved, NoKey, NotAMember, InviteOnly) — and both channel types now derive it, with `canPost()` defined as `postingGate() == Allowed` so the answer and the explanation cannot drift. Adding a gate later is a compile error at the render site until it is given copy of its own, which is the property the boolean could not express. One `PostingGateNotice` renders all of them, replacing the Concord-only dissolved notice and the NIP-29-only join notice; the forum thread list reuses the same sentence instead of a flat "read-only". The Concord screen also now collects the channel's metadata flow, so a ban or dissolution landing mid-session flips the composer instead of waiting for a re-entry. Only reasons a protocol can produce today are modeled. A Buzz tenant ban (9040) or timeout (9042) is not among them: the relay decides at publish time and reports it in an `OK false` no send path surfaces yet, so there is no local state to derive it from. Nothing is claimed for archived Buzz channels either — neither the relay contract nor our code refuses writes there, so gating them would have hidden a composer the relay accepts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q3KXHjmCpUDzrSRyxatbyB --- .../concord/ConcordChannelScreen.kt | 57 ++--- .../relayGroup/RelayGroupChannelView.kt | 61 ++--- .../relayGroup/RelayGroupThreadsScreen.kt | 22 +- .../loggedIn/chats/utils/PostingGateNotice.kt | 106 +++++++++ amethyst/src/main/res/values/strings.xml | 2 + .../commons/model/chats/PostingGate.kt | 83 +++++++ .../commons/model/concord/ConcordChannel.kt | 24 +- .../nip29RelayGroups/RelayGroupChannel.kt | 24 +- .../commons/model/chats/PostingGateTest.kt | 208 ++++++++++++++++++ docs/community-status-matrix.md | 15 +- 10 files changed, 507 insertions(+), 95 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/PostingGateNotice.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGate.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGateTest.kt 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 dcd7651d6d..5c8338424b 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 @@ -56,6 +56,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.chats.PostingGate +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard @@ -89,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.Ch import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.PostingGateNotice import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta @@ -164,6 +167,16 @@ fun ConcordChannelScreen( } ConcordBackfillHistoryToWindow(feedViewModel.feedState, history) + // The channel's fields (name, membership, dissolution) are refreshed in place by each Control-Plane + // fold, which invalidates this flow — collect it so the screen actually recomposes when they change. + // Without it the composer gate is read once per visit, and a ban or dissolution landing mid-session + // would leave the composer up until the user navigated away and back. + val channelState by channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val liveChannel = channelState.channel as? ConcordChannel ?: channel + val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel") newMessageModel.init(accountViewModel) newMessageModel.load(communityId, channelId) @@ -173,8 +186,8 @@ fun ConcordChannelScreen( TopAppBar( title = { Column { - Text(channel.toBestDisplayName(), maxLines = 1) - channel.communityName?.let { + Text(liveChannel.toBestDisplayName(), maxLines = 1) + liveChannel.communityName?.let { Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1) } } @@ -243,37 +256,25 @@ fun ConcordChannelScreen( ConcordTypingIndicator(communityId, channelId, accountViewModel) - if (channel.canPost()) { - Spacer(modifier = DoubleVertSpacer) - ConcordMessageComposer( - newMessageModel = newMessageModel, - accountViewModel = accountViewModel, - nav = nav, - onMessageSent = { feedViewModel.feedState.sendToTop() }, - ) - } else if (channel.dissolved) { - // CORD-02 §9: an owner-signed tombstone seals the community read-only — the composer is - // gone (canPost() is false) and this replaces it so the seal is explained, not silent. - ConcordDissolvedNotice() + // Every reason posting can be blocked here gets said out loud. This used to explain only + // dissolution, so a banned member — the person who most needs telling — got the composer's + // empty space and nothing else. + when (val gate = liveChannel.postingGate()) { + PostingGate.Allowed -> { + Spacer(modifier = DoubleVertSpacer) + ConcordMessageComposer( + newMessageModel = newMessageModel, + accountViewModel = accountViewModel, + nav = nav, + onMessageSent = { feedViewModel.feedState.sendToTop() }, + ) + } + is PostingGate.Blocked -> PostingGateNotice(gate) } } } } -/** - * The read-only notice shown where the composer would be once a community is dissolved (CORD-02 §9). - * The tombstone seals the community: history stays readable, but no member may post again. - */ -@Composable -private fun ConcordDissolvedNotice() { - Text( - text = stringRes(R.string.concord_dissolved_read_only), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - ) -} - /** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */ private const val CONCORD_HISTORY_TARGET = 50 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index add5333fb1..9fd5bf17f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -23,24 +23,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.chats.PostingGate import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState @@ -62,7 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow -import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.PostingGateNotice import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -268,18 +262,17 @@ private fun ChannelView( // Show the composer wherever the relay would actually accept my write: any roster // member/mod/admin, plus any authenticated member of an open Buzz channel (which accepts - // writes without a per-channel join). Otherwise explain why. See [RelayGroupChannel.canPost]. - val canPost = liveChannel.canPost(accountViewModel.userProfile().pubkeyHex) - - if (canPost) { - EditFieldRow( - newPostModel, - accountViewModel, - onSendNewMessage = feedViewModel.feedState::sendToTop, - nav, - ) - } else { - JoinToPostNotice(liveChannel) + // writes without a per-channel join). Otherwise say which gate is in the way — see + // [RelayGroupChannel.postingGate]. + when (val gate = liveChannel.postingGate(accountViewModel.userProfile().pubkeyHex)) { + PostingGate.Allowed -> + EditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = feedViewModel.feedState::sendToTop, + nav, + ) + is PostingGate.Blocked -> PostingGateNotice(gate) } } } @@ -330,31 +323,3 @@ private fun relayShortName(relay: NormalizedRelayUrl): String = .removePrefix("wss://") .removePrefix("ws://") .removeSuffix("/") - -/** - * Shown in place of the composer when I'm not (yet) a member: a relay group won't accept my kind-9 - * chat until its roster lists me, so typing would only earn a silent relay rejection. Points me at - * the top-bar Join action (open groups) or explains the invite requirement (closed groups). - */ -@Composable -private fun JoinToPostNotice(channel: RelayGroupChannel) { - val message = - if (channel.isClosed()) { - stringRes(R.string.relay_group_invite_only_to_post) - } else { - stringRes(R.string.relay_group_join_to_post) - } - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index 33354badb9..dabcebe4f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.chats.PostingGate import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount @@ -73,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistorySubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistorySubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.postingGateReason import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent @@ -147,11 +149,12 @@ private fun RelayGroupThreads( RelayGroupThreadsPaging(threadCount = { threads.size }, listState = listState, history = history) // Hide the compose FAB where the relay would reject the kind-11: on membership-gated groups - // that don't list me. Open Buzz channels accept any authenticated member. See [RelayGroupChannel.canPost]. + // that don't list me. Open Buzz channels accept any authenticated member. See + // [RelayGroupChannel.postingGate]. The gate is kept as a value, not collapsed into the boolean, + // so the empty state can name my actual standing instead of a flat "read-only". val isBuzz = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } - val canPost = - channel.canPost(accountViewModel.userProfile().pubkeyHex) && - canStartThreadHere(channel.event?.buzzChannelType(), isBuzz) + val gate = channel.postingGate(accountViewModel.userProfile().pubkeyHex) + val canPost = gate == PostingGate.Allowed && canStartThreadHere(channel.event?.buzzChannelType(), isBuzz) Scaffold( topBar = { @@ -239,10 +242,13 @@ private fun RelayGroupThreads( Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text( text = - if (canPost) { - stringRes(R.string.relay_group_threads_empty) - } else { - stringRes(R.string.relay_group_threads_empty_read_only) + when { + canPost -> stringRes(R.string.relay_group_threads_empty) + // My standing is what's blocking: say which one, same words the chat + // screen's composer notice uses. + gate is PostingGate.Blocked -> postingGateReason(gate) + // Standing is fine — this channel type just doesn't take new threads. + else -> stringRes(R.string.relay_group_threads_empty_read_only) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/PostingGateNotice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/PostingGateNotice.kt new file mode 100644 index 0000000000..9c3b1fd72f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/PostingGateNotice.kt @@ -0,0 +1,106 @@ +/* + * 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.utils + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.chats.PostingGate +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * Takes the composer's place in any chat room this account can't post to, and says why. + * + * One renderer for every chat protocol: a [PostingGate.Blocked] is a [PostingGate.Blocked] whether it + * came from a Concord banlist or a NIP-29 roster, and the user's question is the same either way. + * Because the reason is a sealed type, adding a gate to a protocol is a compile error here until it is + * given copy — which is what keeps a new blocked state from degrading into the silent empty slot a + * banned Concord member used to get. + */ +@Composable +fun PostingGateNotice( + gate: PostingGate.Blocked, + modifier: Modifier = Modifier, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + SymbolIcon( + symbol = gate.symbol(), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = postingGateReason(gate), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The user-facing sentence for a blocked gate, separate from [PostingGateNotice] so a screen with no + * composer to replace (the forum's thread list, whose FAB simply disappears) can state the same reason + * in its own empty state instead of a generic "read-only". + */ +@Composable +fun postingGateReason(gate: PostingGate.Blocked): String = + stringRes( + when (gate) { + PostingGate.Banned -> R.string.chat_posting_banned + PostingGate.Dissolved -> R.string.concord_dissolved_read_only + PostingGate.NoKey -> R.string.chat_posting_no_key + PostingGate.NotAMember -> R.string.relay_group_join_to_post + PostingGate.InviteOnly -> R.string.relay_group_invite_only_to_post + }, + ) + +private fun PostingGate.Blocked.symbol(): MaterialSymbol = + when (this) { + PostingGate.Banned -> MaterialSymbols.Block + PostingGate.Dissolved -> MaterialSymbols.Lock + PostingGate.NoKey -> MaterialSymbols.Key + PostingGate.NotAMember -> MaterialSymbols.Info + PostingGate.InviteOnly -> MaterialSymbols.Lock + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 53b93fb4a1..5542ee2916 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2709,6 +2709,8 @@ Leave Join this group to send messages. This group is invite-only — you need an invite to post. + You have been banned from this community. Past messages stay readable, but you can no longer post. + You do not hold this community\'s key, so you cannot post here. %1$d member %1$d members diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGate.kt new file mode 100644 index 0000000000..90b4ee9960 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGate.kt @@ -0,0 +1,83 @@ +/* + * 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.commons.model.chats + +/** + * Why this account may or may not post in a chat room — the **typed reason** behind what used to be a + * bare `canPost()` boolean. + * + * A boolean can only hide the composer; it cannot say why, so every new gate silently degraded into + * blank space above the keyboard. (That is exactly how a banned Concord member came to get an empty + * slot and no explanation: the screen's only explanatory branch tested `dissolved`.) Modeling the + * reason as a sealed type moves that from a convention to a compiler guarantee — the `when` that + * renders [Blocked] does not compile until a newly added reason is given copy of its own. + * + * Each channel type derives its own gate (`ConcordChannel.postingGate()`, + * `RelayGroupChannel.postingGate()`) and defines `canPost()` **in terms of it**, so the answer and the + * explanation cannot drift apart. + * + * Only reasons a protocol can actually produce today are modeled here. A Buzz tenant ban (kind 9040) + * or timeout (9042) is *not* among them: the relay decides at publish time and reports it in an + * `OK false` that no send path currently surfaces, so there is no local state to derive it from. When + * that receipt is tracked, this hierarchy gains the matching reason and the `when` will demand copy + * for it. + */ +sealed interface PostingGate { + /** The composer is shown: this account's write would be accepted. */ + data object Allowed : PostingGate + + /** + * The composer is hidden and this reason takes its place. Every subtype must be renderable — + * a [Blocked] gate with nothing to say is the bug this type exists to prevent. + */ + sealed interface Blocked : PostingGate + + /** + * On the community banlist (Concord CORD-04). Standing is gone, but held keys still decrypt, so + * history stays readable — a ban is not an eviction. + */ + data object Banned : Blocked + + /** + * Sealed read-only by an owner-signed `DISSOLVED` tombstone (Concord CORD-02 §9). Terminal and + * one-way, and it outranks every role: not even the owner may post after it. + */ + data object Dissolved : Blocked + + /** + * We cannot place this account in the community at all — no key, no role. Distinct from + * [NotAMember] in that there is no roster to join and no relay to ask: in Concord, holding the + * key *is* membership. + */ + data object NoKey : Blocked + + /** + * Not in the relay-signed roster of a membership-gated NIP-29 group (kinds 39001/39002). The + * relay would reject the write, and the fix is the top bar's Join action. + */ + data object NotAMember : Blocked + + /** + * Same as [NotAMember], except the group is `closed`, so a join request would be ignored — an + * invite is the only way in. + */ + data object InviteOnly : Blocked +} 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 b72056d333..c1d10b503f 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 @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.concord import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.chats.PostingGate import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState @@ -149,13 +150,30 @@ class ConcordChannel( override fun toBestDisplayName(): String = channelName ?: channelId.channelId + /** + * Why this account may or may not post here. Dissolution is reported ahead of a personal ban + * because it is the complete answer: the tombstone seals the community for everyone, so naming + * the ban as well would add a fact that changes nothing. + * + * Deleting one's own past message stays allowed even after dissolution and does not go through + * this gate (it runs from the note context menu, not the composer). + */ + fun postingGate(): PostingGate = + when { + dissolved -> PostingGate.Dissolved + membership == ConcordMembership.BANNED -> PostingGate.Banned + // NONE — no key and no role, so there is nothing to place this account by. + !membership.isMember() -> PostingGate.NoKey + else -> PostingGate.Allowed + } + /** * Whether this account may post to the channel: it must hold a live standing in the community * ([ConcordMembership.isMember]) **and** the community must not have been dissolved (CORD-02 §9 — - * a tombstone seals it read-only for everyone). Deleting one's own past message stays allowed even - * after dissolution and does not go through this gate. + * a tombstone seals it read-only for everyone). Derived from [postingGate] so the answer and the + * reason shown in the composer's place can never disagree. */ - fun canPost(): Boolean = membership.isMember() && !dissolved + fun canPost(): Boolean = postingGate() == PostingGate.Allowed // Synthetic note representing this channel in the Messages list before any // message has loaded (so a just-joined channel appears immediately). Mirrors diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 9c029296f8..4f3274a3ad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.chats.PostingGate import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -336,13 +337,32 @@ class RelayGroupChannel( */ fun requiresMembershipToPost(): Boolean = !BuzzRelayDialect.isBuzz(groupId.relayUrl) || isPrivate() + /** + * Why [pubkey] may or may not post here. + * + * [PostingGate.InviteOnly] is chosen off [isClosed] — a closed group ignores join requests, so + * pointing at the top bar's Join action would be a dead end. Note that Buzz stamps `closed` on + * every channel, so on a Buzz relay a gated (private) channel always reports invite-only; that is + * accurate there, since Buzz admits people by invite claim rather than by kind-9021. + * + * A Buzz *tenant* ban (9040) or timeout (9042) is deliberately absent: the relay enforces those at + * publish time and reports them in an `OK false` we don't yet track, so no local state can derive + * them. See [PostingGate]. + */ + fun postingGate(pubkey: HexKey): PostingGate { + if (!requiresMembershipToPost()) return PostingGate.Allowed + if (membershipOf(pubkey).isMember()) return PostingGate.Allowed + return if (isClosed()) PostingGate.InviteOnly else PostingGate.NotAMember + } + /** * Whether [pubkey] may post here: a roster member/mod/admin always may; on an open Buzz channel any * authenticated relay member may, even without a per-channel join. Mirrors the relay's write gate * (`check_channel_membership`: member OR open-visibility), so the composer isn't hidden where the - * relay would actually accept the message. + * relay would actually accept the message. Derived from [postingGate] so the answer and the reason + * shown in the composer's place can never disagree. */ - fun canPost(pubkey: HexKey): Boolean = !requiresMembershipToPost() || membershipOf(pubkey).isMember() + fun canPost(pubkey: HexKey): Boolean = postingGate(pubkey) == PostingGate.Allowed fun anyNameStartsWith(prefix: String): Boolean = groupId.id.contains(prefix, true) || diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGateTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGateTest.kt new file mode 100644 index 0000000000..8f60160cd2 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGateTest.kt @@ -0,0 +1,208 @@ +/* + * 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.commons.model.chats + +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Every posting gate each protocol can actually produce, and the invariant that ties them together: + * `canPost()` is *defined* as `postingGate() == Allowed`, so a room whose composer is hidden always + * has a reason to show in its place. That is the property the old boolean couldn't express — a banned + * Concord member got `canPost() == false` and an empty slot, because the only explanatory branch on + * the screen tested dissolution. + * + * [PostingGate.NoKey] has no producer to exercise here: it is the mapping for + * `ConcordMembership.NONE`, which the channel fold cannot currently reach (it derives membership for + * communities we already hold the key to). It exists so that enum value cannot silently resolve to + * "allowed". + */ +class PostingGateTest { + // ---- Concord ------------------------------------------------------------------------------ + + private val owner = "0f".repeat(32) + private val banned = "c3".repeat(32) + private val channelIdHex = "ce".repeat(32) + + private fun ed( + kind: ControlEntityKind, + eid: String, + content: String, + ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, owner, "r-$eid", 0) + + private fun concordState( + dissolved: Boolean = false, + banlist: List = emptyList(), + ): ConcordCommunityState { + val editions = + buildList { + add(ed(ControlEntityKind.CHANNEL, channelIdHex, """{"name":"general"}""")) + if (banlist.isNotEmpty()) { + add(ed(ControlEntityKind.BANLIST, "44".repeat(32), banlist.joinToString(",", "[", "]") { "\"$it\"" })) + } + if (dissolved) add(ed(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}""")) + } + return ConcordCommunityState.fold(editions, owner) + } + + private fun concordChannel( + viewer: String, + dissolved: Boolean = false, + banlist: List = emptyList(), + ) = ConcordChannel(ConcordChannelId(owner, channelIdHex)).apply { + updateFrom(concordState(dissolved, banlist), emptySet(), viewer) + } + + @Test + fun concordMemberInGoodStandingIsAllowed() { + val channel = concordChannel(viewer = owner) + assertEquals(PostingGate.Allowed, channel.postingGate()) + assertTrue(channel.canPost()) + } + + @Test + fun concordBannedMemberIsToldWhy() { + // The regression this whole type exists for: before, this was a false boolean and a blank space. + val channel = concordChannel(viewer = banned, banlist = listOf(banned)) + assertEquals(PostingGate.Banned, channel.postingGate()) + assertFalse(channel.canPost()) + } + + @Test + fun concordDissolvedCommunitySealsEvenTheOwner() { + val channel = concordChannel(viewer = owner, dissolved = true) + assertEquals(PostingGate.Dissolved, channel.postingGate()) + assertFalse(channel.canPost()) + } + + @Test + fun dissolutionOutranksAPersonalBan() { + // Both apply. Dissolution is the complete answer — it blocks everyone — so naming the ban too + // would add a fact that changes nothing about what the user can do. + val channel = concordChannel(viewer = banned, dissolved = true, banlist = listOf(banned)) + assertEquals(PostingGate.Dissolved, channel.postingGate()) + } + + // ---- NIP-29 / Buzz relay groups ---------------------------------------------------------- + + private val relaySelf = "aa".repeat(32) + private val alice = "bb".repeat(32) + private val bob = "cc".repeat(32) + private val gid = "0123456789abcdef" + private val relay = RelayUrlNormalizer.normalize("wss://relay.example.com") + + @AfterTest + fun resetDialect() { + BuzzRelayDialect.clearForTesting() + BuzzCommunityMembership.clearForTesting() + } + + private fun metadata(flags: List): GroupMetadataEvent { + val tags = (listOf(arrayOf("d", gid)) + flags.map { arrayOf(it) }).toTypedArray() + return EventFactory.create("00".repeat(32), relaySelf, 100, GroupMetadataEvent.KIND, tags, "", "22".repeat(64)) as GroupMetadataEvent + } + + private fun members(vararg pubkeys: String): GroupMembersEvent { + val tags = (listOf(arrayOf("d", gid)) + pubkeys.map { arrayOf("p", it) }).toTypedArray() + return EventFactory.create("00".repeat(32), relaySelf, 100, GroupMembersEvent.KIND, tags, "", "22".repeat(64)) as GroupMembersEvent + } + + private fun relayGroup(flags: List): RelayGroupChannel = + RelayGroupChannel(GroupId(gid, relay)).apply { + updateGroupInfo(metadata(flags)) + updateMembers(members(alice)) + } + + @Test + fun rosterMemberIsAllowed() { + val channel = relayGroup(flags = emptyList()) + assertEquals(PostingGate.Allowed, channel.postingGate(alice)) + assertTrue(channel.canPost(alice)) + } + + @Test + fun nonMemberOfAnOpenGroupIsPointedAtJoin() { + // Standard NIP-29: membership is required to post to every group, so an open group's answer is + // "join" — an action the top bar actually offers. + val channel = relayGroup(flags = emptyList()) + assertEquals(PostingGate.NotAMember, channel.postingGate(bob)) + assertFalse(channel.canPost(bob)) + } + + @Test + fun nonMemberOfAClosedGroupIsToldAnInviteIsNeeded() { + // A closed group ignores kind-9021 join requests, so offering Join would be a dead end. + val channel = relayGroup(flags = listOf("closed")) + assertEquals(PostingGate.InviteOnly, channel.postingGate(bob)) + } + + @Test + fun openBuzzChannelAllowsANonMemberBecauseTheRelayWould() { + // Buzz accepts kind-9 on a non-private channel from any authenticated tenant member with no + // per-channel join — and it stamps "closed" on every channel, which must not read as a gate. + BuzzRelayDialect.mark(relay) + val channel = relayGroup(flags = listOf("closed")) + assertEquals(PostingGate.Allowed, channel.postingGate(bob)) + assertTrue(channel.canPost(bob)) + } + + @Test + fun privateBuzzChannelStillGatesANonMember() { + // "private" is the real write ACL on Buzz. Since every Buzz channel also carries "closed", the + // honest answer there is invite-only: Buzz admits people by invite claim, not by 9021. + BuzzRelayDialect.mark(relay) + val channel = relayGroup(flags = listOf("private", "closed")) + assertEquals(PostingGate.InviteOnly, channel.postingGate(bob)) + assertFalse(channel.canPost(bob)) + } + + @Test + fun everyBlockedGateKeepsCanPostFalseAndEveryAllowedGateKeepsItTrue() { + // The invariant that makes the composer and its replacement notice impossible to disagree. + val cases = + listOf( + concordChannel(viewer = owner).postingGate() to true, + concordChannel(viewer = banned, banlist = listOf(banned)).postingGate() to false, + concordChannel(viewer = owner, dissolved = true).postingGate() to false, + ) + cases.forEach { (gate, expectedPostable) -> + assertEquals(expectedPostable, gate == PostingGate.Allowed) + assertEquals(!expectedPostable, gate is PostingGate.Blocked) + } + } +} diff --git a/docs/community-status-matrix.md b/docs/community-status-matrix.md index eec466b504..faaef4b60c 100644 --- a/docs/community-status-matrix.md +++ b/docs/community-status-matrix.md @@ -237,8 +237,8 @@ others), and **invisible** (nothing renders it at all). |---|---|---| | `OWNER` / `ADMIN` / `BANNED` / custom role name | `MemberBadge` in `ConcordMembersScreen` — banned in `errorContainer` — for **other** members | **shown** | | My own `OWNER`/`ADMIN` | only via affordances: the create-channel FAB, the Edit icon, the roster's promote/ban items | implied | -| My own `BANNED` | **nothing.** `canPost()` goes false and the composer vanishes, but the explanatory branch is `else if (channel.dissolved)` — so a banned member gets an empty space and no reason. This is the case where the user most needs to be told. | **invisible** | -| `dissolved` | `ConcordDissolvedNotice` replaces the composer, inside an opened channel only — not on the community row, the channel list, or the Messages inbox. (There is also **no write path** to dissolve: an owner cannot do it from Amethyst.) | partial | +| My own `BANNED` | `PostingGateNotice` takes the composer's place and names the ban ("Past messages stay readable, but you can no longer post"). Was invisible: the only explanatory branch tested `dissolved`, so a banned member got an empty space and no reason. | **shown** | +| `dissolved` | The same `PostingGateNotice` replaces the composer, inside an opened channel only — not on the community row, the channel list, or the Messages inbox. (There is also **no write path** to dissolve: an owner cannot do it from Amethyst.) | partial | | **stranded / excluded epoch** | nothing. `recoverStrandedConcordCommunities()` runs at startup, `Log.w` on failure. A stranded community renders as an ordinary *empty* one. | **invisible** | | current epoch / `heldRoots` | nothing | **invisible** | | `private` channel | `Lock` icon in the channel list — but the row navigates like any other into a permanently empty room, because the plane key is never derived (see §8.1). No "you don't hold this key" state. | misleading | @@ -292,10 +292,13 @@ UI-only (the state is tracked correctly, nothing tells the user): NIP-43 role, so a tenant *admin* with no per-channel 39001 row reads as channel `MEMBER`. Correct as a safety default (it avoids over-granting), but it means the community-admin status has no channel-level expression. -7. **A ban is never explained to the person banned.** In Concord the composer disappears with - no notice (the explanatory branch only covers `dissolved`); on Buzz a tenant ban/timeout - produces an `OK false` that no send path surfaces, so the message silently never lands. - Both are the moment the user most needs to be told. +7. **A ban is never explained to the person banned.** *Concord half fixed:* every reason posting + is blocked is now a `PostingGate` (`commons/.../model/chats/PostingGate.kt`) rendered by + `PostingGateNotice`, and `canPost()` is defined as `postingGate() == Allowed` so the two can't + drift. *Buzz half open:* a tenant ban/timeout produces an `OK false` that no send path + surfaces, so the message silently never lands — there is no local state to derive a gate from, + which is why it needs the send-receipt work in §8.9's neighborhood rather than a read-side + notice. `PostingGate` has no `Banned`/`TimedOut` producer for Buzz until then. 8. **Stranding is silent.** Recovery is automatic and log-only, so a member left out of a Refounding sees an ordinary empty community rather than "you were left behind on epoch N". 9. **Buzz tenant moderation has no write path.** 9031 remove-member has no caller; 9032