diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt index 6314ac026..8838d3d5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt @@ -42,10 +42,27 @@ fun RenderMarkdown( onLinkClick: (String) -> Unit, modifier: Modifier = Modifier, fontScale: Float = 1.0f, + highlightedTexts: List = 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 = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt new file mode 100644 index 000000000..4e1d86628 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt @@ -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, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 0f036aa2e..01abd33d1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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) }) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt new file mode 100644 index 000000000..16f238ffd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt @@ -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>>(emptyMap()) + val highlights: StateFlow>> = _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 = _highlights.value[addressTag] ?: emptyList() + + fun getAllHighlights(): Map> = _highlights.value + + private suspend fun loadIndex() { + mutex.withLock { + if (indexFile.exists()) { + try { + val data: Map> = mapper.readValue(indexFile) + _highlights.value = data + } catch (e: Exception) { + // Corrupted file — start fresh + _highlights.value = emptyMap() + } + } + } + } + + private fun saveIndex(data: Map>) { + 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 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index 654cac22c..abcd83b09 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -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(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 }, + ) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt index eeee4a29d..f85a4707c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -54,6 +54,7 @@ private val COLUMN_OPTIONS = DeckColumnType.Search, DeckColumnType.Reads, DeckColumnType.Drafts, + DeckColumnType.MyHighlights, DeckColumnType.Bookmarks, DeckColumnType.GlobalFeed, DeckColumnType.MyProfile, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index 17a2eb781..3bd9fe2ca 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 52cdc7eb1..5bb66c29d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -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, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index c3666b866..3f5ea27b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -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" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 92dd5e91b..52c64dca2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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"), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt new file mode 100644 index 000000000..d43b51c13 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt @@ -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") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt new file mode 100644 index 000000000..992640b02 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt @@ -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>() + + 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) } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt new file mode 100644 index 000000000..ff417478e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -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(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")) diff --git a/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md b/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md new file mode 100644 index 000000000..7e3edfccb --- /dev/null +++ b/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md @@ -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 +- **`` 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>> // 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 + fun getAllHighlights(): Map> +} +``` + +**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` (``) 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): 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` 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 `` 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)