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 =