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..09e1ab078c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt @@ -0,0 +1,147 @@ +/* + * 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.note.types.displayedNoteText +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. + * + * 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: Note, versionShown: 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, versionShown -> + accountViewModel.decryptOrNull(versionShown) { decrypted -> + if (decrypted == null) { + onDismiss() + } else { + val original = displayedNoteText(note, decrypted) + 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..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,20 +300,31 @@ 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. + 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, 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 df3f8c5572..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 @@ -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(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 490f853b80..3825dd0f5e 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 @@ -1586,6 +1586,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 35f9774204..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 @@ -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(note, 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 9489821e4e..4447f09ac5 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 +}