mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
@@ -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()
|
||||
|
||||
+19
-1
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+204
-34
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+28
-2
@@ -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))
|
||||
}
|
||||
|
||||
+23
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user