Merge pull request #3897 from vitorpamplona/claude/copy-text-translation-options-yl7m5x

Add "Copy Original/Translated" chooser for translated notes
This commit is contained in:
Vitor Pamplona
2026-08-11 17:47:23 -04:00
committed by GitHub
9 changed files with 315 additions and 27 deletions
@@ -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
@@ -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<CopyTextChoice?>(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,
)
}
}
}
@@ -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(
@@ -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(
@@ -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
@@ -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.
@@ -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 {
+2
View File
@@ -46,6 +46,8 @@
<string name="violence">Violence</string>
<string name="unknown_author">Unknown Author</string>
<string name="copy_text">Copy Text</string>
<string name="copy_text_original">Copy Original</string>
<string name="copy_text_translated">Copy Translated</string>
<string name="copy_user_pubkey">Copy Author ID</string>
<string name="copy_note_id">Copy Note ID</string>
<string name="copy_raw_json">Copy raw JSON</string>
@@ -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
}