feat(highlights): highlight-a-note menu action; drop composer preview

- 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.
This commit is contained in:
Claude
2026-07-29 02:50:53 +00:00
parent 155ef8e46c
commit 55cc4ce9dc
4 changed files with 56 additions and 30 deletions
@@ -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))
}
@@ -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(
+1 -1
View File
@@ -2771,7 +2771,7 @@
<string name="new_highlight_source_label">Source URL</string>
<string name="new_highlight_note_label">Your note (optional)</string>
<string name="new_highlight_note_placeholder">Add your thoughts…</string>
<string name="new_highlight_preview_label">Preview</string>
<string name="highlight_action">Highlight</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
<string name="add_media_to_gallery">Add Media to Gallery</string>
@@ -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. Geohots 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)
}
}