feat: explain every reason a chat room blocks posting

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3KXHjmCpUDzrSRyxatbyB
This commit is contained in:
Claude
2026-07-30 02:10:08 +00:00
parent 94f042050b
commit 4bb44de5e2
10 changed files with 507 additions and 95 deletions
@@ -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
@@ -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),
)
}
}
@@ -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,
@@ -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
}
+2
View File
@@ -2709,6 +2709,8 @@
<string name="channel_invite_leave">Leave</string>
<string name="relay_group_join_to_post">Join this group to send messages.</string>
<string name="relay_group_invite_only_to_post">This group is invite-only — you need an invite to post.</string>
<string name="chat_posting_banned">You have been banned from this community. Past messages stay readable, but you can no longer post.</string>
<string name="chat_posting_no_key">You do not hold this community\'s key, so you cannot post here.</string>
<plurals name="relay_group_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
@@ -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
}
@@ -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
@@ -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) ||
@@ -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<String> = 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<String> = 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<String>): 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<String>): 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)
}
}
}
+9 -6
View File
@@ -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