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:
+19
-2
@@ -42,10 +42,27 @@ fun RenderMarkdown(
|
|||||||
onLinkClick: (String) -> Unit,
|
onLinkClick: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
fontScale: Float = 1.0f,
|
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 =
|
val astNode =
|
||||||
remember(content) {
|
remember(processedContent) {
|
||||||
CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content)
|
CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
val uriHandler =
|
val uriHandler =
|
||||||
|
|||||||
+32
@@ -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("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
|
||||||
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
|
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
|
||||||
Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) })
|
Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) })
|
||||||
|
Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) })
|
||||||
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
|
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
|
||||||
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
|
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
|
||||||
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
|
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
|
||||||
|
|||||||
+193
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-1
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.isMetaPressed
|
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.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader
|
import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader
|
||||||
import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents
|
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.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
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.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
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.createRepostsSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
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.nip18Reposts.RepostEvent
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -115,6 +122,7 @@ fun ArticleReaderScreen(
|
|||||||
account: AccountState.LoggedIn?,
|
account: AccountState.LoggedIn?,
|
||||||
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
|
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
|
highlightStore: DesktopHighlightStore? = null,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
onNavigateToThread: (String) -> Unit = {},
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
@@ -135,6 +143,18 @@ fun ArticleReaderScreen(
|
|||||||
|
|
||||||
// Zoom level for article text
|
// Zoom level for article text
|
||||||
var zoomLevel by remember { mutableStateOf(1.0f) }
|
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 =
|
val focusRequester =
|
||||||
remember {
|
remember {
|
||||||
androidx.compose.ui.focus
|
androidx.compose.ui.focus
|
||||||
@@ -331,6 +351,8 @@ fun ArticleReaderScreen(
|
|||||||
val authorName = authorUser?.toBestDisplayName()
|
val authorName = authorUser?.toBestDisplayName()
|
||||||
val authorPicture = authorUser?.profilePicture()
|
val authorPicture = authorUser?.profilePicture()
|
||||||
|
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
@@ -359,6 +381,27 @@ fun ArticleReaderScreen(
|
|||||||
true
|
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 -> {
|
else -> {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -480,12 +523,15 @@ fun ArticleReaderScreen(
|
|||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Markdown body
|
// Markdown body with selectable text
|
||||||
|
SelectionContainer {
|
||||||
RenderMarkdown(
|
RenderMarkdown(
|
||||||
content = content,
|
content = content,
|
||||||
onLinkClick = onLinkClick,
|
onLinkClick = onLinkClick,
|
||||||
fontScale = zoomLevel,
|
fontScale = zoomLevel,
|
||||||
|
highlightedTexts = articleHighlights.map { it.text },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -54,6 +54,7 @@ private val COLUMN_OPTIONS =
|
|||||||
DeckColumnType.Search,
|
DeckColumnType.Search,
|
||||||
DeckColumnType.Reads,
|
DeckColumnType.Reads,
|
||||||
DeckColumnType.Drafts,
|
DeckColumnType.Drafts,
|
||||||
|
DeckColumnType.MyHighlights,
|
||||||
DeckColumnType.Bookmarks,
|
DeckColumnType.Bookmarks,
|
||||||
DeckColumnType.GlobalFeed,
|
DeckColumnType.GlobalFeed,
|
||||||
DeckColumnType.MyProfile,
|
DeckColumnType.MyProfile,
|
||||||
|
|||||||
+1
@@ -135,6 +135,7 @@ fun DeckColumnType.icon(): ImageVector =
|
|||||||
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
|
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
|
||||||
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
|
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
|
||||||
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
|
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
|
||||||
|
DeckColumnType.MyHighlights -> Icons.AutoMirrored.Filled.Article
|
||||||
is DeckColumnType.Profile -> Icons.Default.Person
|
is DeckColumnType.Profile -> Icons.Default.Person
|
||||||
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
|
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
|
||||||
is DeckColumnType.Hashtag -> Icons.Default.Tag
|
is DeckColumnType.Hashtag -> Icons.Default.Tag
|
||||||
|
|||||||
+16
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
|||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
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.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
||||||
@@ -105,6 +106,8 @@ fun DeckColumnContainer(
|
|||||||
val navState = remember(column.id) { ColumnNavigationState() }
|
val navState = remember(column.id) { ColumnNavigationState() }
|
||||||
val navStack by navState.stack.collectAsState()
|
val navStack by navState.stack.collectAsState()
|
||||||
val currentOverlay = navStack.lastOrNull()
|
val currentOverlay = navStack.lastOrNull()
|
||||||
|
val containerScope = rememberCoroutineScope()
|
||||||
|
val highlightStore = remember { DesktopHighlightStore(containerScope) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -136,6 +139,7 @@ fun DeckColumnContainer(
|
|||||||
iAccount = iAccount,
|
iAccount = iAccount,
|
||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
highlightStore = highlightStore,
|
||||||
appScope = appScope,
|
appScope = appScope,
|
||||||
compactMode = true,
|
compactMode = true,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
@@ -157,6 +161,7 @@ fun DeckColumnContainer(
|
|||||||
account = account,
|
account = account,
|
||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
highlightStore = highlightStore,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
@@ -180,6 +185,7 @@ internal fun RootContent(
|
|||||||
iAccount: DesktopIAccount,
|
iAccount: DesktopIAccount,
|
||||||
nwcConnection: Nip47URINorm?,
|
nwcConnection: Nip47URINorm?,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
highlightStore: DesktopHighlightStore? = null,
|
||||||
appScope: CoroutineScope,
|
appScope: CoroutineScope,
|
||||||
compactMode: Boolean = false,
|
compactMode: Boolean = false,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
@@ -339,6 +345,7 @@ internal fun RootContent(
|
|||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
highlightStore = highlightStore,
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
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 -> {
|
is DeckColumnType.Hashtag -> {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
@@ -385,6 +399,7 @@ internal fun OverlayContent(
|
|||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
nwcConnection: Nip47URINorm?,
|
nwcConnection: Nip47URINorm?,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||||
|
highlightStore: DesktopHighlightStore? = null,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onZapFeedback: (ZapFeedback) -> Unit,
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
@@ -431,6 +446,7 @@ internal fun OverlayContent(
|
|||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
highlightStore = highlightStore,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
)
|
)
|
||||||
|
|||||||
+4
@@ -61,6 +61,8 @@ sealed class DeckColumnType {
|
|||||||
|
|
||||||
object Drafts : DeckColumnType()
|
object Drafts : DeckColumnType()
|
||||||
|
|
||||||
|
object MyHighlights : DeckColumnType()
|
||||||
|
|
||||||
data class Hashtag(
|
data class Hashtag(
|
||||||
val tag: String,
|
val tag: String,
|
||||||
) : DeckColumnType()
|
) : DeckColumnType()
|
||||||
@@ -80,6 +82,7 @@ sealed class DeckColumnType {
|
|||||||
is Article -> "Article"
|
is Article -> "Article"
|
||||||
is Editor -> "New Article"
|
is Editor -> "New Article"
|
||||||
Drafts -> "Drafts"
|
Drafts -> "Drafts"
|
||||||
|
MyHighlights -> "Highlights"
|
||||||
is Profile -> "Profile"
|
is Profile -> "Profile"
|
||||||
is Thread -> "Thread"
|
is Thread -> "Thread"
|
||||||
is Hashtag -> "#$tag"
|
is Hashtag -> "#$tag"
|
||||||
@@ -100,6 +103,7 @@ sealed class DeckColumnType {
|
|||||||
is Article -> "article"
|
is Article -> "article"
|
||||||
is Editor -> "editor"
|
is Editor -> "editor"
|
||||||
Drafts -> "drafts"
|
Drafts -> "drafts"
|
||||||
|
MyHighlights -> "highlights"
|
||||||
is Profile -> "profile"
|
is Profile -> "profile"
|
||||||
is Thread -> "thread"
|
is Thread -> "thread"
|
||||||
is Hashtag -> "hashtag"
|
is Hashtag -> "hashtag"
|
||||||
|
|||||||
+1
@@ -80,6 +80,7 @@ private val navItems =
|
|||||||
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
|
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
|
||||||
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
|
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
|
||||||
NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"),
|
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.Search, Icons.Default.Search, "Search"),
|
||||||
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
|
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
|
||||||
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
|
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
|
||||||
|
|||||||
+80
@@ -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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+75
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -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)
|
||||||
Reference in New Issue
Block a user