mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+112
-19
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+91
-32
@@ -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,
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user