feat(concord): mobile moderation — ban action + read-time ban enforcement

Makes the CORD-04 moderation reachable and enforced on the phone:

- A "Ban" action in the note quick-action menu for Concord messages, shown only
  when this account may actually ban the author (owner or holds BAN, target is
  not the owner/self — Account.concordBanTarget). Confirms, then publishes the
  banlist edition via banConcordMember.
- Read-time enforcement: Account.isAcceptable drops a Concord message whose author
  is banned in that community's fold, so banned content is hidden across the inbox
  and chat feed (filter, not delete — matching how the app handles mutes/blocks).
- Reactive: the decrypt sink is gated to drop a banned author's NEW messages
  before they become Notes, and on re-fold (a ban that lands after messages
  loaded) refreshConcordChannelIndex removes their existing notes — removeNote
  invalidates the feed so the ban shows live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-10 22:14:07 +00:00
parent b83ef20d61
commit 6aa3e0e1b9
5 changed files with 119 additions and 4 deletions
@@ -131,6 +131,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -400,7 +401,29 @@ class Account(
* [concordChannelList] and consulted by the giftwrap decrypt path so a Concord
* plane wrap routes here instead of being dropped as an undecryptable DM.
*/
val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope, cache::consumeConcordRumor)
val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope, ::consumeConcordRumorGated)
/**
* Sink for decrypted Concord rumors: drops a message whose author is banned in
* the community's current fold before it ever becomes a Note, then delegates to
* the cache. Bans that arrive *after* a message are handled by removing the
* author's existing notes on re-fold (see `refreshConcordChannelIndex`); this
* gate stops *new* posts from a banned author from appearing at all.
*/
private fun consumeConcordRumorGated(
communityId: String,
channelIdHex: String,
rumor: Event,
) {
val authority =
concordSessions
.sessionFor(communityId)
?.state
?.value
?.authority
if (authority?.isBanned(rumor.pubKey) == true) return
cache.consumeConcordRumor(communityId, channelIdHex, rumor)
}
val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer)
val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings)
@@ -1671,6 +1694,29 @@ class Account(
return true
}
/**
* If [note] is a Concord channel message whose author this account is allowed to
* ban — the actor is the owner or holds the BAN permission, and the target is
* neither the owner nor the actor — returns `(communityId, memberHex)`. Null
* otherwise, so the UI shows the Ban action only when it would actually take
* effect on fold.
*/
fun concordBanTarget(note: Note): Pair<String, HexKey>? {
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
if (author == signer.pubKey) return null
val communityId = channel.channelId.communityId
val authority =
concordSessions
.sessionFor(communityId)
?.state
?.value
?.authority ?: return null
if (authority.isOwner(author)) return null
val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN)
return if (canBan) communityId to author else null
}
/** Add [member] to the community banlist. */
suspend fun banConcordMember(
communityId: String,
@@ -3785,7 +3831,27 @@ class Account(
return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true
}
/**
* True if [note] is a Concord channel message whose author is banned in that
* community's current fold. Bans are per-community (not global mutes), so they
* are enforced here at read time — the same "filter, don't delete" approach the
* rest of the app uses. A ban that arrives after a message is applied on the
* next feed pass.
*/
private fun isConcordBanned(note: Note): Boolean {
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return false
val authority =
concordSessions
.sessionFor(channel.channelId.communityId)
?.state
?.value
?.authority ?: return false
return authority.isBanned(author)
}
override fun isAcceptable(note: Note): Boolean {
if (isConcordBanned(note)) return false
val mutedThreads = hiddenUsers.flow.value.mutedThreads
if (mutedThreads.isNotEmpty() && mutedThreads.contains(resolveThreadRoot(note))) return false
return note.author?.let { isAcceptable(it) } ?: true &&
@@ -274,6 +274,30 @@ fun CardBody(
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
// Concord moderation: only present when this account may actually ban the author.
val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null }
val showConcordBanDialog = remember { mutableStateOf(false) }
if (showConcordBanDialog.value) {
QuickActionAlertDialogOneButton(
title = stringRes(R.string.concord_ban_user_title),
textContent = stringRes(R.string.concord_ban_user_body),
buttonIcon = MaterialSymbols.Gavel,
buttonText = stringRes(R.string.concord_ban_user),
buttonColors =
ButtonDefaults.buttonColors(
containerColor = LightRedColor,
contentColor = Color.White,
),
onClickDoOnce = {
accountViewModel.banConcordMember(note)
showConcordBanDialog.value = false
onDismiss()
},
onDismiss = { showConcordBanDialog.value = false },
)
}
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
NoteQuickActionItem(
@@ -449,6 +473,17 @@ fun CardBody(
showReportDialog.value = true
}
}
if (canConcordBan) {
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
MaterialSymbols.Gavel,
stringRes(R.string.concord_ban_user),
) {
showConcordBanDialog.value = true
}
}
}
}
}
@@ -568,6 +568,12 @@ class AccountViewModel(
reactToOrDelete(note, reaction)
}
/** Ban the author of a Concord channel message (no-op unless this account may ban them). */
fun banConcordMember(note: Note) {
val (communityId, member) = account.concordBanTarget(note) ?: return
launchSigner { account.banConcordMember(communityId, member) }
}
@Immutable
data class NoteComposeReportState(
val isPostHidden: Boolean = false,
@@ -82,9 +82,14 @@ private fun refreshConcordChannelIndex(account: Account) {
val communityId = session.entry.id
val relays = relaysByCommunity[communityId] ?: emptySet()
for (channelIdHex in state.channels.keys) {
LocalCache
.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
.updateFrom(state, relays, myPubKey)
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
channel.updateFrom(state, relays, myPubKey)
// A member banned since these notes loaded: drop their messages now (the
// ingest gate stops future ones). removeNote invalidates the feed, so the
// ban is reflected live rather than only on the next feed pass.
channel.notes
.filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true }
.forEach { channel.removeNote(it) }
}
}
}
+3
View File
@@ -316,6 +316,9 @@
<string name="concord_create_action">Create</string>
<string name="concord_invite_action">Invite people</string>
<string name="concord_invite_title">Invite link</string>
<string name="concord_ban_user">Ban</string>
<string name="concord_ban_user_title">Ban from this community?</string>
<string name="concord_ban_user_body">This member will be added to the community banlist. Their messages will be hidden and their future posts dropped by every member. You can unban them later.</string>
<string name="chats_history_proto_nip17">encrypted</string>
<string name="chats_history_proto_nip04">legacy</string>
<string name="chats_reply_searching_history">Looking for the original message…</string>