diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index e3fdaaa743..095037c07a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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, + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 3956faf0d2..9b13e30a7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -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, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt new file mode 100644 index 0000000000..c72a4d40d8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -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> { + 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() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt index f6e9783b6b..45e25ac2aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatEngagementDetailSheet.kt @@ -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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 1cdca1b9bf..20b5292bab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -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 -> - 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index 8a336fbed3..299e46aad5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt index bcd6051b45..ce97a5daef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt index 79fbfd5f12..4fb1f50ffa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt deleted file mode 100644 index 40a53bea7b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChangeChannelMetadataNote.kt +++ /dev/null @@ -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, - 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, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt deleted file mode 100644 index 3c801a11b0..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ /dev/null @@ -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, - 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, - bgColor: MutableState, - 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, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index 6b3b681378..25fb82f0a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 86e7dc894d..37102c2934 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt index ae778e98a8..9c793a1b7c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt @@ -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)