refactor: chat audit cleanup - shared action catalog and dedup

- New noteActionSections: the full note-action inventory (follow, copy,
  share, edit, broadcast, timestamp, pin, label, bookmarks, playlists,
  emoji packs, mute, delete/report) lives once, with all gating, and is
  rendered by BOTH the 3-dot NoteDropDownMenu (as M3 rows) and the chat
  long-press sheet (as icon tiles) - the two surfaces can no longer
  drift. The menu also gains the delete confirmation dialog and the
  privacy-safe private-rumor delete the sheet got in the audit fixes.
- payViaIntentOrManualSplit: the shared zap-payment tail (wallet intent
  vs manual split screen), replacing three verbatim copies in
  ReactionsRow and one in the sheet.
- Chat bubble shapes move to commons ChatTheme.kt as the single source
  of truth (18dp geometry incl. grouped variants); Desktop now inherits
  the modernized corners, and the stale 15dp duplicates are gone.
- Deleted the unreferenced RenderCreateChannelNote /
  RenderChangeChannelMetadataNote card renderers (~360 lines).
- ChatSystemMessage renders one Surface with a conditional clickable
  instead of two duplicated branches.
- ChatEngagementDetailSheet rows share one EngagementRow scaffold;
  delivery tick selection deduped into DeliveryLadderTick /
  DeliveryStatusTick.
- Hardcoded chat font sizes replaced with Font12SP / named constants.

Not merged on purpose: ChipReactionGlyph vs MultiSetCompose's gallery
glyph dispatch - the gallery variant handles interactive secret emoji
and alignment modifiers, so unification would change behavior.

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-12 22:33:58 +00:00
parent e41b089a26
commit 57ecda7302
13 changed files with 601 additions and 980 deletions
@@ -1153,6 +1153,31 @@ private data class OnchainZapRequest(
val amountSats: Long?,
)
/**
* Pays a single payable via a wallet intent, or routes multiple payables (zap
* splits) to the manual payment screen. The shared tail of every zap flow.
*/
@OptIn(ExperimentalUuidApi::class)
fun payViaIntentOrManualSplit(
payables: ImmutableList<ZapPaymentHandler.Payable>,
context: Context,
accountViewModel: AccountViewModel,
nav: INav,
onPaymentError: () -> Unit = {},
) {
if (payables.size == 1) {
val payable = payables.first()
payViaIntent(payable.invoice, context, { }) { error ->
onPaymentError()
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, payables)
nav.nav(Route.ManualZapSplitPayment(uid))
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class)
fun ZapReaction(
@@ -1204,17 +1229,7 @@ fun ZapReaction(
}
},
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
payViaIntentOrManualSplit(it, context, accountViewModel, nav, onPaymentError = { zappingProgress = 0f })
},
onCustomAmount = {
wantsToSetCustomZap = true
@@ -1260,17 +1275,7 @@ fun ZapReaction(
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
payViaIntentOrManualSplit(it, context, accountViewModel, nav, onPaymentError = { zappingProgress = 0f })
},
onReloadNutzap = { amount ->
wantsToZap = false
@@ -26,43 +26,33 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboard
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
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.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun MoreOptionsButton(
@@ -114,6 +104,7 @@ fun NoteDropDownMenu(
var reportDialogShowing by remember { mutableStateOf(false) }
var addLabelDialogShowing by remember { mutableStateOf(false) }
var showShareSheet by remember { mutableStateOf(false) }
var deleteConfirmationShowing 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
@@ -167,247 +158,79 @@ fun NoteDropDownMenu(
)
}
// 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.
val performDelete = {
if (note.isPrivateRumor()) {
accountViewModel.deletePrivately(note)
} else {
accountViewModel.delete(note)
}
}
if (deleteConfirmationShowing) {
QuickActionAlertDialog(
title = stringRes(R.string.quick_action_request_deletion_alert_title),
textContent = stringRes(R.string.quick_action_request_deletion_alert_body),
buttonIcon = MaterialSymbols.Delete,
buttonText = stringRes(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
performDelete()
onDismiss()
},
onClickDontShowAgain = {
performDelete()
accountViewModel.account.settings.setHideDeleteRequestDialog()
onDismiss()
},
onDismiss = { deleteConfirmationShowing = false },
)
}
val handlers =
NoteActionHandlers(
onShare = { showShareSheet = true },
onEditPost = { wantsToEditPost.value = true },
onEditDraft = { nav.nav { routeEditDraftTo(note, accountViewModel.account) } },
onAddLabel = { addLabelDialogShowing = true },
onReport = { reportDialogShowing = true },
onDeleteRequest = {
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
performDelete()
onDismiss()
} else {
deleteConfirmationShowing = true
}
},
onDismiss = onDismiss,
)
// "Copy Text" copies the newest version of a versioned post when the caller
// is looking at one.
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
M3ActionDialog(
title = stringRes(R.string.note_actions_dialog_title),
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
// Unsealed rumors (private replies/posts received in gift wraps) are
// unsigned and must never be referenced by a public event: hide every
// action that would publish an e-tag of this note (broadcast, edit,
// OTS timestamp, pin, label, public bookmark, deletion request).
val isPrivateRumor = note.isPrivateRumor()
// Follow section
M3ActionSection {
if (!state.isFollowingAuthor) {
M3ActionRow(icon = MaterialSymbols.PersonAdd, text = stringRes(R.string.follow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.follow(author)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.PersonRemove, text = stringRes(R.string.unfollow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.unfollow(author)
onDismiss()
}
}
M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) {
val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow
nav.nav(Route.PeopleListManagement(authorHexKey))
onDismiss()
}
}
// Copy & Share section. The copy-to-clipboard rows live here (and only
// here); the "Share" row hands off to the shared Share drawer.
M3ActionSection {
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) {
scope.launch {
clipboardManager.setText(it)
}
}
onDismiss()
}
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText("nostr:${it.pubkeyNpub()}")
onDismiss()
}
}
}
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(note.toNostrUri())
onDismiss()
}
}
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_raw_json)) {
val event = note.event
if (event != null) {
scope.launch {
val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) }
clipboardManager.setText(json)
onDismiss()
}
} else {
onDismiss()
}
}
if (!isPrivateRumor) {
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) {
showShareSheet = true
}
}
}
// Edit & Broadcast section
M3ActionSection {
if (state.isLoggedUser && note.isDraft()) {
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_draft)) {
nav.nav { routeEditDraftTo(note, accountViewModel.account) }
}
}
if (!note.isDraft() && !isPrivateRumor) {
if (note.event is TextNoteEvent) {
if (state.isLoggedUser) {
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_post)) {
wantsToEditPost.value = true
}
} else {
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.propose_an_edit)) {
wantsToEditPost.value = true
}
}
} else if (note.event is LongTextNoteEvent && state.isLoggedUser) {
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_article)) {
nav.nav { Route.NewLongFormPost(version = note.idHex) }
}
}
}
// Rumors are rebroadcast as their delivering gift wrap; hidden
// when the wrap is unknown (the unsigned rumor must never be
// published).
if (accountViewModel.canBroadcast(note)) {
M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
onDismiss()
}
}
}
// Timestamp & Bookmarks section
M3ActionSection {
if (!isPrivateRumor) {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() }
} else {
M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
onDismiss()
}
}
}
if (state.isLoggedUser && !isPrivateRumor) {
if (state.isPinnedNote) {
M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.unpin_from_profile)) {
accountViewModel.removePin(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.pin_to_profile)) {
accountViewModel.addPin(note)
onDismiss()
}
}
}
if (!isPrivateRumor) {
M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) {
addLabelDialogShowing = true
}
}
// Pick exactly one curation flow per kind: music tracks go to playlists, emoji
// packs go to the emoji list, everything else gets the standard bookmark rows.
// Showing both at once is noisy and makes "bookmark" feel like the catch-all when
// it really isn't for these kinds.
when {
isPrivateRumor -> {
// No bookmark/playlist/emoji-list rows for private rumors:
// those lists reference the note by id, which other devices
// can't resolve from relays and public lists would leak.
}
note.event is MusicTrackEvent && note is AddressableNote -> {
// Music tracks (kind 36787) belong in playlists (kind 34139). The
// sheet behind this nav lets the user toggle membership across all of
// their own playlists in one place — that subsumes the private/public
// bookmark add+remove pair for this kind.
M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.add_to_music_playlist)) {
nav.nav(Route.AddToMusicPlaylist(note.address.toValue()))
onDismiss()
}
}
note.event is EmojiPackEvent -> {
// Emoji packs belong in the user's emoji list (kind 10030), not the
// bookmark list.
val emojiText =
if (state.isEmojiPackInMyList) {
stringRes(R.string.remove_from_emoji_list)
} else {
stringRes(R.string.add_to_emoji_list)
}
M3ActionRow(icon = MaterialSymbols.EmojiEmotions, text = emojiText) {
val address = (note as AddressableNote).address
nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag))
onDismiss()
}
}
else -> {
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
onDismiss()
}
if (state.isPrivateBookmarkNote) {
M3ActionRow(icon = MaterialSymbols.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
accountViewModel.removePrivateBookmark(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.Lock, text = stringRes(R.string.add_to_private_bookmarks)) {
accountViewModel.addPrivateBookmark(note)
onDismiss()
}
}
if (state.isPublicBookmarkNote) {
M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
onDismiss()
}
}
}
}
}
// Moderation section
M3ActionSection {
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
M3ActionRow(
icon = MaterialSymbols.AutoMirrored.VolumeOff,
text = stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
) {
if (isThreadMuted) {
accountViewModel.unmuteThread(note)
} else {
accountViewModel.muteThread(note)
}
onDismiss()
}
if (state.isLoggedUser && !isPrivateRumor) {
M3ActionRow(icon = MaterialSymbols.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
accountViewModel.delete(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.Report, text = stringRes(R.string.block_report), isDestructive = true) {
reportDialogShowing = true
// The action inventory is shared with the chat long-press sheet
// (noteActionSections), so the two surfaces cannot drift.
noteActionSections(
note = note,
noteVersionToCopy = lastNoteVersion,
state = state,
handlers = handlers,
accountViewModel = accountViewModel,
nav = nav,
).forEach { section ->
M3ActionSection {
section.forEach { action ->
M3ActionRow(
icon = action.symbol,
text = action.label,
isDestructive = action.isDestructive,
onClick = action.onClick,
)
}
}
}
@@ -0,0 +1,345 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalClipboard
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.AddressableNote
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** One note action, rendered as a menu row or an action-sheet tile. */
@Immutable
data class NoteAction(
val symbol: MaterialSymbol,
val label: String,
val isDestructive: Boolean = false,
val onClick: () -> Unit,
)
/**
* Callbacks for actions whose UI is owned by the rendering surface (dialogs,
* sheets), so the shared inventory stays surface-agnostic.
*/
@Immutable
data class NoteActionHandlers(
val onShare: () -> Unit,
val onEditPost: () -> Unit,
val onEditDraft: () -> Unit,
val onAddLabel: () -> Unit,
val onReport: () -> Unit,
val onDeleteRequest: () -> Unit,
val onDismiss: () -> Unit,
)
/**
* The shared note-action inventory behind both the 3-dot menu (NoteDropDownMenu)
* and the chat long-press sheet: one list of sections, each a list of actions with
* their visibility gating applied, so the two surfaces can never drift.
*
* [noteVersionToCopy] lets the menu copy the latest edit of a versioned post; every
* other caller passes [note].
*/
@Composable
fun noteActionSections(
note: Note,
noteVersionToCopy: Note,
state: DropDownParams,
handlers: NoteActionHandlers,
accountViewModel: AccountViewModel,
nav: INav,
): List<List<NoteAction>> {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
// Unsealed rumors (private replies/posts received in gift wraps) are
// unsigned and must never be referenced by a public event: hide every
// action that would publish an e-tag of this note (broadcast, edit,
// OTS timestamp, pin, label, public bookmark).
val isPrivateRumor = note.isPrivateRumor()
val author =
buildList {
if (!state.isLoggedUser) {
if (!state.isFollowingAuthor) {
add(
NoteAction(MaterialSymbols.PersonAdd, stringRes(R.string.follow)) {
note.author?.let { accountViewModel.follow(it) }
handlers.onDismiss()
},
)
} else {
add(
NoteAction(MaterialSymbols.PersonRemove, stringRes(R.string.unfollow)) {
note.author?.let { accountViewModel.unfollow(it) }
handlers.onDismiss()
},
)
}
}
add(
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(R.string.follow_set_add_author_from_note_action)) {
note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) }
handlers.onDismiss()
},
)
}
val copyAndShare =
buildList {
add(
NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) {
accountViewModel.decrypt(noteVersionToCopy) {
scope.launch { clipboardManager.setText(it) }
}
handlers.onDismiss()
},
)
add(
NoteAction(MaterialSymbols.AlternateEmail, stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText("nostr:${it.pubkeyNpub()}")
handlers.onDismiss()
}
}
},
)
add(
NoteAction(MaterialSymbols.FormatQuote, stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(note.toNostrUri())
handlers.onDismiss()
}
},
)
add(
NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_raw_json)) {
val event = note.event
if (event != null) {
scope.launch {
val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) }
clipboardManager.setText(json)
handlers.onDismiss()
}
} else {
handlers.onDismiss()
}
},
)
if (!isPrivateRumor) {
add(NoteAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = handlers.onShare))
}
}
val editAndBroadcast =
buildList {
if (state.isLoggedUser && note.isDraft()) {
add(NoteAction(MaterialSymbols.Edit, stringRes(R.string.edit_draft), onClick = handlers.onEditDraft))
}
if (!note.isDraft() && !isPrivateRumor) {
if (note.event is TextNoteEvent) {
add(
NoteAction(
MaterialSymbols.Edit,
stringRes(if (state.isLoggedUser) R.string.edit_post else R.string.propose_an_edit),
onClick = handlers.onEditPost,
),
)
} else if (note.event is LongTextNoteEvent && state.isLoggedUser) {
add(
NoteAction(MaterialSymbols.Edit, stringRes(R.string.edit_article)) {
nav.nav { Route.NewLongFormPost(version = note.idHex) }
},
)
}
}
// Rumors are rebroadcast as their delivering gift wrap; hidden when the
// wrap is unknown (the unsigned rumor must never be published).
if (accountViewModel.canBroadcast(note)) {
add(
NoteAction(MaterialSymbols.CellTower, stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
handlers.onDismiss()
},
)
}
}
val organize =
buildList {
if (!isPrivateRumor) {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
add(NoteAction(MaterialSymbols.Schedule, stringRes(R.string.timestamp_pending)) { handlers.onDismiss() })
} else {
add(
NoteAction(MaterialSymbols.Schedule, stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
handlers.onDismiss()
},
)
}
}
if (state.isLoggedUser && !isPrivateRumor) {
add(
NoteAction(
MaterialSymbols.PushPin,
stringRes(if (state.isPinnedNote) R.string.unpin_from_profile else R.string.pin_to_profile),
) {
if (state.isPinnedNote) {
accountViewModel.removePin(note)
} else {
accountViewModel.addPin(note)
}
handlers.onDismiss()
},
)
}
if (!isPrivateRumor) {
add(NoteAction(MaterialSymbols.Tag, stringRes(R.string.add_hashtag_label), onClick = handlers.onAddLabel))
}
// Pick exactly one curation flow per kind: music tracks go to playlists,
// emoji packs go to the emoji list, everything else gets the standard
// bookmark rows. No bookmark/playlist/emoji-list rows for private rumors:
// those lists reference the note by id, which other devices can't resolve
// from relays and public lists would leak.
when {
isPrivateRumor -> {}
note.event is MusicTrackEvent && note is AddressableNote -> {
add(
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(R.string.add_to_music_playlist)) {
nav.nav(Route.AddToMusicPlaylist(note.address.toValue()))
handlers.onDismiss()
},
)
}
note.event is EmojiPackEvent -> {
val emojiText =
if (state.isEmojiPackInMyList) {
stringRes(R.string.remove_from_emoji_list)
} else {
stringRes(R.string.add_to_emoji_list)
}
add(
NoteAction(MaterialSymbols.EmojiEmotions, emojiText) {
val address = (note as AddressableNote).address
nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag))
handlers.onDismiss()
},
)
}
else -> {
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
add(
NoteAction(MaterialSymbols.BookmarkAdd, stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
handlers.onDismiss()
},
)
if (state.isPrivateBookmarkNote) {
add(
NoteAction(MaterialSymbols.LockOpen, stringRes(R.string.remove_from_private_bookmarks)) {
accountViewModel.removePrivateBookmark(note)
handlers.onDismiss()
},
)
} else {
add(
NoteAction(MaterialSymbols.Lock, stringRes(R.string.add_to_private_bookmarks)) {
accountViewModel.addPrivateBookmark(note)
handlers.onDismiss()
},
)
}
if (state.isPublicBookmarkNote) {
add(
NoteAction(MaterialSymbols.BookmarkRemove, stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
handlers.onDismiss()
},
)
} else {
add(
NoteAction(MaterialSymbols.Bookmark, stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
handlers.onDismiss()
},
)
}
}
}
}
val moderation =
buildList {
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
add(
NoteAction(
MaterialSymbols.AutoMirrored.VolumeOff,
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
) {
if (isThreadMuted) {
accountViewModel.unmuteThread(note)
} else {
accountViewModel.muteThread(note)
}
handlers.onDismiss()
},
)
// Own messages always get a delete affordance (the surface routes private
// rumors through the gift-wrapped deletion); reporting yourself never
// makes sense, so Report is others-only.
if (state.isLoggedUser) {
add(NoteAction(MaterialSymbols.Delete, stringRes(R.string.request_deletion), isDestructive = true, onClick = handlers.onDeleteRequest))
} else {
add(NoteAction(MaterialSymbols.Report, stringRes(R.string.block_report), isDestructive = true, onClick = handlers.onReport))
}
}
return listOf(author, copyAndShare, editAndBroadcast, organize, moderation).filter { it.isNotEmpty() }
}
@@ -137,35 +137,16 @@ private fun ZapperRow(
nav: INav,
onDismiss: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.clickable {
// Open the zap receipt in its own thread view (replies to the
// zap itself); profile stays one tap away on the avatar. Falls
// back to the zapper's profile while the receipt is unknown.
val destination =
zap.zapNote?.let { routeFor(it, accountViewModel.account) }
?: zap.user?.let { routeFor(it) }
destination?.let {
nav.nav(it)
onDismiss()
}
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
Column(modifier = Modifier.fillMaxWidth()) {
EngagementRow(
user = zap.user,
// The zap receipt's own thread view (replies to the zap itself);
// falls back to the zapper's profile while the receipt is unknown.
eventNote = zap.zapNote,
accountViewModel = accountViewModel,
nav = nav,
onDismiss = onDismiss,
) {
zap.user?.let { user ->
UserPicture(user, Size25dp, Modifier, accountViewModel, nav)
Row(modifier = Modifier.weight(1f)) {
UsernameDisplay(baseUser = user, accountViewModel = accountViewModel)
}
} ?: Spacer(Modifier.weight(1f))
zap.amount?.let { amount ->
Text(
text = "$amount",
@@ -193,6 +174,32 @@ private fun ReactorRow(
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
) {
EngagementRow(
user = entry.user,
eventNote = entry.reactionNote,
accountViewModel = accountViewModel,
nav = nav,
onDismiss = onDismiss,
) {
ChipReactionGlyph(entry.reactionType)
}
}
/**
* Shared engagement-list row: avatar + name + a trailing element. The row opens
* the engagement event's own thread view (replies/reactions TO the zap or
* reaction), falling back to the user's profile; the avatar keeps its built-in
* one-tap profile navigation.
*/
@Composable
private fun EngagementRow(
user: User?,
eventNote: Note?,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
trailing: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -201,20 +208,25 @@ private fun ReactorRow(
Modifier
.fillMaxWidth()
.clickable {
// Open the reaction event in its own thread view; the avatar
// still navigates to the profile.
val destination =
routeFor(entry.reactionNote, accountViewModel.account)
?: routeFor(entry.user)
eventNote?.let { routeFor(it, accountViewModel.account) }
?: user?.let { routeFor(it) }
nav.nav(destination)
onDismiss()
destination?.let {
nav.nav(it)
onDismiss()
}
},
) {
UserPicture(entry.user, Size25dp, Modifier, accountViewModel, nav)
Row(modifier = Modifier.weight(1f)) {
UsernameDisplay(baseUser = entry.user, accountViewModel = accountViewModel)
if (user != null) {
UserPicture(user, Size25dp, Modifier, accountViewModel, nav)
Row(modifier = Modifier.weight(1f)) {
UsernameDisplay(baseUser = user, accountViewModel = accountViewModel)
}
} else {
Spacer(Modifier.weight(1f))
}
ChipReactionGlyph(entry.reactionType)
trailing()
}
}
@@ -46,12 +46,10 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -67,8 +65,6 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
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.ChangeReactionIcon
@@ -77,10 +73,12 @@ 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.DropDownParams
import com.vitorpamplona.amethyst.ui.note.elements.NoteActionHandlers
import com.vitorpamplona.amethyst.ui.note.elements.ShareOptionsBottomSheet
import com.vitorpamplona.amethyst.ui.note.elements.noteActionSections
import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount
import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
@@ -93,16 +91,10 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
// null amount = open the on-chain dialog with no prefill.
@Immutable
@@ -112,12 +104,9 @@ private data class OnchainZapRequest(
/**
* Long-press surface for a chat message: a quick-reaction row and the unpacked
* zap amount presets on top, then every note action the 3-dot menu offers,
* grouped by similarity into rows of icon tiles (message, author, organize,
* moderation).
*
* The music-playlist and emoji-pack curation specials from the 3-dot menu are
* intentionally absent: those kinds never render in a chat feed.
* zap amount presets on top, chat-only tiles (reply, edit draft), then the shared
* note-action inventory (noteActionSections the same one behind the 3-dot menu,
* so the two surfaces can never drift) rendered as rows of icon tiles.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -248,267 +237,80 @@ fun ChatMessageActionSheet(
SectionDivider()
}
MessageSection(note, state, onWantsToReply, onWantsToEditDraft, onEditPost = { wantsToEditPost = true }, onShare = { showShareSheet = true }, onDismiss, accountViewModel)
// Chat-only affordances first, then the shared note-action inventory
// (the same one behind the 3-dot menu, so the two surfaces never drift).
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
SectionDivider()
AuthorSection(note, state, onDismiss, accountViewModel, nav)
if (!note.isPrivateRumor()) {
SectionDivider()
OrganizeSection(note, state, onAddLabel = { addLabelDialogShowing = true }, onDismiss, accountViewModel, nav)
}
SectionDivider()
ModerationSection(
note = note,
state = state,
onReport = { reportDialogShowing = true },
onDeleteRequest = {
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
performDelete()
val handlers =
NoteActionHandlers(
onShare = { showShareSheet = true },
onEditPost = { wantsToEditPost = true },
onEditDraft = {
onWantsToEditDraft(note)
onDismiss()
} else {
deleteConfirmationShowing = true
}
},
onDismiss = onDismiss,
},
onAddLabel = { addLabelDialogShowing = true },
onReport = { reportDialogShowing = true },
onDeleteRequest = {
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
performDelete()
onDismiss()
} else {
deleteConfirmationShowing = true
}
},
onDismiss = onDismiss,
)
noteActionSections(
note = note,
noteVersionToCopy = note,
state = state,
handlers = handlers,
accountViewModel = accountViewModel,
)
nav = nav,
).forEach { section ->
SectionDivider()
TileRow {
section.forEach { action ->
ActionTile(action.symbol, action.label, action.isDestructive, action.onClick)
}
}
}
Spacer(Modifier.height(24.dp))
}
}
}
// ---------- Action tile sections (the unpacked 3-dot menu) ----------
// ---------- Action tile sections ----------
@OptIn(ExperimentalLayoutApi::class, ExperimentalUuidApi::class)
/** Chat-specific tiles (reply, edit draft) that have no 3-dot menu equivalent. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun MessageSection(
private fun ChatOnlyRow(
note: Note,
state: DropDownParams,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onEditPost: () -> Unit,
onShare: () -> Unit,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val isPrivateRumor = note.isPrivateRumor()
TileRow {
if (!note.isDraft()) {
ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.reply_description)) {
onWantsToReply(note)
onDismiss()
}
}
if (state.isLoggedUser && note.isDraft()) {
if (note.isDraft() && !state.isLoggedUser) return
if (note.isDraft() && state.isLoggedUser) {
TileRow {
ActionTile(MaterialSymbols.Edit, stringRes(R.string.edit_draft)) {
onWantsToEditDraft(note)
onDismiss()
}
}
if (!note.isDraft() && !isPrivateRumor && note.event is TextNoteEvent) {
ActionTile(
MaterialSymbols.Edit,
stringRes(if (state.isLoggedUser) R.string.edit_post else R.string.propose_an_edit),
onClick = onEditPost,
)
}
ActionTile(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) {
accountViewModel.decrypt(note) {
scope.launch { clipboardManager.setText(it) }
}
onDismiss()
}
ActionTile(MaterialSymbols.FormatQuote, stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(note.toNostrUri())
onDismiss()
}
}
ActionTile(MaterialSymbols.ContentCopy, stringRes(R.string.copy_raw_json)) {
val event = note.event
if (event != null) {
scope.launch {
val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) }
clipboardManager.setText(json)
onDismiss()
}
} else {
onDismiss()
}
}
if (!isPrivateRumor) {
ActionTile(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = onShare)
}
// Rumors are rebroadcast as their delivering gift wrap; hidden when the
// wrap is unknown (the unsigned rumor must never be published).
if (accountViewModel.canBroadcast(note)) {
ActionTile(MaterialSymbols.CellTower, stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
onDismiss()
}
}
return
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AuthorSection(
note: Note,
state: DropDownParams,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
TileRow {
if (!state.isLoggedUser) {
if (!state.isFollowingAuthor) {
ActionTile(MaterialSymbols.PersonAdd, stringRes(R.string.follow)) {
note.author?.let { accountViewModel.follow(it) }
onDismiss()
}
} else {
ActionTile(MaterialSymbols.PersonRemove, stringRes(R.string.unfollow)) {
note.author?.let { accountViewModel.unfollow(it) }
onDismiss()
}
}
}
ActionTile(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(R.string.follow_set_add_author_from_note_action)) {
note.author?.pubkeyHex?.let {
nav.nav(Route.PeopleListManagement(it))
}
ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.reply_description)) {
onWantsToReply(note)
onDismiss()
}
ActionTile(MaterialSymbols.AlternateEmail, stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText("nostr:${it.pubkeyNpub()}")
onDismiss()
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun OrganizeSection(
note: Note,
state: DropDownParams,
onAddLabel: () -> Unit,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
TileRow {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
ActionTile(MaterialSymbols.Schedule, stringRes(R.string.timestamp_pending)) { onDismiss() }
} else {
ActionTile(MaterialSymbols.Schedule, stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
onDismiss()
}
}
if (state.isLoggedUser) {
ActionTile(
MaterialSymbols.PushPin,
stringRes(if (state.isPinnedNote) R.string.unpin_from_profile else R.string.pin_to_profile),
) {
if (state.isPinnedNote) {
accountViewModel.removePin(note)
} else {
accountViewModel.addPin(note)
}
onDismiss()
}
}
ActionTile(MaterialSymbols.Tag, stringRes(R.string.add_hashtag_label), onClick = onAddLabel)
ActionTile(MaterialSymbols.BookmarkAdd, stringRes(R.string.manage_bookmark_label, stringRes(R.string.post))) {
nav.nav(Route.PostBookmarkManagement(note.idHex))
onDismiss()
}
if (state.isPrivateBookmarkNote) {
ActionTile(MaterialSymbols.LockOpen, stringRes(R.string.remove_from_private_bookmarks)) {
accountViewModel.removePrivateBookmark(note)
onDismiss()
}
} else {
ActionTile(MaterialSymbols.Lock, stringRes(R.string.add_to_private_bookmarks)) {
accountViewModel.addPrivateBookmark(note)
onDismiss()
}
}
if (state.isPublicBookmarkNote) {
ActionTile(MaterialSymbols.BookmarkRemove, stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
onDismiss()
}
} else {
ActionTile(MaterialSymbols.Bookmark, stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
onDismiss()
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ModerationSection(
note: Note,
state: DropDownParams,
onReport: () -> Unit,
onDeleteRequest: () -> Unit,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
TileRow {
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
ActionTile(
MaterialSymbols.AutoMirrored.VolumeOff,
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
) {
if (isThreadMuted) {
accountViewModel.unmuteThread(note)
} else {
accountViewModel.muteThread(note)
}
onDismiss()
}
// Own messages always get a delete affordance (the private-rumor variant
// goes through the gift-wrapped deletion); reporting yourself never
// makes sense, so Report is others-only.
if (state.isLoggedUser) {
ActionTile(MaterialSymbols.Delete, stringRes(R.string.request_deletion), isDestructive = true, onClick = onDeleteRequest)
} else {
ActionTile(MaterialSymbols.Report, stringRes(R.string.block_report), isDestructive = true, onClick = onReport)
}
}
}
@@ -659,16 +461,7 @@ private fun QuickZapAmountRow(
}
val onPayViaIntent = { payables: ImmutableList<ZapPaymentHandler.Payable> ->
if (payables.size == 1) {
val payable = payables.first()
payViaIntent(payable.invoice, context, { }) { error ->
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, payables)
nav.nav(Route.ManualZapSplitPayment(uid))
}
payViaIntentOrManualSplit(payables, context, accountViewModel, nav)
}
Row(
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.note.ObserveZapAmountText
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
@@ -72,6 +73,8 @@ private data class ReactionChip(
val includesMe: Boolean,
)
private val ChipGlyphFontSize = 13.sp
/**
* Messenger-style pills under a chat bubble summarizing who engaged with the message:
* one chip per reaction emoji (with count, highlighted when the logged-in user is
@@ -194,7 +197,7 @@ private fun ReactionChipView(
if (chip.count > 1) {
Text(
text = chip.count.toString(),
fontSize = 12.sp,
fontSize = Font12SP,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
@@ -219,7 +222,7 @@ private fun ZapChip(
ZappedIcon(Size14Modifier)
Text(
text = amount,
fontSize = 12.sp,
fontSize = Font12SP,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
@@ -246,18 +249,18 @@ internal fun ChipReactionGlyph(reactionType: String) {
InLineIconRenderer(
persistentListOf(CustomEmoji.ImageUrlType(url)),
style = SpanStyle(color = MaterialTheme.colorScheme.onBackground),
fontSize = 13.sp,
fontSize = ChipGlyphFontSize,
maxLines = 1,
)
} else {
when (reactionType) {
"+" -> LikedIcon(Size14Modifier)
"-" -> Text(text = "👎", fontSize = 13.sp, maxLines = 1)
"-" -> Text(text = "👎", fontSize = ChipGlyphFontSize, maxLines = 1)
else -> {
if (EmojiCoder.isCoded(reactionType)) {
AnimatedBorderTextCornerRadius(reactionType, fontSize = 13.sp)
AnimatedBorderTextCornerRadius(reactionType, fontSize = ChipGlyphFontSize)
} else {
Text(text = reactionType, fontSize = 13.sp, maxLines = 1)
Text(text = reactionType, fontSize = ChipGlyphFontSize, maxLines = 1)
}
}
}
@@ -25,16 +25,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeBottom
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeMiddle
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeTop
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemBottom
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemMiddle
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemTop
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMeBottom
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMeMiddle
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMeTop
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThemBottom
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThemMiddle
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThemTop
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -29,6 +30,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -54,21 +56,17 @@ fun ChatSystemMessage(
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.Center,
) {
if (onClick != null) {
Surface(
onClick = onClick,
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
SystemMessageText(text)
}
} else {
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
SystemMessageText(text)
}
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
if (onClick != null) {
Modifier.clip(ButtonBorder).clickable(onClick = onClick)
} else {
Modifier
},
) {
SystemMessageText(text)
}
}
}
@@ -1,56 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
@Composable
fun RenderChangeChannelMetadataNote(
note: Note,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? ChannelMetadataEvent ?: return
val channelInfo = remember(noteEvent) { noteEvent.channelInfo() }
val tags =
remember(noteEvent) {
noteEvent.tags.toImmutableListOfLists()
}
RenderChannelData(
noteEvent.id,
note.toNostrUri(),
channelInfo,
tags,
bgColor,
accountViewModel,
nav,
)
}
@@ -1,307 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
import kotlinx.coroutines.launch
@Composable
fun RenderCreateChannelNote(
note: Note,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? ChannelCreateEvent ?: return
val channelInfo = remember(noteEvent) { noteEvent.channelInfo() }
val tags =
remember(noteEvent) {
noteEvent.tags.toImmutableListOfLists()
}
RenderChannelData(
noteEvent.id,
note.toNostrUri(),
channelInfo,
tags,
bgColor,
accountViewModel,
nav,
)
}
@Preview
@Composable
fun RenderChannelDataPreview() {
ThemeComparisonRow {
RenderChannelData(
id = "bbaacc",
uri = "nostr:nevent1...",
channelInfo =
ChannelDataNorm(
"My Group",
"Testing About me",
"http://test.com",
listOf(Constants.mom, Constants.nos),
),
tags = EmptyTagList,
bgColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
)
}
}
@Composable
fun RenderChannelData(
id: HexKey,
uri: String,
channelInfo: ChannelDataNorm,
tags: ImmutableListOfLists<String>,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column {
Row {
TranslatableRichTextViewer(
content = stringRes(R.string.changed_chat_profile_to),
canPreview = true,
quotesLeft = 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = id,
callbackUri = uri,
accountViewModel = accountViewModel,
nav = nav,
)
}
channelInfo.picture?.let {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
RobohashFallbackAsyncImage(
robot = id,
model = it,
contentDescription = stringRes(R.string.channel_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
)
}
}
channelInfo.name?.let {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
CreateTextWithEmoji(
text = it,
tags = tags,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
}
}
channelInfo.about?.let {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
TranslatableRichTextViewer(
content = it,
canPreview = true,
quotesLeft = 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = id,
callbackUri = uri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
channelInfo.relays?.let {
Text(
stringRes(R.string.public_chat_relays_title) + ": ",
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
)
it.forEach {
Spacer(StdVertSpacer)
RenderRelayLinePublicChat(
it,
accountViewModel,
nav,
)
}
}
}
}
@Preview
@Composable
fun RenderRelayLinePreview() {
ThemeComparisonRow {
RenderRelayLine(
"wss://nos.lol",
"http://icon.com/icon.ico",
Modifier,
showPicture = true,
loadRobohash = true,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RenderRelayLinePublicChat(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
nav: INav,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val relayInfo by loadRelayInfo(relay)
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
val clickableModifier =
remember(relay) {
Modifier.combinedClickable(
onLongClick = {
scope.launch {
clipboardManager.setText(relay.url)
}
},
onClick = { nav.nav(Route.RelayInfo(relay.url)) },
)
}
RenderRelayLine(
relay.displayUrl(),
relayInfo.icon,
clickableModifier,
showPicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@Composable
fun RenderRelayLine(
url: String,
icon: String?,
modifier: Modifier = Modifier,
showPicture: Boolean = true,
loadRobohash: Boolean = true,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = modifier,
) {
Text(" -")
Spacer(modifier = StdHorzSpacer)
RobohashFallbackAsyncImage(
robot = url,
model = icon,
contentDescription = stringRes(id = R.string.relay_info, url),
colorFilter = RelayIconFilter,
modifier =
Modifier
.size(Size20dp)
.clip(shape = CircleShape),
loadProfilePicture = showPicture,
loadRobohash = loadRobohash,
)
Spacer(modifier = StdHorzSpacer)
Text(
text = url,
)
}
}
@@ -39,6 +39,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount
import com.vitorpamplona.amethyst.ui.stringRes
// Jumbo sizes step down as the emoji count grows so up to three still fit a line.
private val JumboEmojiSingle = 50.sp
private val JumboEmojiPair = 40.sp
private val JumboEmojiTriple = 32.sp
@Composable
fun RenderRegularTextNote(
note: Note,
@@ -63,9 +68,9 @@ fun RenderRegularTextNote(
text = eventContent.trim(),
fontSize =
when (jumboCount) {
1 -> 50.sp
2 -> 40.sp
else -> 32.sp
1 -> JumboEmojiSingle
2 -> JumboEmojiPair
else -> JumboEmojiTriple
},
)
} else {
@@ -78,19 +78,9 @@ val ButtonBorder = RoundedCornerShape(20.dp)
val LeftHalfCircleButtonBorder = ButtonBorder.copy(topEnd = CornerSize(0f), bottomEnd = CornerSize(0f))
val EditFieldBorder = RoundedCornerShape(25.dp)
// Chat bubble corners: the small 4dp corner is the "tail" pointing at the author's
// side. A grouped run reads as one continuous unit: full rounding only at the very
// top of the group and the very bottom, every edge that touches a neighboring
// message of the same run stays sharp (Top = visually first / oldest, Bottom =
// visually last).
val ChatBubbleShapeMe = RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp)
val ChatBubbleShapeMeTop = RoundedCornerShape(18.dp, 18.dp, 6.dp, 6.dp)
val ChatBubbleShapeMeMiddle = RoundedCornerShape(6.dp, 6.dp, 6.dp, 6.dp)
val ChatBubbleShapeMeBottom = RoundedCornerShape(6.dp, 6.dp, 4.dp, 18.dp)
val ChatBubbleShapeThem = RoundedCornerShape(4.dp, 18.dp, 18.dp, 18.dp)
val ChatBubbleShapeThemTop = RoundedCornerShape(4.dp, 18.dp, 6.dp, 6.dp)
val ChatBubbleShapeThemMiddle = RoundedCornerShape(6.dp, 6.dp, 6.dp, 6.dp)
val ChatBubbleShapeThemBottom = RoundedCornerShape(6.dp, 6.dp, 18.dp, 18.dp)
// Chat bubble shapes live in commons (ChatTheme.kt) so Android and Desktop share
// one geometry: see ChatBubbleShapeMe/Them and their grouped Top/Middle/Bottom
// variants there.
val StdButtonSizeModifier = Modifier.size(19.dp)
@@ -34,9 +34,19 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
// Chat bubble shapes
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
// Chat bubble corners — the single source of truth for every front end. The
// small 4dp corner is the "tail" pointing at the author's side. A grouped run
// reads as one continuous unit: full rounding only at the very top and bottom
// of the group, edges that touch a neighboring message of the same run stay
// sharp (Top = visually first / oldest, Bottom = visually last).
val ChatBubbleShapeMe = RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp)
val ChatBubbleShapeMeTop = RoundedCornerShape(18.dp, 18.dp, 6.dp, 6.dp)
val ChatBubbleShapeMeMiddle = RoundedCornerShape(6.dp, 6.dp, 6.dp, 6.dp)
val ChatBubbleShapeMeBottom = RoundedCornerShape(6.dp, 6.dp, 4.dp, 18.dp)
val ChatBubbleShapeThem = RoundedCornerShape(4.dp, 18.dp, 18.dp, 18.dp)
val ChatBubbleShapeThemTop = RoundedCornerShape(4.dp, 18.dp, 6.dp, 6.dp)
val ChatBubbleShapeThemMiddle = RoundedCornerShape(6.dp, 6.dp, 6.dp, 6.dp)
val ChatBubbleShapeThemBottom = RoundedCornerShape(6.dp, 6.dp, 18.dp, 18.dp)
// Chat bubble modifiers
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f)