mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+8
-3
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user