From 55cc4ce9dcbbc33895b550568fae90fbd95338d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 02:50:53 +0000 Subject: [PATCH] feat(highlights): highlight-a-note menu action; drop composer preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a "Highlight" action to the note action menu (3-dot menu + chat long-press sheet). It opens the NIP-84 composer pre-tagged with the note as source — an `a` reference for an addressable article, else an `e` reference, plus the author `p` — with the passage left for the user to type/paste. Prose kinds only (text notes, long-form), and never a private rumor (a public highlight would leak an e-tag of the unsigned rumor). This is the in-app entry point for "post with the source as an event". - Remove the live preview from the New Highlight screen per review. - Add two real-world regression tests for the shared-highlight parser: Chrome "copy link to highlight" with a prefix/suffix fragment (incl. an encoded hyphen inside the prefix) and a long start-only fragment with encoded commas. Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin. --- .../ui/note/elements/NoteActionSections.kt | 19 ++++++++++ .../loggedIn/highlights/NewHighlightScreen.kt | 29 --------------- amethyst/src/main/res/values/strings.xml | 2 +- .../parse/SharedHighlightParserTest.kt | 36 +++++++++++++++++++ 4 files changed, 56 insertions(+), 30 deletions(-) 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 c1bd667a34..e05844f5c5 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 @@ -171,6 +171,25 @@ fun noteActionSections( } }, ) + // Highlight this note/article as its source: opens the NIP-84 composer with the + // nostr source pre-tagged (`a` for an addressable article, else `e`) plus the author, + // and the passage left for the user to type or paste. Prose kinds only, and never a + // private rumor (a public highlight would e-tag the unsigned rumor onto relays). + if (!isPrivateRumor && (note.event is TextNoteEvent || note.event is LongTextNoteEvent)) { + add( + NoteAction(MaterialSymbols.FormatQuote, stringRes(R.string.highlight_action)) { + val author = note.author?.pubkeyHex + val route = + if (note is AddressableNote) { + Route.NewHighlight(sourceAddress = note.address.toValue(), author = author) + } else { + Route.NewHighlight(sourceEventId = note.idHex, author = author) + } + nav.nav(route) + handlers.onDismiss() + }, + ) + } if (!isPrivateRumor) { add(NoteAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = handlers.onShare)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightScreen.kt index 981258687a..f87a0102d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightScreen.kt @@ -66,7 +66,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.note.HighlightedQuote import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -140,10 +139,6 @@ fun NewHighlightScreen( onPassageChange = { postViewModel.quote = it }, ) - if (postViewModel.quote.isNotBlank()) { - HighlightPreview(passage = postViewModel.quote) - } - IconField( symbol = MaterialSymbols.Link, value = postViewModel.url, @@ -248,30 +243,6 @@ private fun HighlightEditorCard( } } -/** A live render of the passage exactly as it will appear in the feed — the highlighter pen. */ -@Composable -private fun HighlightPreview(passage: String) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringRes(R.string.new_highlight_preview_label), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 1.dp, - modifier = Modifier.fillMaxWidth(), - ) { - HighlightedQuote( - text = passage, - highlight = passage.indices, - modifier = Modifier.padding(16.dp), - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun IconField( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1f2231c66c..8cef251ec7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2771,7 +2771,7 @@ Source URL Your note (optional) Add your thoughts… - Preview + Highlight Copy URL to clipboard Copy Note ID to clipboard Add Media to Gallery diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParserTest.kt index 3f4dd8a73b..a38a47e055 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/parse/SharedHighlightParserTest.kt @@ -171,4 +171,40 @@ class SharedHighlightParserTest { assertNull(result.prefix) assertNull(result.suffix) } + + @Test + fun realWorldChromeCopyLinkWithPrefixSuffixAndEncodedHyphen() { + // Chrome "copy link to highlight": a short selection with prefix/suffix anchors, and an + // encoded hyphen (%2D "pró-Irã") inside the prefix that must not be read as a delimiter. + val result = + SharedHighlightParser.parse( + "\"baseados\"\n https://g1.globo.com/mundo/noticia/2026/07/28/ira-rompe-tregua-e-lanca-misseis-balisticos-contra-bases-dos-eua-no-oriente-medio.ghtml" + + "#:~:text=Saudita%20lan%C3%A7aram%20ataques%20em%20conjunto%20contra%20militantes%20pr%C3%B3%2DIr%C3%A3-,baseados,-no%20Iraque.", + ) + assertEquals("baseados", result.quote) + assertEquals( + "https://g1.globo.com/mundo/noticia/2026/07/28/ira-rompe-tregua-e-lanca-misseis-balisticos-contra-bases-dos-eua-no-oriente-medio.ghtml", + result.url, + ) + assertEquals("Saudita lançaram ataques em conjunto contra militantes pró-Irã", result.prefix) + assertEquals("no Iraque", result.suffix) + } + + @Test + fun realWorldChromeCopyLinkStartOnlyWithEncodedCommas() { + // A long start-only fragment whose commas are encoded (%2C) so they don't split the + // directive, and whose apostrophe (’) inside the passage must survive quote-trimming. + val passage = + "cherish those corners. Geohot’s blog is one such corner that I rediscovered recently. " + + "Oh, what a joy to read something human from an actual human. His stuff makes me cry, laugh, and everything in" + val result = + SharedHighlightParser.parse( + "\"$passage\"\n https://dergigi.com/2026/07/28/typing/" + + "#:~:text=cherish%20those%20corners.%20Geohot%E2%80%99s%20blog%20is%20one%20such%20corner%20that%20I%20rediscovered%20recently.%20Oh%2C%20what%20a%20joy%20to%20read%20something%20human%20from%20an%20actual%20human.%20His%20stuff%20makes%20me%20cry%2C%20laugh%2C%20and%20everything%20in", + ) + assertEquals(passage, result.quote) + assertEquals("https://dergigi.com/2026/07/28/typing/", result.url) + assertNull(result.prefix) + assertNull(result.suffix) + } }