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:
@@ -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) })
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-6
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -54,6 +54,7 @@ private val COLUMN_OPTIONS =
|
||||
DeckColumnType.Search,
|
||||
DeckColumnType.Reads,
|
||||
DeckColumnType.Drafts,
|
||||
DeckColumnType.MyHighlights,
|
||||
DeckColumnType.Bookmarks,
|
||||
DeckColumnType.GlobalFeed,
|
||||
DeckColumnType.MyProfile,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+16
@@ -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,
|
||||
)
|
||||
|
||||
+4
@@ -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"
|
||||
|
||||
+1
@@ -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"),
|
||||
|
||||
+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"))
|
||||
Reference in New Issue
Block a user