From e56e2269df3fbb965a1040c633f8d28a73cc2db5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 18:39:39 +0000 Subject: [PATCH 1/2] feat: offer Copy Original / Copy Translated when copying translated notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Copy Text menu (note quick-action popup, the shared note-action sections behind the 3-dot menu and chat long-press sheet, and bookmark group item options) now checks whether the rendered note was translated and, if so, pops a chooser offering Copy Original / Copy Translated instead of silently copying the original. Rather than plumbing the translated string from TranslatableRichTextViewer down to the menus, the shared copyNoteTextAction flow re-derives it from the process-wide TranslationsCache keyed by (content, language settings) — rendering the note is what populated that cache, so a hit means the user is looking at a translation. The cache is play-flavor-only, so the lookup goes through a new cachedTranslation() flavor pair (fdroid always null, keeping its Copy Text single-option). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J7wEshKZC5HSgQykQiMQnt --- .../ui/components/CachedTranslation.kt | 32 +++++ .../amethyst/ui/note/CopyNoteText.kt | 133 ++++++++++++++++++ .../amethyst/ui/note/NoteQuickActionMenu.kt | 22 +-- .../ui/note/elements/NoteActionSections.kt | 16 ++- .../display/BookmarkGroupItemOptions.kt | 18 ++- amethyst/src/main/res/values/strings.xml | 2 + .../ui/components/CachedTranslation.kt | 45 ++++++ 7 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt new file mode 100644 index 0000000000..1388e9a5bc --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt @@ -0,0 +1,32 @@ +/* + * 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.components + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * No translation service in this flavor, so no note is ever translated and the copy-text + * menus never need to offer a "Copy Translated" option. + */ +fun cachedTranslation( + content: String, + accountViewModel: AccountViewModel, +): String? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt new file mode 100644 index 0000000000..74e23ee3f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt @@ -0,0 +1,133 @@ +/* + * 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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalClipboard +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Note +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.cachedTranslation +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch + +/** Both texts of a translated note, held while the user picks which one to copy. */ +@Immutable +data class CopyTextChoice( + val original: String, + val translated: String, +) + +/** + * The "Copy Text" flow shared by every menu that copies an event's text. + * + * The copy menus sit far from the `TranslatableRichTextViewer` that rendered (and possibly + * translated) the note, so instead of plumbing the translated string down the hierarchy this + * flow re-derives it from [cachedTranslation]: the process-wide translation cache keyed by + * (content, language settings). By the time any copy menu is reachable the note has been + * rendered, which is what populated that cache — so a hit means the user is looking at a + * translation and gets a chooser (Copy Original / Copy Translated); a miss copies directly. + * + * Returns the click handler for the menu entry, taking the note whose text to copy (callers + * pass the latest version of a versioned post). [onCopied] runs after the text lands on the + * clipboard, [onDismiss] when the chooser is cancelled without copying; callers must keep + * their menu in composition until one of the two runs, because the chooser dialog is emitted + * from this composable. + */ +@Composable +fun copyNoteTextAction( + accountViewModel: AccountViewModel, + onCopied: () -> Unit, + onDismiss: () -> Unit, +): (Note) -> Unit { + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + val choice = remember { mutableStateOf(null) } + + val copy: (String) -> Unit = { text -> + scope.launch { + clipboardManager.setText(text) + onCopied() + } + } + + choice.value?.let { options -> + CopyTextChooserDialog( + onCopyOriginal = { + choice.value = null + copy(options.original) + }, + onCopyTranslated = { + choice.value = null + copy(options.translated) + }, + onDismiss = { + choice.value = null + onDismiss() + }, + ) + } + + return { note -> + accountViewModel.decrypt(note) { original -> + val translated = cachedTranslation(original, accountViewModel) + if (translated == null) { + copy(original) + } else { + choice.value = CopyTextChoice(original, translated) + } + } + } +} + +@Composable +fun CopyTextChooserDialog( + onCopyOriginal: () -> Unit, + onCopyTranslated: () -> Unit, + onDismiss: () -> Unit, +) { + M3ActionDialog( + title = stringRes(R.string.copy_text), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow( + icon = MaterialSymbols.ContentCopy, + text = stringRes(R.string.copy_text_original), + onClick = onCopyOriginal, + ) + M3ActionRow( + icon = MaterialSymbols.Translate, + text = stringRes(R.string.copy_text_translated), + onClick = onCopyTranslated, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index a1161ab2b5..fc98c537af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -299,20 +299,26 @@ fun CardBody( ) } + // When the rendered note was translated, tapping Copy Text opens a chooser + // (Copy Original / Copy Translated) on top of this popup; the popup stays up + // until the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = { + showToast(R.string.copied_note_text_to_clipboard) + onDismiss() + }, + onDismiss = onDismiss, + ) + Column(modifier = Modifier.width(IntrinsicSize.Min)) { Row(modifier = Modifier.height(IntrinsicSize.Min)) { NoteQuickActionItem( icon = MaterialSymbols.ContentCopy, label = stringRes(R.string.quick_action_copy_text), ) { - accountViewModel.decrypt(note) { - scope.launch { - clipboardManager.setText(it) - showToast(R.string.copied_note_text_to_clipboard) - } - } - - onDismiss() + copyNoteText(note) } VerticalDivider(color = primaryLight) NoteQuickActionItem( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index df3f8c5572..9cb33f5778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialogOneButton +import com.vitorpamplona.amethyst.ui.note.copyNoteTextAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.LightRedColor @@ -129,14 +130,21 @@ fun noteActionSections( ) } + // When the rendered note was translated, Copy Text opens a chooser (Copy + // Original / Copy Translated) on top of the menu; the menu dismisses only + // after the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = handlers.onDismiss, + onDismiss = handlers.onDismiss, + ) + val copyAndShare = buildList { add( NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) { - accountViewModel.decrypt(noteVersionToCopy) { - scope.launch { clipboardManager.setText(it) } - } - handlers.onDismiss() + copyNoteText(noteVersionToCopy) }, ) add( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 35f9774204..229f21c690 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -45,6 +45,7 @@ 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.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.note.copyNoteTextAction import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount import com.vitorpamplona.amethyst.ui.note.externalLinkForNote @@ -186,16 +187,21 @@ fun BookmarkGroupItemOptionsMenu( } } + // When the rendered note was translated, Copy Text opens a chooser (Copy + // Original / Copy Translated) on top of the menu; the menu dismisses only + // after the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = onDismiss, + onDismiss = onDismiss, + ) + // Copy & Share section 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() + copyNoteText(lastNoteVersion) } M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1843dbfdd4..bdc64269b3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -46,6 +46,8 @@ Violence Unknown Author Copy Text + Copy Original + Copy Translated Copy Author ID Copy Note ID Copy raw JSON diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt new file mode 100644 index 0000000000..cc7229491f --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt @@ -0,0 +1,45 @@ +/* + * 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.components + +import com.vitorpamplona.amethyst.service.lang.TranslationsCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * The already-computed translation of [content] under the current language settings, or null + * when no translation occurred (same language, undetected source, blocklisted) or none is + * cached. Cache-only on purpose: this backs the "Copy Translated" option of the copy-text + * menus, which only applies to text the user is looking at — and rendering it through + * [TranslatableRichTextViewer] is what populated the cache. + */ +fun cachedTranslation( + content: String, + accountViewModel: AccountViewModel, +): String? { + val languages = accountViewModel.account.settings.syncedSettings.languages + val config = + TranslationsCache.get(content, languages.translateTo.value, languages.dontTranslateFrom.value) + ?: return null + val source = config.sourceLang ?: return null + val target = config.targetLang ?: return null + if (source == target || config.result == content) return null + return config.result +} From 6251d6e18ee29b1f94bd91a2171fb0cf6fde3f65 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Aug 2026 17:38:08 -0400 Subject: [PATCH 2/2] fix(copy-text): close the menu when decryption fails, key the chooser on what was rendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from testing the Copy Original / Copy Translated chooser on device. The menus stopped dismissing for a note that cannot be decrypted. AccountViewModel.decrypt only invokes its callback on success, and the copy flow now dismisses from inside that callback, so a read-only account (or a foreign/corrupt DM, or a signer that refuses) left the popup/sheet sitting open with nothing copied — previously onDismiss ran unconditionally. Adds decryptOrNull, which always answers (null on failure, and still rethrows so launchSigner keeps toasting/logging the signer error), and dismisses on null. The chooser was also missed whenever the string the viewer translated wasn't the note's raw content. RenderTextEvent prepends a NIP-14 subject the body doesn't already repeat, and renders the newest edit of a versioned post; the copy flow looked the translation up by the raw content of the note it was handed, so a subject-carrying note never hit the cache and silently copied the untranslated text. That derivation is now one function, displayedNoteText, used by both sides, and it is also what gets copied — a subject is part of what the user is reading. The handler takes (note, versionShown) so the body comes from the version on screen and the subject from the note itself, as the viewer composes them; the quick-action menu, which passed the pre-edit original, now resolves the newest modification the way the card does. Verified on a tablet: an undecryptable NIP-04 message under a read-only login now closes the sheet, and a translated note carrying a subject tag offers the chooser and copies the subject line with the original. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/ui/note/CopyNoteText.kt | 38 +++++++++++++------ .../amethyst/ui/note/NoteQuickActionMenu.kt | 8 +++- .../ui/note/elements/NoteActionSections.kt | 2 +- .../amethyst/ui/note/types/Text.kt | 31 ++++++++++----- .../ui/screen/loggedIn/AccountViewModel.kt | 23 +++++++++++ .../display/BookmarkGroupItemOptions.kt | 2 +- 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt index 74e23ee3f6..09e1ab078c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.cachedTranslation import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.note.types.displayedNoteText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.launch @@ -55,18 +56,26 @@ data class CopyTextChoice( * rendered, which is what populated that cache — so a hit means the user is looking at a * translation and gets a chooser (Copy Original / Copy Translated); a miss copies directly. * - * Returns the click handler for the menu entry, taking the note whose text to copy (callers - * pass the latest version of a versioned post). [onCopied] runs after the text lands on the - * clipboard, [onDismiss] when the chooser is cancelled without copying; callers must keep - * their menu in composition until one of the two runs, because the chooser dialog is emitted - * from this composable. + * What gets copied — and what the cache is keyed on — is [displayedNoteText], the same string + * the viewer rendered, not the raw event content: a NIP-14 subject is part of what the user is + * reading and of what was translated. + * + * Returns the click handler for the menu entry, taking the note the menu belongs to and the + * version of it the screen is showing (the same note unless the post was edited — the body + * comes from the version, the subject from the note itself, exactly as the viewer composes + * them). [onCopied] runs after the text lands on the + * clipboard, [onDismiss] when the chooser is cancelled without copying **or** when the note + * can't be decrypted at all (a read-only account, a refused signer) so the menu still closes + * instead of hanging on a copy that will never happen. Callers must keep their menu in + * composition until one of the two runs, because the chooser dialog is emitted from this + * composable. */ @Composable fun copyNoteTextAction( accountViewModel: AccountViewModel, onCopied: () -> Unit, onDismiss: () -> Unit, -): (Note) -> Unit { +): (note: Note, versionShown: Note) -> Unit { val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() val choice = remember { mutableStateOf(null) } @@ -95,13 +104,18 @@ fun copyNoteTextAction( ) } - return { note -> - accountViewModel.decrypt(note) { original -> - val translated = cachedTranslation(original, accountViewModel) - if (translated == null) { - copy(original) + return { note, versionShown -> + accountViewModel.decryptOrNull(versionShown) { decrypted -> + if (decrypted == null) { + onDismiss() } else { - choice.value = CopyTextChoice(original, translated) + val original = displayedNoteText(note, decrypted) + val translated = cachedTranslation(original, accountViewModel) + if (translated == null) { + copy(original) + } else { + choice.value = CopyTextChoice(original, translated) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index fc98c537af..0363421c51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.textNoteModifications import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo @@ -299,6 +300,11 @@ fun CardBody( ) } + // "Copy Text" copies the version on screen: an edited post renders its newest modification + // by default (EditState.updateModifications), and the 3-dot menu already copies that one. + // Reading `edits` is a hard-referenced in-memory fold, so no cache scan here. + val noteVersionToCopy = remember(note) { note.textNoteModifications().lastOrNull() ?: note } + // When the rendered note was translated, tapping Copy Text opens a chooser // (Copy Original / Copy Translated) on top of this popup; the popup stays up // until the flow resolves so the chooser survives in composition. @@ -318,7 +324,7 @@ fun CardBody( icon = MaterialSymbols.ContentCopy, label = stringRes(R.string.quick_action_copy_text), ) { - copyNoteText(note) + copyNoteText(note, noteVersionToCopy) } VerticalDivider(color = primaryLight) NoteQuickActionItem( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index 9cb33f5778..ba13c44b22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -144,7 +144,7 @@ fun noteActionSections( buildList { add( NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) { - copyNoteText(noteVersionToCopy) + copyNoteText(note, noteVersionToCopy) }, ) add( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index b9954841e1..02347f4c91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -69,6 +69,27 @@ enum class ReplyRenderType { NONE, } +/** + * The text [RenderTextEvent] puts on screen for [note] given its decrypted [body]: a NIP-14 + * subject the body doesn't already repeat is prepended to it. + * + * This is the exact string handed to `TranslatableRichTextViewer`, so it is also the key the + * translation cache stores the result under. The copy-text menus look their translation up by + * the same function — keying on the raw body instead would miss the entry for every + * subject-carrying note and silently copy the untranslated text. + */ +fun displayedNoteText( + note: Note, + body: String, +): String { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifBlank { null } + return if (subject != null && !body.contains(subject, ignoreCase = true)) { + "$subject\n\n$body" + } else { + body + } +} + @Composable fun RenderTextEvent( note: Note, @@ -177,15 +198,7 @@ fun RenderTextEvent( body } - val eventContent = - remember(newBody) { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifBlank { null } - if (!subject.isNullOrBlank() && !newBody.contains(subject, ignoreCase = true)) { - "$subject\n\n$newBody" - } else { - newBody - } - } + val eventContent = remember(newBody) { displayedNoteText(note, newBody) } // A boosted note inside a zap/nutzap/onchain activity card is always shown as a // compact 2-line preview, even when the logged-in user is only a zap-split diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7963b56c27..951523e1a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1585,6 +1585,29 @@ class AccountViewModel( account.decryptContent(note)?.let { onReady(it) } } + /** + * [decrypt] that always answers: [onReady] gets null when the content can't be read — a + * read-only account holding no key, a DM this account isn't part of, or a signer that + * refused/timed out. [decrypt] stays silent in those cases, which strands callers that must + * finish either way (a menu that only closes once the copy resolves, say). + */ + fun decryptOrNull( + note: Note, + onReady: (String?) -> Unit, + ) = launchSigner { + val decrypted = + try { + account.decryptContent(note) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // launchSigner still gets the exception to toast/log the signer failure. + onReady(null) + throw e + } + onReady(decrypted) + } + /** * Runs an action that has both a tracked and a direct broadcast variant, * picking the path the user selected via the "Tracked broadcasts" setting. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 229f21c690..77b637efdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -201,7 +201,7 @@ fun BookmarkGroupItemOptionsMenu( M3ActionSection { M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - copyNoteText(lastNoteVersion) + copyNoteText(note, lastNoteVersion) } M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let {