diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index cfc404698e..c3608bcadc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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? { + 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 && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 7d4d6ce281..e1782a14ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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 + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 0672eacf9a..f0c46aee14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 50bb7394e8..1491b5befc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -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) } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f755e1d923..cc0c3f6504 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -316,6 +316,9 @@ Create Invite people Invite link + Ban + Ban from this community? + 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. encrypted legacy Looking for the original message…