feat(highlights): add article highlights and note-taking system

Phase 1: HighlightData model + DesktopHighlightStore (Preferences-based)
Phase 2: Text selection highlight via Cmd+H/Cmd+Shift+H, inline bold/italic
         rendering, HighlightAnnotationDialog, SelectionContainer wrapping
Phase 3: MyHighlightsScreen with grouped view, HighlightPublishAction for
         NIP-84 (kind 9802), deck/sidebar/menu wiring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-24 12:16:54 +02:00
parent c75176891f
commit efa294ea72
14 changed files with 1137 additions and 8 deletions
@@ -42,10 +42,27 @@ fun RenderMarkdown(
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
fontScale: Float = 1.0f,
highlightedTexts: List<String> = emptyList(),
) {
val processedContent =
remember(content, highlightedTexts) {
if (highlightedTexts.isEmpty()) {
content
} else {
var result = content
highlightedTexts.sortedByDescending { it.length }.forEach { text ->
val idx = result.indexOf(text)
if (idx >= 0) {
result = result.replaceFirst(text, "***$text***")
}
}
result
}
}
val astNode =
remember(content) {
CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content)
remember(processedContent) {
CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent)
}
val uriHandler =
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.highlights
data class HighlightData(
val id: String,
val text: String,
val note: String? = null,
val articleAddressTag: String,
val articleTitle: String? = null,
val createdAt: Long,
val published: Boolean = false,
val eventId: String? = null,
)
@@ -380,6 +380,7 @@ fun main() {
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) })
Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) })
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
@@ -0,0 +1,193 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.highlights
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.util.UUID
/**
* Local highlight storage for article annotations.
* Stores highlights as JSON in ~/.amethyst/highlights/index.json.
* Uses atomic writes following the same pattern as DesktopDraftStore.
*/
class DesktopHighlightStore(
private val scope: CoroutineScope,
) {
private val mapper = jacksonObjectMapper()
private val mutex = Mutex()
private val _highlights = MutableStateFlow<Map<String, List<HighlightData>>>(emptyMap())
val highlights: StateFlow<Map<String, List<HighlightData>>> = _highlights.asStateFlow()
private val highlightsDir: File by lazy {
val dir = File(System.getProperty("user.home"), ".amethyst/highlights")
if (!dir.exists()) {
dir.mkdirs()
}
dir
}
private val indexFile: File get() = File(highlightsDir, "index.json")
init {
scope.launch(Dispatchers.IO) {
loadIndex()
}
}
suspend fun addHighlight(
articleAddressTag: String,
text: String,
note: String?,
articleTitle: String?,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
val articleHighlights = current.getOrDefault(articleAddressTag, emptyList()).toMutableList()
// Avoid duplicate highlights of the same text
if (articleHighlights.any { it.text == text }) return
articleHighlights.add(
HighlightData(
id = UUID.randomUUID().toString(),
text = text,
note = note,
articleAddressTag = articleAddressTag,
articleTitle = articleTitle,
createdAt = Instant.now().epochSecond,
),
)
current[articleAddressTag] = articleHighlights
_highlights.value = current
saveIndex(current)
}
}
suspend fun updateNote(
highlightId: String,
note: String,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val idx = list.indexOfFirst { it.id == highlightId }
if (idx >= 0) {
current[key] =
list.toMutableList().apply {
set(idx, get(idx).copy(note = note))
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
suspend fun removeHighlight(highlightId: String) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val filtered = list.filter { it.id != highlightId }
if (filtered.size != list.size) {
if (filtered.isEmpty()) {
current.remove(key)
} else {
current[key] = filtered
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
suspend fun markPublished(
highlightId: String,
eventId: String,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val idx = list.indexOfFirst { it.id == highlightId }
if (idx >= 0) {
current[key] =
list.toMutableList().apply {
set(idx, get(idx).copy(published = true, eventId = eventId))
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
fun getHighlightsForArticle(addressTag: String): List<HighlightData> = _highlights.value[addressTag] ?: emptyList()
fun getAllHighlights(): Map<String, List<HighlightData>> = _highlights.value
private suspend fun loadIndex() {
mutex.withLock {
if (indexFile.exists()) {
try {
val data: Map<String, List<HighlightData>> = mapper.readValue(indexFile)
_highlights.value = data
} catch (e: Exception) {
// Corrupted file — start fresh
_highlights.value = emptyMap()
}
}
}
}
private fun saveIndex(data: Map<String, List<HighlightData>>) {
try {
val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(data)
val tempFile = File(highlightsDir, "index.json.tmp")
tempFile.writeText(json)
Files.move(
tempFile.toPath(),
indexFile.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
} catch (_: Exception) {
// Best effort — don't crash on write failure
}
}
}
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -49,6 +50,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -56,9 +58,11 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader
import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents
@@ -70,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
@@ -78,12 +83,14 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscriptio
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -115,6 +122,7 @@ fun ArticleReaderScreen(
account: AccountState.LoggedIn?,
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
highlightStore: DesktopHighlightStore? = null,
onBack: () -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
@@ -135,6 +143,18 @@ fun ArticleReaderScreen(
// Zoom level for article text
var zoomLevel by remember { mutableStateOf(1.0f) }
// Coroutine scope for highlight operations
val scope = rememberCoroutineScope()
// Highlight state
val articleHighlights =
highlightStore?.let { store ->
val allHighlights by store.highlights.collectAsState()
allHighlights[addressTag] ?: emptyList()
} ?: emptyList()
var showAnnotationDialog by remember { mutableStateOf<String?>(null) }
val focusRequester =
remember {
androidx.compose.ui.focus
@@ -331,6 +351,8 @@ fun ArticleReaderScreen(
val authorName = authorUser?.toBestDisplayName()
val authorPicture = authorUser?.profilePicture()
val clipboardManager = LocalClipboardManager.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
@@ -359,6 +381,27 @@ fun ArticleReaderScreen(
true
}
Key.H -> {
val selectedText = clipboardManager.getText()?.text
if (!selectedText.isNullOrBlank() && highlightStore != null) {
if (event.isShiftPressed) {
showAnnotationDialog = selectedText
} else {
scope.launch {
highlightStore.addHighlight(
articleAddressTag = addressTag,
text = selectedText,
note = null,
articleTitle = title,
)
}
}
true
} else {
false
}
}
else -> {
false
}
@@ -480,12 +523,15 @@ fun ArticleReaderScreen(
thickness = 1.dp,
)
// Markdown body
RenderMarkdown(
content = content,
onLinkClick = onLinkClick,
fontScale = zoomLevel,
)
// Markdown body with selectable text
SelectionContainer {
RenderMarkdown(
content = content,
onLinkClick = onLinkClick,
fontScale = zoomLevel,
highlightedTexts = articleHighlights.map { it.text },
)
}
Spacer(Modifier.height(32.dp))
@@ -549,4 +595,22 @@ fun ArticleReaderScreen(
}
}
}
showAnnotationDialog?.let { selectedText ->
HighlightAnnotationDialog(
selectedText = selectedText,
onConfirm = { note ->
scope.launch {
highlightStore?.addHighlight(
articleAddressTag = addressTag,
text = selectedText,
note = note,
articleTitle = title,
)
}
showAnnotationDialog = null
},
onDismiss = { showAnnotationDialog = null },
)
}
}
@@ -54,6 +54,7 @@ private val COLUMN_OPTIONS =
DeckColumnType.Search,
DeckColumnType.Reads,
DeckColumnType.Drafts,
DeckColumnType.MyHighlights,
DeckColumnType.Bookmarks,
DeckColumnType.GlobalFeed,
DeckColumnType.MyProfile,
@@ -135,6 +135,7 @@ fun DeckColumnType.icon(): ImageVector =
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
DeckColumnType.MyHighlights -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Profile -> Icons.Default.Person
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Hashtag -> Icons.Default.Tag
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
@@ -105,6 +106,8 @@ fun DeckColumnContainer(
val navState = remember(column.id) { ColumnNavigationState() }
val navStack by navState.stack.collectAsState()
val currentOverlay = navStack.lastOrNull()
val containerScope = rememberCoroutineScope()
val highlightStore = remember { DesktopHighlightStore(containerScope) }
Column(
modifier =
@@ -136,6 +139,7 @@ fun DeckColumnContainer(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
appScope = appScope,
compactMode = true,
onShowComposeDialog = onShowComposeDialog,
@@ -157,6 +161,7 @@ fun DeckColumnContainer(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
@@ -180,6 +185,7 @@ internal fun RootContent(
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
appScope: CoroutineScope,
compactMode: Boolean = false,
onShowComposeDialog: () -> Unit,
@@ -339,6 +345,7 @@ internal fun RootContent(
localCache = localCache,
account = account,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = {},
onNavigateToProfile = onNavigateToProfile,
)
@@ -364,6 +371,13 @@ internal fun RootContent(
)
}
DeckColumnType.MyHighlights -> {
com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen(
highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) },
onNavigateToArticle = onNavigateToArticle,
)
}
is DeckColumnType.Hashtag -> {
SearchScreen(
localCache = localCache,
@@ -385,6 +399,7 @@ internal fun OverlayContent(
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
@@ -431,6 +446,7 @@ internal fun OverlayContent(
localCache = localCache,
account = account,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = onBack,
onNavigateToProfile = onNavigateToProfile,
)
@@ -61,6 +61,8 @@ sealed class DeckColumnType {
object Drafts : DeckColumnType()
object MyHighlights : DeckColumnType()
data class Hashtag(
val tag: String,
) : DeckColumnType()
@@ -80,6 +82,7 @@ sealed class DeckColumnType {
is Article -> "Article"
is Editor -> "New Article"
Drafts -> "Drafts"
MyHighlights -> "Highlights"
is Profile -> "Profile"
is Thread -> "Thread"
is Hashtag -> "#$tag"
@@ -100,6 +103,7 @@ sealed class DeckColumnType {
is Article -> "article"
is Editor -> "editor"
Drafts -> "drafts"
MyHighlights -> "highlights"
is Profile -> "profile"
is Thread -> "thread"
is Hashtag -> "hashtag"
@@ -80,6 +80,7 @@ private val navItems =
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"),
NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"),
NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"),
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun HighlightAnnotationDialog(
selectedText: String,
onConfirm: (note: String) -> Unit,
onDismiss: () -> Unit,
) {
var note by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Highlight Note") },
text = {
Column {
Text(
text = "\"$selectedText\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = note,
onValueChange = { note = it },
label = { Text("Note (optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 5,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(note) }) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag
import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag
import com.vitorpamplona.quartz.utils.TimeUtils
object HighlightPublishAction {
suspend fun publish(
highlightText: String,
articleAddressTag: String,
note: String?,
context: String?,
signer: NostrSigner,
): HighlightEvent {
val tags = mutableListOf<Array<String>>()
tags.add(AltTag.assemble(HighlightEvent.ALT))
tags.add(ATag.assemble(articleAddressTag, null))
// Tag the article author
val parts = articleAddressTag.split(":", limit = 3)
val pubkey = parts.getOrNull(1)
if (!pubkey.isNullOrBlank()) {
tags.add(PTag.assemble(pubkey, null))
}
if (!note.isNullOrBlank()) {
tags.add(CommentTag.assemble(note))
}
if (!context.isNullOrBlank()) {
tags.add(ContextTag.assemble(context))
}
return signer.sign(
createdAt = TimeUtils.now(),
kind = HighlightEvent.KIND,
tags = tags.toTypedArray(),
content = highlightText,
)
}
fun extractContext(
content: String,
highlightText: String,
): String? {
val paragraphs = content.split("\n\n")
return paragraphs.find { it.contains(highlightText) }
}
}
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun MyHighlightsScreen(
highlightStore: DesktopHighlightStore,
onNavigateToArticle: (addressTag: String) -> Unit,
) {
val allHighlights by highlightStore.highlights.collectAsState()
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
Text(
"Highlights",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
if (allHighlights.isEmpty()) {
EmptyState(
title = "No highlights yet",
description = "Select text in an article and choose \"Highlight\" to save passages.",
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
allHighlights.forEach { (addressTag, highlights) ->
val articleTitle = highlights.firstOrNull()?.articleTitle ?: addressTag
stickyHeader(key = addressTag) {
ArticleGroupHeader(
title = articleTitle,
onClick = { onNavigateToArticle(addressTag) },
)
}
items(highlights, key = { it.id }) { highlight ->
HighlightCard(
highlight = highlight,
onDelete = { deleteTarget = highlight },
)
}
}
}
}
}
deleteTarget?.let { highlight ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text("Delete Highlight") },
text = {
Text(
"Delete this highlight? This cannot be undone.",
)
},
confirmButton = {
TextButton(onClick = {
scope.launch { highlightStore.removeHighlight(highlight.id) }
deleteTarget = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text("Cancel")
}
},
)
}
}
@Composable
private fun ArticleGroupHeader(
title: String,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun HighlightCard(
highlight: HighlightData,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "\u201C${highlight.text}\u201D",
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
)
val noteText = highlight.note
if (!noteText.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = noteText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatTimestamp(highlight.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock,
contentDescription = if (highlight.published) "Published" else "Private",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete highlight",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
private fun formatTimestamp(epochSeconds: Long): String =
Instant
.ofEpochSecond(epochSeconds)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
@@ -0,0 +1,410 @@
---
title: "feat: Article Highlights & Note-Taking"
type: feat
status: active
date: 2026-03-24
deepened: 2026-03-24
origin: docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md
---
# feat: Article Highlights & Note-Taking
## Enhancement Summary
**Deepened on:** 2026-03-24
**Research agents used:** text-selection, richtext-rendering, floating-popup, nip84-highlights
### Key Improvements
1. **Text selection strategy resolved** — Use `LocalTextContextMenu` override (only official API exposing selected text), not clipboard polling
2. **Inline rendering strategy resolved** — Pre-process markdown with special link URI (`highlight://`) as v1; fork-level `==highlight==` syntax as v2
3. **Floating toolbar pattern confirmed**`Popup` + custom `PopupPositionProvider`, matches existing `ChatPane.kt` pattern
4. **NIP-84 gap found**`HighlightEvent.create()` only takes `msg`+`signer`, needs tag assembly wrapper for full highlight creation
### Resolved Questions
- **`<mark>` support?** No — richtext library ignores `HtmlInline` nodes. Use pre-processing or fork changes.
- **Clipboard polling reliability?** Moot — use `LocalTextContextMenu` instead (right-click UX, official API)
- **NIP-09 deletion?** Yes — send kind 5 event with `["e", highlightEventId]` + `["k", "9802"]`
## Overview
Text selection-based highlight and annotation system for the Desktop article reader. Users select text in NIP-23 articles, a context menu option or floating toolbar appears, and they create highlights (with optional notes). Highlights render inline as colored markers. Supports private (local) and public (NIP-84) modes. Includes a "My Highlights" aggregation screen.
## Problem Statement
Desktop article reader has no way to mark up, annotate, or take notes on long-form content. Users reading NIP-23 articles can't highlight passages, add personal notes, or publish highlights to their Nostr social graph. This limits the reading experience compared to tools like Kindle, Medium, or Hypothesis.
## Proposed Solution
Three-phase implementation:
1. **Storage + data model** — DesktopHighlightStore (Preferences-based) + highlight data classes
2. **Selection UX + inline rendering** — Text selection interception via context menu, floating toolbar, yellow highlight markers in markdown
3. **My Highlights screen + NIP-84 publishing** — Aggregation view, public/private toggle, relay broadcast
## Technical Approach
### Architecture
```
desktopApp/
├── service/highlights/
│ └── DesktopHighlightStore.kt # Preferences-based storage (like DraftStore)
├── ui/
│ ├── ArticleReaderScreen.kt # Modified: selection + inline highlights
│ ├── highlights/
│ │ ├── FloatingHighlightToolbar.kt # Popup on text selection
│ │ ├── HighlightAnnotationDialog.kt # Note entry dialog
│ │ ├── HighlightPublishAction.kt # NIP-84 tag assembly + publish
│ │ └── MyHighlightsScreen.kt # Aggregation screen
│ └── deck/
│ ├── DeckColumnType.kt # Add MyHighlights
│ ├── DeckColumnContainer.kt # Route MyHighlights
│ └── SinglePaneLayout.kt # Add nav item
commons/
├── compose/markdown/
│ └── RenderMarkdown.kt # Modified: accept highlight ranges, render yellow bg
├── model/highlights/
│ └── HighlightData.kt # Shared data class
```
### Implementation Phases
#### Phase 1: Storage & Data Model
**Goal:** DesktopHighlightStore + highlight data classes, no UI yet.
**Files:**
- `desktopApp/service/highlights/DesktopHighlightStore.kt` — follows DesktopDraftStore pattern (Jackson + Preferences)
- `commons/model/highlights/HighlightData.kt` — shared data class
**Data model:**
```kotlin
data class HighlightData(
val id: String, // UUID
val text: String, // selected/highlighted text
val note: String?, // optional annotation
val articleAddressTag: String, // "30023:pubkey:d-tag"
val articleTitle: String?, // cached for My Highlights display
val createdAt: Long, // epoch seconds
val published: Boolean, // false = private, true = NIP-84 published
val eventId: String?, // NIP-84 event ID if published
)
```
**DesktopHighlightStore API:**
```kotlin
class DesktopHighlightStore(scope: CoroutineScope) {
private val mapper = jacksonObjectMapper()
val highlights: StateFlow<Map<String, List<HighlightData>>> // keyed by articleAddressTag
suspend fun addHighlight(articleAddressTag: String, text: String, note: String?, articleTitle: String?)
suspend fun updateNote(highlightId: String, note: String)
suspend fun removeHighlight(highlightId: String)
suspend fun markPublished(highlightId: String, eventId: String)
fun getHighlightsForArticle(addressTag: String): List<HighlightData>
fun getAllHighlights(): Map<String, List<HighlightData>>
}
```
**Tests:** Unit tests for store CRUD, serialization round-trip.
**Success criteria:**
- [ ] HighlightData serializes/deserializes via Jackson
- [ ] Store persists across app restarts via Preferences
- [ ] CRUD operations work correctly
- [ ] StateFlow emits on changes
### Research Insights — Phase 1
**Storage pattern:** Follow `DesktopDraftStore.kt` exactly:
- `jacksonObjectMapper()` for serialization (line 62)
- Atomic writes with temp files + `Files.move()` (lines 237-254)
- POSIX file permissions for security (lines 261-288)
- Preferences key: `"highlights:${articleAddressTag}"` with JSON array value
**Edge case — Preferences size limit:** `java.util.prefs.Preferences` has a per-value limit of 8192 bytes on some platforms. For articles with many highlights, the JSON array could exceed this. Mitigation: if value exceeds 6KB, spill to file-based storage (same pattern as DraftStore's file storage).
---
#### Phase 2: Selection UX + Inline Rendering
**Goal:** Select text in article → create highlight → see yellow marker.
##### Text Selection Strategy (REVISED)
**Primary: `LocalTextContextMenu` override** — the only official Compose Desktop API that exposes `selectedText`:
```kotlin
@Composable
fun HighlightableContent(
onHighlight: (String) -> Unit,
onAnnotate: (String) -> Unit,
content: @Composable () -> Unit,
) {
val defaultMenu = LocalTextContextMenu.current
CompositionLocalProvider(
LocalTextContextMenu provides object : TextContextMenu {
@Composable
override fun Area(
textManager: TextContextMenu.TextManager,
state: ContextMenuState,
content: @Composable () -> Unit,
) {
ContextMenuDataProvider({
val selected = textManager.selectedText
if (selected.text.isNotEmpty()) {
listOf(
ContextMenuItem("Highlight") { onHighlight(selected.text) },
ContextMenuItem("Highlight with Note") { onAnnotate(selected.text) },
)
} else {
emptyList()
}
}) {
defaultMenu.Area(textManager, state, content = content)
}
}
},
content = content,
)
}
```
**UX:** Select text → right-click → "Highlight" / "Highlight with Note" in context menu. Natural desktop UX. No clipboard polling needed.
**Secondary: Keyboard shortcut (Cmd+H)** — reads clipboard after user copies:
```kotlin
Modifier.onPreviewKeyEvent { event ->
if (event.isMetaPressed && event.key == Key.H && event.type == KeyEventType.KeyDown) {
val clipText = clipboard.getText()?.text
if (!clipText.isNullOrBlank()) onHighlight(clipText)
true
} else false
}
```
##### Inline Rendering Strategy (REVISED)
**Research finding:** richtext library ignores `HtmlInline` (`<mark>`) and has no `Highlight` format. Three options ranked:
| Approach | Effort | Quality | Recommended |
|----------|--------|---------|-------------|
| Pre-process: wrap in special link `[text](highlight://)` | Low | Hacky but works | v1 |
| Fork: add `==text==` DelimiterProcessor + `Format.Highlight` | Medium | Clean, semantic | v2 |
| Overlay: position colored Box composables | High | Fragile | No |
**v1 approach (ship fast):** Pre-process markdown before parsing:
```kotlin
fun applyHighlights(content: String, highlights: List<HighlightData>): String {
var result = content
// Sort by length descending to avoid nested replacement issues
highlights.sortedByDescending { it.text.length }.forEach { h ->
val idx = result.indexOf(h.text)
if (idx >= 0) {
// Wrap in bold + italic to visually distinguish
result = result.replaceFirst(h.text, "***${h.text}***")
}
}
return result
}
```
**v2 approach (proper):** Add highlight support to Vitor's richtext fork:
1. Add `AstHighlight` inline node type
2. Add `HighlightDelimiterProcessor` for `==text==` syntax
3. Add `Format.Highlight` with `SpanStyle(background = Color(0xFFFFEB3B))`
4. Pre-process: wrap highlights with `==text==` before parsing
##### Floating Toolbar (for future enhancement beyond context menu)
**Pattern:** `Popup` + custom `PopupPositionProvider` (matches existing `ChatPane.kt:584`):
```kotlin
class MousePositionProvider(private val offset: IntOffset) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect, windowSize: IntSize,
layoutDirection: LayoutDirection, popupContentSize: IntSize,
): IntOffset {
val x = (offset.x - popupContentSize.width / 2)
.coerceIn(0, windowSize.width - popupContentSize.width)
val y = (offset.y - popupContentSize.height - 8)
.coerceIn(0, windowSize.height - popupContentSize.height)
return IntOffset(x, y)
}
}
```
Track mouse via `Modifier.pointerInput` + `awaitPointerEventScope` (pattern from `VideoControls.kt:89`). Dismiss on scroll via `LaunchedEffect(scrollState.isScrollInProgress)`.
**Files:**
- `desktopApp/ui/highlights/FloatingHighlightToolbar.kt` — Popup with Highlight/Annotate buttons
- `desktopApp/ui/highlights/HighlightAnnotationDialog.kt` — AlertDialog for note text
- `desktopApp/ui/ArticleReaderScreen.kt` — Add context menu override, highlight state, inline rendering
- `commons/compose/markdown/RenderMarkdown.kt` — Add `highlights: List<HighlightData>` param
**UX flow (revised):**
1. User reads article, selects text by click-dragging
2. Right-click → context menu shows "Highlight" / "Highlight with Note" (via `LocalTextContextMenu`)
3. "Highlight" → saves immediately via DesktopHighlightStore, re-renders with bold/italic marker (v1) or yellow bg (v2)
4. "Highlight with Note" → opens HighlightAnnotationDialog → saves with note
5. Click existing highlight → popup with note / delete / publish toggle
**Success criteria:**
- [ ] Right-click context menu shows "Highlight" option when text is selected
- [ ] Highlight saves and renders as visual marker in article
- [ ] Annotation dialog captures and saves notes
- [ ] Highlights persist across navigation (leave article and return)
- [ ] Best-effort match: highlights survive minor article edits (see brainstorm)
- [ ] Cmd+H keyboard shortcut works as alternative (select, copy, Cmd+H)
---
#### Phase 3: My Highlights Screen + NIP-84 Publishing
**Goal:** Aggregation screen showing all highlights grouped by article. Public/private toggle per highlight.
**Files:**
- `desktopApp/ui/highlights/MyHighlightsScreen.kt`
- `desktopApp/ui/highlights/HighlightPublishAction.kt` — tag assembly + publish
- `desktopApp/ui/deck/DeckColumnType.kt` — Add `object MyHighlights`
- `desktopApp/ui/deck/DeckColumnContainer.kt` — Route MyHighlights
- `desktopApp/ui/deck/SinglePaneLayout.kt` — Add nav item
- `desktopApp/ui/deck/AddColumnDialog.kt` — Add to column options
- `desktopApp/ui/deck/ColumnHeader.kt` — Add icon
##### NIP-84 Publishing (REVISED)
**Gap found:** `HighlightEvent.create()` only takes `msg` + `signer` — doesn't accept source/context/comment tags. Need a wrapper:
```kotlin
object HighlightPublishAction {
suspend fun publish(
highlightText: String,
articleEvent: LongTextNoteEvent,
note: String?,
signer: NostrSigner,
): HighlightEvent {
val tags = TagArrayBuilder().apply {
// Article reference (addressable event)
add(ATag.assemble(30023, articleEvent.pubKey, articleEvent.dTag()))
// Article author attribution
add(PTag.assemble(articleEvent.pubKey, role = "author"))
// Optional annotation
note?.let { add(CommentTag.assemble(it)) }
// Surrounding paragraph as context
extractContext(articleEvent.content, highlightText)?.let {
add(ContextTag.assemble(it))
}
// Alt text for non-NIP-84 clients
add(AltTag.assemble("Highlight: $highlightText"))
}.build()
return HighlightEvent.create(
msg = highlightText,
tags = tags,
signer = signer,
)
}
/** Extract the paragraph containing the highlighted text */
fun extractContext(content: String, highlightText: String): String? {
val paragraphs = content.split("\n\n")
return paragraphs.find { it.contains(highlightText) }
}
}
```
##### NIP-09 Deletion for Published Highlights
```kotlin
suspend fun deleteHighlight(eventId: String, signer: NostrSigner): DeletionEvent {
return DeletionEvent.create(
deleteEvents = listOf(eventId),
deleteKinds = listOf(9802),
reason = "User deleted highlight",
signer = signer,
)
}
```
**My Highlights screen layout:**
```
┌─────────────────────────────────┐
│ My Highlights │
├─────────────────────────────────┤
│ ▼ "Article Title One" │
│ "highlighted text..." 🔒 │
│ Note: my annotation
│ Mar 24, 2026 │
│ │
│ "another highlight..." 🌐 │
│ Mar 24, 2026 │
│ │
│ ▼ "Article Title Two" │
│ "highlighted passage..." 🔒 │
│ Note: thoughts here
└─────────────────────────────────┘
🔒 = private 🌐 = published
Click article title → navigates to article
Click 🔒 → publish to Nostr (NIP-84)
Click → delete (+ NIP-09 if published)
```
**Success criteria:**
- [ ] My Highlights accessible from sidebar nav
- [ ] Highlights grouped by article with collapsible sections
- [ ] Click article title navigates to article (onNavigateToArticle)
- [ ] Public/private toggle publishes NIP-84 event to relays
- [ ] Delete removes from local store + sends NIP-09 deletion for published
- [ ] Empty state when no highlights exist
## Acceptance Criteria
- [ ] Right-click selected text in article → "Highlight" in context menu
- [ ] Click "Highlight" → text marked visually, saved locally
- [ ] Click "Highlight with Note" → note dialog, then saved with annotation
- [ ] Cmd+H keyboard shortcut creates highlight from clipboard
- [ ] Highlights persist across app restarts
- [ ] Highlights survive article content updates (best-effort string match)
- [ ] "My Highlights" screen shows all highlights grouped by article
- [ ] Can toggle highlight public/private (publishes NIP-84 event)
- [ ] Can delete highlights (+ NIP-09 for published)
- [ ] Zoom (Cmd+/Cmd-) doesn't break highlight rendering
- [ ] Works in both single-pane and deck layout modes
## Dependencies & Risks
| Risk | Impact | Mitigation | Status |
|------|--------|------------|--------|
| `SelectionContainer` doesn't expose selection state | High | **Resolved:** Use `LocalTextContextMenu` override — official API, accesses `selectedText` directly | Mitigated |
| richtext library doesn't support `<mark>` or highlight formatting | Medium | **Resolved:** v1 uses bold/italic pre-processing; v2 adds `Format.Highlight` to fork | Mitigated |
| `LocalTextContextMenu.TextManager.selectedText` doesn't work across multiple `Text()` children | Medium | Test during Phase 2; fallback to clipboard-based Cmd+H shortcut | Open |
| `HighlightEvent.create()` doesn't accept custom tags | Low | **Resolved:** Create `HighlightPublishAction` wrapper with `TagArrayBuilder` | Mitigated |
| Preferences 8KB per-value limit | Low | Monitor; spill to file storage if needed | Open |
## Sources & References
### Origin
- **Brainstorm:** [docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md](docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md)
- Key decisions: private+public scope, select+popup UX, Preferences storage, own highlights only, best-effort persistence, My Highlights screen
### Internal References
- HighlightEvent protocol: `quartz/nip84Highlights/HighlightEvent.kt:137-141`
- DraftStore pattern: `desktopApp/service/drafts/DesktopDraftStore.kt`
- Event publishing: `desktopApp/ui/ArticleEditorScreen.kt:161-187`
- SelectionContainer: `desktopApp/ui/ArticleEditorScreen.kt:304`
- Context menu override: `LocalTextContextMenu` (Compose Desktop API)
- Popup pattern: `desktopApp/ui/chats/ChatPane.kt:584`
- Mouse tracking: `desktopApp/ui/media/VideoControls.kt:89`
- Android highlight rendering: `amethyst/ui/note/types/Highlight.kt:179-198`
### External References
- [Compose Desktop context menus](https://kotlinlang.org/docs/multiplatform/compose-desktop-context-menus.html)
- [NIP-84 spec (Highlights)](https://github.com/nostr-protocol/nips/blob/master/84.md)
- [NIP-09 spec (Event Deletion)](https://github.com/nostr-protocol/nips/blob/master/09.md)
- [commonmark-java DelimiterProcessor](https://github.com/commonmark/commonmark-java)