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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
This commit is contained in:
Claude
2026-07-15 04:51:10 +00:00
parent ce727351b8
commit a2a3a60ee7
5 changed files with 143 additions and 67 deletions
@@ -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,
)
@@ -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,
)
}
@@ -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,
)
@@ -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
@@ -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<ReactionChip>,
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,
)
}
}
}