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 b7aae7a469..93869afb38 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 @@ -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(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) + }, ) } } 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 f87a0102d6..e1fb30ecf2 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 @@ -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, 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 7c4b0b5c45..7a245fe805 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -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.() -> Unit = {}, ): EventTemplate = eventTemplate(KIND, quote, createdAt) { addAll(assembleTags(url, prefix, suffix, comment, context, address, event, author)) + initializer() } private fun assembleTags(