mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
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:
+29
-28
@@ -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
|
||||
|
||||
|
||||
+13
-48
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-8
@@ -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,
|
||||
|
||||
+106
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+83
@@ -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
|
||||
}
|
||||
+21
-3
@@ -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
|
||||
|
||||
+22
-2
@@ -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) ||
|
||||
|
||||
+208
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user