mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+60
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+12
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
-66
@@ -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
|
||||
|
||||
+60
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user