From a2a3a60ee7c5bc1a23d9b69d723801cf4b2521b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 04:51:10 +0000 Subject: [PATCH] feat(chat): bring Concord moderation and minichat threads into the modern chat UI Concord channel messages render through the redesigned chat feed, so two features that main wired to the old surfaces were unreachable there: - Ban / make-admin moderation lived only in the social-feed NoteQuickActionMenu. Moved into the shared noteActionSections catalog (gated by concordAdminTarget / concordBanTarget), so both the chat long-press sheet and the 3-dot menu now expose it. The community-rekeying ban keeps a confirmation dialog, shared by both hosts via ConcordBanConfirmationDialog. - The minichat reply-count chip sat in the tap-to-expand detailRow, hidden in the modern compact layout. Moved into the compact engagement row (ChatReactionChips) as a "N replies" chip alongside reactions and zaps, so the thread affordance is always visible; tapping opens the message's minichat thread. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX --- .../amethyst/ui/note/elements/DropDownMenu.kt | 11 ++++ .../ui/note/elements/NoteActionSections.kt | 60 +++++++++++++++++ .../chats/feed/ChatMessageActionSheet.kt | 12 ++++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 66 ------------------- .../loggedIn/chats/feed/ChatReactionChips.kt | 61 ++++++++++++++++- 5 files changed, 143 insertions(+), 67 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 9b13e30a7f..1a93f59346 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -105,6 +105,7 @@ fun NoteDropDownMenu( var addLabelDialogShowing by remember { mutableStateOf(false) } var showShareSheet by remember { mutableStateOf(false) } var deleteConfirmationShowing by remember { mutableStateOf(false) } + var concordBanConfirming by remember { mutableStateOf(false) } // Tapping "Share" hands the note's share options off to the shared Share // drawer (ShareOptionsBottomSheet). We render it INSTEAD of the menu dialog @@ -187,6 +188,15 @@ fun NoteDropDownMenu( ) } + if (concordBanConfirming) { + ConcordBanConfirmationDialog( + note = note, + accountViewModel = accountViewModel, + onClose = { concordBanConfirming = false }, + onBanned = onDismiss, + ) + } + val handlers = NoteActionHandlers( onShare = { showShareSheet = true }, @@ -202,6 +212,7 @@ fun NoteDropDownMenu( deleteConfirmationShowing = true } }, + onConcordBan = { concordBanConfirming = true }, onDismiss = onDismiss, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index c72a4d40d8..c1bd667a34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import androidx.compose.material3.ButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboard import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol @@ -32,8 +34,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialogOneButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -64,6 +68,10 @@ data class NoteActionHandlers( val onAddLabel: () -> Unit, val onReport: () -> Unit, val onDeleteRequest: () -> Unit, + // Banning a Concord member re-keys the community for everyone, so the surface + // owns a confirmation dialog (like delete). Null where a caller never renders + // Concord messages (they simply never see the Ban tile anyway). + val onConcordBan: (() -> Unit)? = null, val onDismiss: () -> Unit, ) @@ -339,7 +347,59 @@ fun noteActionSections( } else { add(NoteAction(MaterialSymbols.Report, stringRes(R.string.block_report), isDestructive = true, onClick = handlers.onReport)) } + + // Concord channel moderation, gated by this account's authority over the + // message's author (both return null unless it's a Concord message this + // account may act on). Promote/demote is instant; a ban re-keys the + // community, so it defers to the surface's confirmation dialog. + val concordAdmin = accountViewModel.account.concordAdminTarget(note) + if (concordAdmin != null) { + val isAdmin = concordAdmin.third + add( + NoteAction( + MaterialSymbols.Shield, + stringRes(if (isAdmin) R.string.concord_remove_admin else R.string.concord_make_admin), + ) { + accountViewModel.toggleConcordAdmin(note) + handlers.onDismiss() + }, + ) + } + if (handlers.onConcordBan != null && accountViewModel.account.concordBanTarget(note) != null) { + add(NoteAction(MaterialSymbols.Gavel, stringRes(R.string.concord_ban_user), isDestructive = true, onClick = handlers.onConcordBan)) + } } return listOf(author, copyAndShare, editAndBroadcast, organize, moderation).filter { it.isNotEmpty() } } + +/** + * Confirmation for a Concord ban — banning re-keys the community for every remaining + * member (CORD-06 refounding), so it's a heavier action than a mute and gets its own + * dialog. Shared by both action surfaces (sheet + 3-dot menu). [onClose] dismisses the + * dialog; [onBanned] fires after the ban is issued (so the host can also close itself). + */ +@Composable +fun ConcordBanConfirmationDialog( + note: Note, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + onBanned: () -> Unit, +) { + 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) + onBanned() + }, + onDismiss = onClose, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 20b5292bab..80e6e5b9a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog import com.vitorpamplona.amethyst.ui.note.RenderReaction import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid import com.vitorpamplona.amethyst.ui.note.elements.AddHashtagLabelDialog +import com.vitorpamplona.amethyst.ui.note.elements.ConcordBanConfirmationDialog import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams import com.vitorpamplona.amethyst.ui.note.elements.NoteActionHandlers import com.vitorpamplona.amethyst.ui.note.elements.ShareOptionsBottomSheet @@ -123,6 +124,7 @@ fun ChatMessageActionSheet( var reportDialogShowing by remember { mutableStateOf(false) } var addLabelDialogShowing by remember { mutableStateOf(false) } var deleteConfirmationShowing by remember { mutableStateOf(false) } + var concordBanConfirming by remember { mutableStateOf(false) } // Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped // deletion — a public NIP-09 would e-tag the rumor id onto public relays. @@ -206,6 +208,15 @@ fun ChatMessageActionSheet( ) } + if (concordBanConfirming) { + ConcordBanConfirmationDialog( + note = note, + accountViewModel = accountViewModel, + onClose = { concordBanConfirming = false }, + onBanned = onDismiss, + ) + } + val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle( DropDownParams( isFollowingAuthor = false, @@ -259,6 +270,7 @@ fun ChatMessageActionSheet( deleteConfirmationShowing = true } }, + onConcordBan = { concordBanConfirming = true }, onDismiss = onDismiss, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index f7113e6997..f6ffe607af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -21,19 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -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.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -50,18 +45,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel -import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.DisplayDraftChat import com.vitorpamplona.amethyst.ui.note.LikeReaction @@ -111,7 +98,6 @@ import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as MaterialSymbolIcon @Composable fun ChatroomMessageCompose( @@ -372,8 +358,6 @@ fun NormalChatNote( ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) - MinichatReplyChip(note, accountViewModel, nav) - val geo = remember(note) { note.event?.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) @@ -481,56 +465,6 @@ private fun MessageBubbleLines( } } -/** - * A chip on a chat message's action row showing how many kind-1111 thread ("minichat") - * replies it has; tapping opens that thread. Shown only when there is at least one — an - * inline reply is an ordinary message and isn't counted. - * - * Only shown where minichats are actually wired: the public chats (Concord, NIP-28, NIP-29). - * NIP-17 DMs are deliberately excluded — most clients don't render kind-1111 replies in a DM - * view, so a thread there would be a dead end. - */ -@Composable -private fun MinichatReplyChip( - note: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val supportsMinichat = - remember(note) { - note.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true - } - if (!supportsMinichat) return - - val count by observeNoteMinichatReplyCount(note, accountViewModel) - if (count > 0) { - Spacer(StdHorzSpacer) - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.clickable { nav.nav(Route.ChatMinichat(note.idHex)) }, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp), - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) { - MaterialSymbolIcon( - symbol = MaterialSymbols.Forum, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(13.dp), - ) - Text( - text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } -} - /** * Id of a note whose reply-to preview should be suppressed on a message row. Set by a * thread view that already pins that note at the top (the minichat pins its root), so each diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index 299e46aad5..f78359e2b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -42,16 +42,25 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.LikedIcon import com.vitorpamplona.amethyst.ui.note.ObserveZapAmountText import com.vitorpamplona.amethyst.ui.note.ZappedIcon @@ -119,12 +128,23 @@ fun ChatReactionChips( ) } + // Kind-1111 thread ("minichat") replies. Only public chats wire them up (Concord, + // NIP-28, NIP-29); NIP-17 DMs deliberately don't, so most clients wouldn't render + // a thread there. An inline reply is an ordinary message and isn't counted. + val supportsMinichat = + remember(baseNote) { + baseNote.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true + } + val minichatCount by if (supportsMinichat) observeNoteMinichatReplyCount(baseNote, accountViewModel) else remember { mutableStateOf(0) } + ObserveZapAmountText(baseNote, accountViewModel) { zapAmount -> RenderChatReactionChips( chips = chips, zapAmount = zapAmount, + minichatCount = minichatCount, onToggleReaction = { accountViewModel.reactToOrDelete(baseNote, it) }, onOpenDetails = { showDetails = true }, + onOpenMinichat = { nav.nav(Route.ChatMinichat(baseNote.idHex)) }, ) } } @@ -134,10 +154,12 @@ fun ChatReactionChips( private fun RenderChatReactionChips( chips: ImmutableList, zapAmount: String, + minichatCount: Int, onToggleReaction: (String) -> Unit, onOpenDetails: () -> Unit, + onOpenMinichat: () -> Unit, ) { - if (chips.isEmpty() && zapAmount.isBlank()) return + if (chips.isEmpty() && zapAmount.isBlank() && minichatCount <= 0) return FlowRow( // Inset from the bubble's edge so overlapping chips ride the border without @@ -157,6 +179,43 @@ private fun RenderChatReactionChips( onLongClick = onOpenDetails, ) } + + if (minichatCount > 0) { + MinichatChip(minichatCount, onClick = onOpenMinichat) + } + } +} + +/** + * "N replies" thread entry, styled like the reaction/zap chips so the engagement row + * reads as one strip under the bubble. Tapping opens the message's minichat thread. + */ +@Composable +private fun MinichatChip( + count: Int, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + shape = ButtonBorder, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder), + ) { + ChipContentRow { + Icon( + symbol = MaterialSymbols.Forum, + contentDescription = null, + tint = MaterialTheme.colorScheme.grayText, + modifier = Size14Modifier, + ) + Text( + text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count), + fontSize = Font12SP, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) + } } }