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, + ) + } } }