mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+32
@@ -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(
|
||||
|
||||
+12
-4
@@ -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
|
||||
|
||||
+23
@@ -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.
|
||||
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user