feat(highlights): richer composer UI and full NIP-84 source coverage

Redesign the New Highlight composer as a pull-quote you craft: a rounded tonal
hero card with a quotation-mark watermark, a highlighter-amber accent bar, and
the passage in large type, plus a live highlighter-pen preview (reusing the feed's
HighlightedQuote) and icon-led source/note fields.

Also extend HighlightEvent.create()/build() to emit a/e/p tags, so the builder
now covers every NIP-84 source — a web page (r), a nostr article (a), a nostr
note (e), each with optional author attribution (p) and context — not just web
highlights. Route.NewHighlight and the composer ViewModel carry the nostr source
(address/event/author) and context through so a future in-app "highlight this
passage" action can open the composer for a nostr article/note.

Covered by a new builder test for the a/e/p tags; verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
This commit is contained in:
Claude
2026-07-29 02:27:49 +00:00
parent 9189955155
commit 155ef8e46c
7 changed files with 287 additions and 37 deletions
@@ -947,6 +947,10 @@ fun BuildNavigation(
prefix = it.prefix,
suffix = it.suffix,
comment = it.comment,
context = it.context,
sourceAddress = it.sourceAddress,
sourceEventId = it.sourceEventId,
author = it.author,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -1001,6 +1001,12 @@ sealed class Route {
val prefix: String? = null,
val suffix: String? = null,
val comment: String? = null,
val context: String? = null,
// A nostr source (set when highlighting a nostr article/note rather than a web page):
// an addressable coordinate (`a`), a specific event id (`e`) and the author (`p`).
val sourceAddress: String? = null,
val sourceEventId: String? = null,
val author: String? = null,
) : Route()
@Serializable data object NewHlsVideo : Route()
@@ -52,8 +52,14 @@ class NewHighlightPostViewModel : ViewModel() {
/** The user's own note about the passage — becomes a `comment` tag (a quote highlight). */
var comment by mutableStateOf("")
// Carried through from the share/source but not shown as editable fields — page-scraped
// anchors and nostr-source references, not something the user would meaningfully edit.
private var prefix: String? = null
private var suffix: String? = null
private var context: String? = null
private var sourceAddress: String? = null
private var sourceEventId: String? = null
private var author: String? = null
private var loaded = false
@@ -62,7 +68,7 @@ class NewHighlightPostViewModel : ViewModel() {
}
/**
* Applies the parsed share once. Guarded so a recomposition (or a config change that
* Applies the incoming source once. Guarded so a recomposition (or a config change that
* re-runs the loading effect) can't clobber edits the user already made.
*/
fun load(
@@ -71,6 +77,10 @@ class NewHighlightPostViewModel : ViewModel() {
prefix: String?,
suffix: String?,
comment: String?,
context: String?,
sourceAddress: String?,
sourceEventId: String?,
author: String?,
) {
if (loaded) return
loaded = true
@@ -80,6 +90,10 @@ class NewHighlightPostViewModel : ViewModel() {
this.comment = comment.orEmpty()
this.prefix = prefix
this.suffix = suffix
this.context = context
this.sourceAddress = sourceAddress
this.sourceEventId = sourceEventId
this.author = author
}
fun canPost(): Boolean = quote.isNotBlank()
@@ -95,6 +109,10 @@ class NewHighlightPostViewModel : ViewModel() {
prefix = prefix,
suffix = suffix,
comment = comment.trim().ifBlank { null },
context = context,
address = sourceAddress,
event = sourceEventId,
author = author,
),
)
}
@@ -20,35 +20,70 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
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
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.stringRes
/** A warm highlighter amber — the highlight metaphor reads as yellow regardless of theme. */
private val MarkerAccent = Color(0xFFF5C518)
/**
* The "New Highlight" composer. Reached either from the "Add highlight" action or when a
* browser shares a text selection to Amethyst (routed in as [Route.NewHighlight]). It is a
* deliberately small subset of the short-note composer — no polls, zaps, media, scheduling,
* etc. — because a NIP-84 highlight is just a passage, its source, and an optional note.
* browser (or a nostr article/note) shares a passage to Amethyst, routed in as
* [com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewHighlight].
*
* It is a deliberately small subset of the short-note composer — no polls, zaps, media,
* scheduling — because a NIP-84 highlight is just a passage, its source, and an optional
* note. The passage is presented as a pull-quote you craft: an accent bar, a quotation-mark
* watermark, and a live highlighter-pen preview of exactly how it will appear in the feed.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -58,6 +93,10 @@ fun NewHighlightScreen(
prefix: String? = null,
suffix: String? = null,
comment: String? = null,
context: String? = null,
sourceAddress: String? = null,
sourceEventId: String? = null,
author: String? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
@@ -65,7 +104,7 @@ fun NewHighlightScreen(
postViewModel.init(accountViewModel)
LaunchedEffect(Unit) {
postViewModel.load(quote, url, prefix, suffix, comment)
postViewModel.load(quote, url, prefix, suffix, comment, context, sourceAddress, sourceEventId, author)
}
Scaffold(
@@ -85,45 +124,176 @@ fun NewHighlightScreen(
)
},
) { pad ->
Surface(
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Column(
HighlightEditorCard(
passage = postViewModel.quote,
onPassageChange = { postViewModel.quote = it },
)
if (postViewModel.quote.isNotBlank()) {
HighlightPreview(passage = postViewModel.quote)
}
IconField(
symbol = MaterialSymbols.Link,
value = postViewModel.url,
onValueChange = { postViewModel.url = it },
label = stringRes(R.string.new_highlight_source_label),
placeholder = "https://example.com/article",
singleLine = true,
)
IconField(
symbol = MaterialSymbols.EditNote,
value = postViewModel.comment,
onValueChange = { postViewModel.comment = it },
label = stringRes(R.string.new_highlight_note_label),
placeholder = stringRes(R.string.new_highlight_note_placeholder),
singleLine = false,
minLines = 2,
)
}
}
}
/**
* The hero: a rounded, tonal card carrying a quotation-mark watermark, a highlighter-yellow
* accent bar, and the editable passage set in a large, comfortable type.
*/
@Composable
private fun HighlightEditorCard(
passage: String,
onPassageChange: (String) -> Unit,
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxWidth()) {
// Oversized quotation mark watermark, tucked behind the passage.
Icon(
symbol = MaterialSymbols.FormatQuote,
contentDescription = null,
tint = MarkerAccent.copy(alpha = 0.20f),
modifier =
Modifier
.align(Alignment.TopStart)
.offset(x = 8.dp, y = (-6).dp)
.size(80.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(Size10dp),
verticalArrangement = Arrangement.spacedBy(Size10dp),
.height(IntrinsicSize.Min)
.padding(20.dp),
) {
OutlinedTextField(
value = postViewModel.quote,
onValueChange = { postViewModel.quote = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.new_highlight_passage_label)) },
minLines = 3,
)
OutlinedTextField(
value = postViewModel.url,
onValueChange = { postViewModel.url = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.new_highlight_source_label)) },
singleLine = true,
)
OutlinedTextField(
value = postViewModel.comment,
onValueChange = { postViewModel.comment = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.new_highlight_note_label)) },
minLines = 2,
Spacer(
Modifier
.width(4.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(MarkerAccent),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
BasicTextField(
value = passage,
onValueChange = onPassageChange,
textStyle =
LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
fontSize = 20.sp,
lineHeight = 1.4.em,
fontWeight = FontWeight.Medium,
),
cursorBrush = SolidColor(MarkerAccent),
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
decorationBox = { inner ->
if (passage.isEmpty()) {
Text(
text = stringRes(R.string.new_highlight_passage_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 20.sp,
lineHeight = 1.4.em,
)
}
inner()
},
)
Spacer(Modifier.height(10.dp))
Text(
text = "${passage.length}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
/** 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(
symbol: MaterialSymbol,
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String,
singleLine: Boolean,
minLines: Int = 1,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(label) },
placeholder = { Text(placeholder) },
leadingIcon = {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp))
},
singleLine = singleLine,
minLines = minLines,
shape = RoundedCornerShape(16.dp),
)
}
+3
View File
@@ -2767,8 +2767,11 @@
<string name="share_target_as_highlight">New Highlight</string>
<string name="new_highlight_title">New Highlight</string>
<string name="new_highlight_passage_label">Highlighted text</string>
<string name="new_highlight_passage_placeholder">What stood out to you?</string>
<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="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>
@@ -198,12 +198,18 @@ class HighlightEvent(
* highlight composer) produces. The highlighted passage becomes the event `content`;
* the remaining inputs are emitted as their NIP-84 tags when present:
*
* - [address] → an `a` reference to a nostr addressable source (e.g. a NIP-23 article),
* - [event] → an `e` reference to a specific nostr event version highlighted,
* - [author] → a `p` attribution to the highlighted content's author,
* - [url] → an `r` source reference (normalized by [ReferenceTag]; clean it of
* trackers with [com.vitorpamplona.quartz.nip84Highlights.parse.UrlTrackerCleaner] first),
* - [prefix]/[suffix] → a `textquoteselector` anchor (the `exact` field stays a
* placeholder since the passage already lives in `content`),
* - [context] → the surrounding paragraph as a `context` tag,
* - [comment] → the user's own note as a `comment` tag (turns it into a quote highlight).
*
* This covers every NIP-84 source: a web page ([url]), a nostr article ([address]),
* a nostr note ([event]), each with optional author attribution and the user's note.
*/
suspend fun create(
quote: String,
@@ -212,9 +218,12 @@ class HighlightEvent(
suffix: String? = null,
comment: String? = null,
context: String? = null,
address: String? = null,
event: String? = null,
author: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): HighlightEvent = signer.sign(createdAt, KIND, assembleTags(url, prefix, suffix, comment, context), quote)
): HighlightEvent = signer.sign(createdAt, KIND, assembleTags(url, prefix, suffix, comment, context, address, event, author), quote)
/**
* The unsigned [EventTemplate] counterpart of [create], for the app's
@@ -228,10 +237,13 @@ class HighlightEvent(
suffix: String? = null,
comment: String? = null,
context: String? = null,
address: String? = null,
event: String? = null,
author: String? = null,
createdAt: Long = TimeUtils.now(),
): EventTemplate<HighlightEvent> =
eventTemplate(KIND, quote, createdAt) {
addAll(assembleTags(url, prefix, suffix, comment, context))
addAll(assembleTags(url, prefix, suffix, comment, context, address, event, author))
}
private fun assembleTags(
@@ -240,9 +252,23 @@ class HighlightEvent(
suffix: String?,
comment: String?,
context: String?,
address: String? = null,
event: String? = null,
author: String? = null,
): Array<Array<String>> {
val tags = mutableListOf<Array<String>>()
if (!address.isNullOrBlank()) {
tags.add(ATag.assemble(address, null))
}
if (!event.isNullOrBlank()) {
tags.add(ETag.assemble(event, null, null))
}
if (!author.isNullOrBlank()) {
// Mark the role so [author] attributes to this p tag even when the highlight also
// carries `mention` p tags — the producer-side counterpart of that reader logic.
tags.add(arrayOf(PTag.TAG_NAME, author, "", AUTHOR_MARKER))
}
if (!url.isNullOrBlank()) {
tags.add(ReferenceTag.assemble(url))
}
@@ -117,6 +117,29 @@ class HighlightEventBuilderTest {
assertTrue(event.tags.isEmpty())
}
@Test
fun buildsNostrSourceTags() =
runTest {
val article = "30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:my-article"
val author = "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"
val version = "8d7ae10a57ef178a17563a6ecbf9a399bb1796a2e032ca72703b00913b4cfd42"
val event =
HighlightEvent.create(
quote = "a passage from an article",
address = article,
event = version,
author = author,
signer = signer,
)
assertEquals(article, event.inPostAddress()?.toValue())
assertEquals(version, event.inPostVersion()?.eventId)
assertEquals(author, event.author())
// The p tag carries the NIP-84 "author" role so attribution survives mention p tags.
assertTrue(event.tags.any { it[0] == "p" && it[1] == author && it.getOrNull(3) == "author" })
}
@Test
fun buildProducesUnsignedTemplateWithSameTags() {
val template =