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