diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b8199bec2e..14faf2d41c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index dbf8504f53..57b5af66e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt index bde261fe3b..b7aae7a469 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/NewHighlightPostViewModel.kt @@ -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, ), ) } 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 c2437ab8d7..981258687a 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 @@ -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), + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e29f32bf0a..1f2231c66c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2767,8 +2767,11 @@ New Highlight New Highlight Highlighted text + What stood out to you? Source URL Your note (optional) + Add your thoughts… + Preview Copy URL to clipboard Copy Note ID to clipboard Add Media to Gallery diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index e17333bffa..7c4b0b5c45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -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 = 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> { val tags = mutableListOf>() + 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)) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventBuilderTest.kt index d7a8cd84f2..a6d0d3c811 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventBuilderTest.kt @@ -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 =