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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-24 12:16:54 +02:00
parent c75176891f
commit efa294ea72
14 changed files with 1137 additions and 8 deletions
@@ -380,6 +380,7 @@ fun main() {
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) })
Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) })
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
@@ -0,0 +1,193 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.highlights
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.util.UUID
/**
* Local highlight storage for article annotations.
* Stores highlights as JSON in ~/.amethyst/highlights/index.json.
* Uses atomic writes following the same pattern as DesktopDraftStore.
*/
class DesktopHighlightStore(
private val scope: CoroutineScope,
) {
private val mapper = jacksonObjectMapper()
private val mutex = Mutex()
private val _highlights = MutableStateFlow<Map<String, List<HighlightData>>>(emptyMap())
val highlights: StateFlow<Map<String, List<HighlightData>>> = _highlights.asStateFlow()
private val highlightsDir: File by lazy {
val dir = File(System.getProperty("user.home"), ".amethyst/highlights")
if (!dir.exists()) {
dir.mkdirs()
}
dir
}
private val indexFile: File get() = File(highlightsDir, "index.json")
init {
scope.launch(Dispatchers.IO) {
loadIndex()
}
}
suspend fun addHighlight(
articleAddressTag: String,
text: String,
note: String?,
articleTitle: String?,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
val articleHighlights = current.getOrDefault(articleAddressTag, emptyList()).toMutableList()
// Avoid duplicate highlights of the same text
if (articleHighlights.any { it.text == text }) return
articleHighlights.add(
HighlightData(
id = UUID.randomUUID().toString(),
text = text,
note = note,
articleAddressTag = articleAddressTag,
articleTitle = articleTitle,
createdAt = Instant.now().epochSecond,
),
)
current[articleAddressTag] = articleHighlights
_highlights.value = current
saveIndex(current)
}
}
suspend fun updateNote(
highlightId: String,
note: String,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val idx = list.indexOfFirst { it.id == highlightId }
if (idx >= 0) {
current[key] =
list.toMutableList().apply {
set(idx, get(idx).copy(note = note))
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
suspend fun removeHighlight(highlightId: String) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val filtered = list.filter { it.id != highlightId }
if (filtered.size != list.size) {
if (filtered.isEmpty()) {
current.remove(key)
} else {
current[key] = filtered
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
suspend fun markPublished(
highlightId: String,
eventId: String,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val idx = list.indexOfFirst { it.id == highlightId }
if (idx >= 0) {
current[key] =
list.toMutableList().apply {
set(idx, get(idx).copy(published = true, eventId = eventId))
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
fun getHighlightsForArticle(addressTag: String): List<HighlightData> = _highlights.value[addressTag] ?: emptyList()
fun getAllHighlights(): Map<String, List<HighlightData>> = _highlights.value
private suspend fun loadIndex() {
mutex.withLock {
if (indexFile.exists()) {
try {
val data: Map<String, List<HighlightData>> = mapper.readValue(indexFile)
_highlights.value = data
} catch (e: Exception) {
// Corrupted file — start fresh
_highlights.value = emptyMap()
}
}
}
}
private fun saveIndex(data: Map<String, List<HighlightData>>) {
try {
val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(data)
val tempFile = File(highlightsDir, "index.json.tmp")
tempFile.writeText(json)
Files.move(
tempFile.toPath(),
indexFile.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
} catch (_: Exception) {
// Best effort — don't crash on write failure
}
}
}
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -49,6 +50,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -56,9 +58,11 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader
import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents
@@ -70,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
@@ -78,12 +83,14 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscriptio
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -115,6 +122,7 @@ fun ArticleReaderScreen(
account: AccountState.LoggedIn?,
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
highlightStore: DesktopHighlightStore? = null,
onBack: () -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
@@ -135,6 +143,18 @@ fun ArticleReaderScreen(
// Zoom level for article text
var zoomLevel by remember { mutableStateOf(1.0f) }
// Coroutine scope for highlight operations
val scope = rememberCoroutineScope()
// Highlight state
val articleHighlights =
highlightStore?.let { store ->
val allHighlights by store.highlights.collectAsState()
allHighlights[addressTag] ?: emptyList()
} ?: emptyList()
var showAnnotationDialog by remember { mutableStateOf<String?>(null) }
val focusRequester =
remember {
androidx.compose.ui.focus
@@ -331,6 +351,8 @@ fun ArticleReaderScreen(
val authorName = authorUser?.toBestDisplayName()
val authorPicture = authorUser?.profilePicture()
val clipboardManager = LocalClipboardManager.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
@@ -359,6 +381,27 @@ fun ArticleReaderScreen(
true
}
Key.H -> {
val selectedText = clipboardManager.getText()?.text
if (!selectedText.isNullOrBlank() && highlightStore != null) {
if (event.isShiftPressed) {
showAnnotationDialog = selectedText
} else {
scope.launch {
highlightStore.addHighlight(
articleAddressTag = addressTag,
text = selectedText,
note = null,
articleTitle = title,
)
}
}
true
} else {
false
}
}
else -> {
false
}
@@ -480,12 +523,15 @@ fun ArticleReaderScreen(
thickness = 1.dp,
)
// Markdown body
RenderMarkdown(
content = content,
onLinkClick = onLinkClick,
fontScale = zoomLevel,
)
// Markdown body with selectable text
SelectionContainer {
RenderMarkdown(
content = content,
onLinkClick = onLinkClick,
fontScale = zoomLevel,
highlightedTexts = articleHighlights.map { it.text },
)
}
Spacer(Modifier.height(32.dp))
@@ -549,4 +595,22 @@ fun ArticleReaderScreen(
}
}
}
showAnnotationDialog?.let { selectedText ->
HighlightAnnotationDialog(
selectedText = selectedText,
onConfirm = { note ->
scope.launch {
highlightStore?.addHighlight(
articleAddressTag = addressTag,
text = selectedText,
note = note,
articleTitle = title,
)
}
showAnnotationDialog = null
},
onDismiss = { showAnnotationDialog = null },
)
}
}
@@ -54,6 +54,7 @@ private val COLUMN_OPTIONS =
DeckColumnType.Search,
DeckColumnType.Reads,
DeckColumnType.Drafts,
DeckColumnType.MyHighlights,
DeckColumnType.Bookmarks,
DeckColumnType.GlobalFeed,
DeckColumnType.MyProfile,
@@ -135,6 +135,7 @@ fun DeckColumnType.icon(): ImageVector =
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
DeckColumnType.MyHighlights -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Profile -> Icons.Default.Person
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Hashtag -> Icons.Default.Tag
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
@@ -105,6 +106,8 @@ fun DeckColumnContainer(
val navState = remember(column.id) { ColumnNavigationState() }
val navStack by navState.stack.collectAsState()
val currentOverlay = navStack.lastOrNull()
val containerScope = rememberCoroutineScope()
val highlightStore = remember { DesktopHighlightStore(containerScope) }
Column(
modifier =
@@ -136,6 +139,7 @@ fun DeckColumnContainer(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
appScope = appScope,
compactMode = true,
onShowComposeDialog = onShowComposeDialog,
@@ -157,6 +161,7 @@ fun DeckColumnContainer(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
@@ -180,6 +185,7 @@ internal fun RootContent(
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
appScope: CoroutineScope,
compactMode: Boolean = false,
onShowComposeDialog: () -> Unit,
@@ -339,6 +345,7 @@ internal fun RootContent(
localCache = localCache,
account = account,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = {},
onNavigateToProfile = onNavigateToProfile,
)
@@ -364,6 +371,13 @@ internal fun RootContent(
)
}
DeckColumnType.MyHighlights -> {
com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen(
highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) },
onNavigateToArticle = onNavigateToArticle,
)
}
is DeckColumnType.Hashtag -> {
SearchScreen(
localCache = localCache,
@@ -385,6 +399,7 @@ internal fun OverlayContent(
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
@@ -431,6 +446,7 @@ internal fun OverlayContent(
localCache = localCache,
account = account,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = onBack,
onNavigateToProfile = onNavigateToProfile,
)
@@ -61,6 +61,8 @@ sealed class DeckColumnType {
object Drafts : DeckColumnType()
object MyHighlights : DeckColumnType()
data class Hashtag(
val tag: String,
) : DeckColumnType()
@@ -80,6 +82,7 @@ sealed class DeckColumnType {
is Article -> "Article"
is Editor -> "New Article"
Drafts -> "Drafts"
MyHighlights -> "Highlights"
is Profile -> "Profile"
is Thread -> "Thread"
is Hashtag -> "#$tag"
@@ -100,6 +103,7 @@ sealed class DeckColumnType {
is Article -> "article"
is Editor -> "editor"
Drafts -> "drafts"
MyHighlights -> "highlights"
is Profile -> "profile"
is Thread -> "thread"
is Hashtag -> "hashtag"
@@ -80,6 +80,7 @@ private val navItems =
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"),
NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"),
NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"),
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun HighlightAnnotationDialog(
selectedText: String,
onConfirm: (note: String) -> Unit,
onDismiss: () -> Unit,
) {
var note by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Highlight Note") },
text = {
Column {
Text(
text = "\"$selectedText\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = note,
onValueChange = { note = it },
label = { Text("Note (optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 5,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(note) }) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag
import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag
import com.vitorpamplona.quartz.utils.TimeUtils
object HighlightPublishAction {
suspend fun publish(
highlightText: String,
articleAddressTag: String,
note: String?,
context: String?,
signer: NostrSigner,
): HighlightEvent {
val tags = mutableListOf<Array<String>>()
tags.add(AltTag.assemble(HighlightEvent.ALT))
tags.add(ATag.assemble(articleAddressTag, null))
// Tag the article author
val parts = articleAddressTag.split(":", limit = 3)
val pubkey = parts.getOrNull(1)
if (!pubkey.isNullOrBlank()) {
tags.add(PTag.assemble(pubkey, null))
}
if (!note.isNullOrBlank()) {
tags.add(CommentTag.assemble(note))
}
if (!context.isNullOrBlank()) {
tags.add(ContextTag.assemble(context))
}
return signer.sign(
createdAt = TimeUtils.now(),
kind = HighlightEvent.KIND,
tags = tags.toTypedArray(),
content = highlightText,
)
}
fun extractContext(
content: String,
highlightText: String,
): String? {
val paragraphs = content.split("\n\n")
return paragraphs.find { it.contains(highlightText) }
}
}
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun MyHighlightsScreen(
highlightStore: DesktopHighlightStore,
onNavigateToArticle: (addressTag: String) -> Unit,
) {
val allHighlights by highlightStore.highlights.collectAsState()
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
Text(
"Highlights",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
if (allHighlights.isEmpty()) {
EmptyState(
title = "No highlights yet",
description = "Select text in an article and choose \"Highlight\" to save passages.",
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
allHighlights.forEach { (addressTag, highlights) ->
val articleTitle = highlights.firstOrNull()?.articleTitle ?: addressTag
stickyHeader(key = addressTag) {
ArticleGroupHeader(
title = articleTitle,
onClick = { onNavigateToArticle(addressTag) },
)
}
items(highlights, key = { it.id }) { highlight ->
HighlightCard(
highlight = highlight,
onDelete = { deleteTarget = highlight },
)
}
}
}
}
}
deleteTarget?.let { highlight ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text("Delete Highlight") },
text = {
Text(
"Delete this highlight? This cannot be undone.",
)
},
confirmButton = {
TextButton(onClick = {
scope.launch { highlightStore.removeHighlight(highlight.id) }
deleteTarget = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text("Cancel")
}
},
)
}
}
@Composable
private fun ArticleGroupHeader(
title: String,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun HighlightCard(
highlight: HighlightData,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "\u201C${highlight.text}\u201D",
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
)
val noteText = highlight.note
if (!noteText.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = noteText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatTimestamp(highlight.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock,
contentDescription = if (highlight.published) "Published" else "Private",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete highlight",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
private fun formatTimestamp(epochSeconds: Long): String =
Instant
.ofEpochSecond(epochSeconds)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))