feat(highlights): rich comment field + source-event preview in composer

Address composer feedback:
- The annotation field is now the short-note composer's rich MessageField
  (@-mention + custom-emoji autocomplete, inline previews). On publish it becomes
  the highlight's `comment` tag, with the mentions (`p`), emoji, URLs (`r`),
  hashtags and quotes it references emitted as their own tags — via
  NewMessageTagger + a new tag-builder initializer on HighlightEvent.build().
- A nostr source now renders as a reply-style preview card (NoteCompose, quoted
  style) instead of showing a URL field; the URL field appears only for web
  sources. The source note is resolved from the a/e coordinate carried in the
  route.

Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
This commit is contained in:
Claude
2026-07-29 03:38:29 +00:00
parent 55cc4ce9dc
commit 7927e1de1b
3 changed files with 206 additions and 51 deletions
@@ -20,40 +20,71 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
/**
* Backs the "New Highlight" composer — a trimmed-down cousin of the short-note composer that
* only carries the fields a NIP-84 kind:9802 highlight needs. It is pre-filled from a browser
* share (parsed by [com.vitorpamplona.quartz.nip84Highlights.parse.SharedHighlightParser] in
* the navigation layer) and lets the user confirm/edit before signing.
* Backs the "New Highlight" composer. A NIP-84 highlight is a quoted passage (the event
* `content`), its source, and an optional annotation. The annotation reuses the short-note
* composer's rich [message] field via [IMessageField], so it gets @-mention and custom-emoji
* autocomplete and inline previews; on publish it becomes the highlight's `comment` tag with
* the mentions, emoji, URLs, hashtags and quotes it references emitted as their own tags.
*
* The `textquoteselector` prefix/suffix anchors are carried through from the share but not
* shown as editable fields — they are page-scraped positioning data, not something the user
* would meaningfully edit.
* The passage, the `textquoteselector` prefix/suffix, the surrounding `context`, and the
* nostr source (`a`/`e`/`p`) are carried through from the share or the "Highlight this note"
* action. When a nostr event is the source, [originalNote] is resolved so the screen can
* render it as a reply-style preview instead of showing a URL field.
*/
@Stable
class NewHighlightPostViewModel : ViewModel() {
class NewHighlightPostViewModel :
ViewModel(),
IMessageField {
private var accountViewModel: AccountViewModel? = null
private var account: Account? = null
/** The highlighted passage — becomes the event `content`. */
var quote by mutableStateOf("")
/** The source URL — becomes an `r` tag. */
/** The source URL — becomes an `r` tag. Hidden when a nostr event is the source. */
var url by mutableStateOf("")
/** The user's own note about the passage — becomes a `comment` tag (a quote highlight). */
var comment by mutableStateOf("")
/** The user's annotation — the rich comment field; becomes a `comment` tag. */
override val message = TextFieldState()
/** The source note, when highlighting a nostr article/note, for the reply-style preview. */
var originalNote by mutableStateOf<Note?>(null)
private set
var userSuggestions: UserSuggestionState? = null
var emojiSuggestions: EmojiSuggestionState? = null
// 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
@@ -64,12 +95,16 @@ class NewHighlightPostViewModel : ViewModel() {
private var loaded = false
fun init(accountViewModel: AccountViewModel) {
account = accountViewModel.account
if (this.accountViewModel == accountViewModel) return
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
emojiSuggestions = EmojiSuggestionState(accountViewModel.account.emoji)
}
/**
* 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.
* Applies the incoming source once, and resolves [originalNote] for a nostr source. Guarded
* so a recomposition can't clobber edits the user already made.
*/
fun load(
quote: String?,
@@ -87,33 +122,91 @@ class NewHighlightPostViewModel : ViewModel() {
this.quote = quote.orEmpty()
this.url = url.orEmpty()
this.comment = comment.orEmpty()
comment?.ifBlank { null }?.let { message.setTextAndPlaceCursorAtEnd(it) }
this.prefix = prefix
this.suffix = suffix
this.context = context
this.sourceAddress = sourceAddress
this.sourceEventId = sourceEventId
this.author = author
val accountViewModel = accountViewModel
if (accountViewModel != null) {
originalNote =
when {
!sourceAddress.isNullOrBlank() -> Address.parse(sourceAddress)?.let { accountViewModel.getOrCreateAddressableNote(it) }
!sourceEventId.isNullOrBlank() -> accountViewModel.getOrCreateNote(sourceEventId)
else -> null
}
}
}
override fun onMessageChanged() {
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestions?.processCurrentWord(lastWord)
} else {
userSuggestions?.reset()
}
emojiSuggestions?.processCurrentWord(lastWord)
}
}
fun autocompleteWithUser(item: User) {
userSuggestions?.let {
val lastWord = message.currentWord()
it.replaceCurrentWord(message, lastWord, item)
it.reset()
}
}
fun autocompleteWithEmoji(item: EmojiMedia) {
emojiSuggestions?.autocompleteInto(message, item)
}
fun autocompleteWithEmojiUrl(item: EmojiMedia) {
message.replaceCurrentWord(item.link + " ")
emojiSuggestions?.reset()
}
fun canPost(): Boolean = quote.isNotBlank()
suspend fun sendHighlight() {
val account = account ?: return
val dao = accountViewModel ?: return
if (!canPost()) return
// Resolve @mentions, nostr: refs, emoji, URLs and hashtags out of the annotation the same
// way the short-note composer does, so a highlight comment behaves like any other note.
val tagger = NewMessageTagger(message.text.toString().trim(), null, null, dao)
tagger.run()
val commentText = tagger.message.ifBlank { null }
val mentions = tagger.directMentionsUsers.map { it.toPTag() }
val emojiTags = account.emoji.findEmojiTags(tagger.message)
val urls = findURLs(tagger.message)
val tags = findHashtags(tagger.message)
val quotes = findNostrUris(tagger.message)
account.signAndComputeBroadcast(
HighlightEvent.build(
quote = quote.trim(),
url = url.trim().ifBlank { null },
prefix = prefix,
suffix = suffix,
comment = comment.trim().ifBlank { null },
comment = commentText,
context = context,
address = sourceAddress,
event = sourceEventId,
author = author,
),
) {
if (mentions.isNotEmpty()) pTags(mentions)
references(urls)
hashtags(tags)
quotes(quotes)
emojis(emojiTags)
},
)
}
}
@@ -66,23 +66,32 @@ 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.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.replyModifier
/** 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 (or a nostr article/note) shares a passage to Amethyst, routed in as
* The "New Highlight" composer. Reached either from the "Add highlight" action, a browser
* share, or the "Highlight" note-action, 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.
* A NIP-84 highlight is a quoted passage, its source, and an optional annotation:
* - the passage is a pull-quote you craft (accent bar + quotation-mark watermark),
* - the source is either a nostr event — rendered as a reply-style preview — or a web URL,
* - the annotation is the same rich composer field the short-note screen uses, so it gets
* @-mention and custom-emoji autocomplete and inline previews.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -102,6 +111,8 @@ fun NewHighlightScreen(
val postViewModel: NewHighlightPostViewModel = viewModel()
postViewModel.init(accountViewModel)
WatchAndLoadMyEmojiList(accountViewModel)
LaunchedEffect(Unit) {
postViewModel.load(quote, url, prefix, suffix, comment, context, sourceAddress, sourceEventId, author)
}
@@ -129,34 +140,83 @@ fun NewHighlightScreen(
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
.fillMaxSize(),
) {
HighlightEditorCard(
passage = postViewModel.quote,
onPassageChange = { postViewModel.quote = it },
)
Column(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
HighlightEditorCard(
passage = postViewModel.quote,
onPassageChange = { postViewModel.quote = it },
)
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,
)
val source = postViewModel.originalNote
if (source != null) {
NoteCompose(
baseNote = source,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = ReplyRenderType.NONE,
makeItShort = true,
quotesLeft = 1,
accountViewModel = accountViewModel,
nav = nav,
)
} else {
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,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.EditNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringRes(R.string.new_highlight_note_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
MessageField(
placeholder = R.string.new_highlight_note_placeholder,
viewModel = postViewModel,
requestFocus = false,
)
}
postViewModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
postViewModel::autocompleteWithUser,
accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
postViewModel.emojiSuggestions?.let {
ShowEmojiSuggestionList(
it,
postViewModel::autocompleteWithEmoji,
postViewModel::autocompleteWithEmojiUrl,
modifier = SuggestionListDefaultHeightPage,
)
}
}
}
}
@@ -177,7 +237,6 @@ private fun HighlightEditorCard(
modifier = Modifier.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxWidth()) {
// Oversized quotation mark watermark, tucked behind the passage.
Icon(
symbol = MaterialSymbols.FormatQuote,
contentDescription = null,
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip84Highlights
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
@@ -241,9 +242,11 @@ class HighlightEvent(
event: String? = null,
author: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<HighlightEvent>.() -> Unit = {},
): EventTemplate<HighlightEvent> =
eventTemplate(KIND, quote, createdAt) {
addAll(assembleTags(url, prefix, suffix, comment, context, address, event, author))
initializer()
}
private fun assembleTags(