fix(highlights): shared store, context menu, link rendering, publish, profile tabs

- Share DesktopHighlightStore and DesktopDraftStore at app level instead
  of per-column to fix cross-deck reactivity and draft persistence
- Replace broken clipboard-based highlight creation with
  LocalContextMenuRepresentation that piggybacks on Copy to get selected text
- Render highlights as highlight:// links instead of bold/italic markers
- Add collapsible highlights panel in article reader with edit/delete/publish
- Publish highlights to relays as NIP-84 events via HighlightPublishAction
- Add Reads tab (kind 30023) and Highlights tab (kind 9802) to profile
- Rewrite MarkdownToolbar with MarkdownEditorState for selection-aware
  toggle, Material icons, and keyboard shortcuts (Cmd+B/I/E/K)
- Wire DraftsScreen "New Draft" button to ArticleEditorScreen navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-25 06:59:41 +02:00
parent efa294ea72
commit e037254b39
11 changed files with 1166 additions and 125 deletions
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -609,6 +610,13 @@ fun MainContent(
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
}
val highlightStore = remember { DesktopHighlightStore(appScope) }
val draftStore =
remember {
com.vitorpamplona.amethyst.desktop.service.drafts
.DesktopDraftStore(appScope)
}
// Subscribe to incoming DMs and process into chatroomList
LaunchedEffect(account) {
relayManager.connectedRelays.first { it.isNotEmpty() }
@@ -728,6 +736,8 @@ fun MainContent(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -765,6 +775,8 @@ fun MainContent(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -57,9 +57,9 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownEditorState
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
@@ -91,14 +91,14 @@ fun ArticleEditorScreen(
var bannerUrl by remember { mutableStateOf("") }
var tags by remember { mutableStateOf<List<String>>(emptyList()) }
var slug by remember { mutableStateOf(draftSlug ?: "") }
var contentField by remember { mutableStateOf(TextFieldValue("")) }
val editorState = remember { MarkdownEditorState() }
var publishing by remember { mutableStateOf(false) }
var saveMessage by remember { mutableStateOf<String?>(null) }
var debouncedContent by remember { mutableStateOf("") }
LaunchedEffect(contentField.text) {
LaunchedEffect(editorState.text) {
delay(300)
debouncedContent = contentField.text
debouncedContent = editorState.text
}
// Load existing draft
@@ -114,7 +114,7 @@ fun ArticleEditorScreen(
slug = draftSlug
}
if (body != null) {
contentField = TextFieldValue(body)
editorState.loadContent(body)
}
}
}
@@ -145,7 +145,7 @@ fun ArticleEditorScreen(
scope.launch {
draftStore.saveDraft(
slug = slug,
content = contentField.text,
content = editorState.text,
metadata =
DraftMetadata(
title = title,
@@ -166,7 +166,7 @@ fun ArticleEditorScreen(
val event =
LongFormPublishAction.publish(
title = title,
content = contentField.text,
content = editorState.text,
summary = summary.ifBlank { null },
image = bannerUrl.ifBlank { null },
tags = tags,
@@ -191,13 +191,37 @@ fun ArticleEditorScreen(
Modifier
.fillMaxSize()
.onPreviewKeyEvent { event ->
// Ctrl/Cmd+S to save
if (event.type == KeyEventType.KeyDown &&
event.key == Key.S &&
event.isMetaPressed
) {
saveDraft()
true
if (event.type == KeyEventType.KeyDown && event.isMetaPressed) {
when (event.key) {
Key.S -> {
saveDraft()
true
}
Key.B -> {
editorState.toggleBold()
true
}
Key.I -> {
editorState.toggleItalic()
true
}
Key.E -> {
editorState.toggleInlineCode()
true
}
Key.K -> {
editorState.insertLink()
true
}
else -> {
false
}
}
} else {
false
}
@@ -226,7 +250,7 @@ fun ArticleEditorScreen(
}
Button(
onClick = { publishArticle() },
enabled = !publishing && title.isNotBlank() && contentField.text.isNotBlank(),
enabled = !publishing && title.isNotBlank() && editorState.text.isNotBlank(),
) {
Text(if (publishing) "Publishing..." else "Publish")
}
@@ -249,27 +273,8 @@ fun ArticleEditorScreen(
Spacer(Modifier.height(8.dp))
// Markdown toolbar
MarkdownToolbar(
onInsert = { prefix, suffix ->
val selection = contentField.selection
val text = contentField.text
val newText =
text.substring(0, selection.start) +
prefix +
text.substring(selection.start, selection.end) +
suffix +
text.substring(selection.end)
val newCursorPos = selection.start + prefix.length + (selection.end - selection.start)
contentField =
TextFieldValue(
text = newText,
selection =
androidx.compose.ui.text
.TextRange(newCursorPos),
)
},
)
// Markdown toolbar — selection-aware toggle behavior
MarkdownToolbar(state = editorState)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
@@ -279,9 +284,9 @@ fun ArticleEditorScreen(
) {
// Source editor
TextField(
value = contentField,
value = editorState.value,
onValueChange = {
contentField = it
editorState.onValueChange(it)
saveMessage = null
},
modifier =
@@ -20,6 +20,11 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.ContextMenuRepresentation
import androidx.compose.foundation.ContextMenuState
import androidx.compose.foundation.LocalContextMenuRepresentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -38,6 +43,8 @@ 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
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -45,6 +52,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -58,7 +66,6 @@ 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
@@ -83,6 +90,7 @@ 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.ArticleHighlightsPanel
import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -147,14 +155,13 @@ fun ArticleReaderScreen(
// 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()
// Highlight state — collect outside let to ensure proper Compose subscription
val allHighlights by (highlightStore?.highlights ?: kotlinx.coroutines.flow.MutableStateFlow(emptyMap()))
.collectAsState()
val articleHighlights = allHighlights[addressTag] ?: emptyList()
var showAnnotationDialog by remember { mutableStateOf<String?>(null) }
var showHighlightsPanel by remember { mutableStateOf(false) }
val focusRequester =
remember {
androidx.compose.ui.focus
@@ -166,16 +173,24 @@ fun ArticleReaderScreen(
// Link click handler for markdown
val onLinkClick: (String) -> Unit =
remember {
remember(articleHighlights) {
{ url: String ->
if (url.startsWith("nostr:")) {
// TODO: Parse nostr: URI and navigate
} else {
try {
java.awt.Desktop
.getDesktop()
.browse(java.net.URI(url))
} catch (_: Exception) {
when {
url.startsWith("highlight://") -> {
showHighlightsPanel = true
}
url.startsWith("nostr:") -> {
// TODO: Parse nostr: URI and navigate
}
else -> {
try {
java.awt.Desktop
.getDesktop()
.browse(java.net.URI(url))
} catch (_: Exception) {
}
}
}
}
@@ -381,27 +396,6 @@ 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
}
@@ -523,14 +517,90 @@ fun ArticleReaderScreen(
thickness = 1.dp,
)
// Markdown body with selectable text
SelectionContainer {
RenderMarkdown(
content = content,
onLinkClick = onLinkClick,
fontScale = zoomLevel,
highlightedTexts = articleHighlights.map { it.text },
)
// Collapsible highlights section
if (articleHighlights.isNotEmpty()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
showHighlightsPanel = !showHighlightsPanel
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (showHighlightsPanel) {
Icons.Default.KeyboardArrowDown
} else {
Icons.AutoMirrored.Filled.KeyboardArrowRight
},
contentDescription = "Toggle highlights",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(4.dp))
Text(
text = "${articleHighlights.size} highlight${if (articleHighlights.size != 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
if (showHighlightsPanel && highlightStore != null) {
ArticleHighlightsPanel(
highlights = articleHighlights,
highlightStore = highlightStore,
articleContent = content,
signer = account?.signer,
relayManager = relayManager,
modifier = Modifier.padding(bottom = 16.dp),
)
HorizontalDivider(
modifier = Modifier.padding(bottom = 16.dp),
thickness = 1.dp,
)
}
}
// Markdown body with right-click highlight via context menu
val defaultRepresentation = LocalContextMenuRepresentation.current
val highlightRepresentation =
remember(
defaultRepresentation,
highlightStore,
addressTag,
title,
) {
HighlightContextMenuRepresentation(
delegate = defaultRepresentation,
clipboardManager = clipboardManager,
onHighlight = { text ->
scope.launch {
highlightStore?.addHighlight(
articleAddressTag = addressTag,
text = text,
note = null,
articleTitle = title,
)
}
},
onHighlightWithNote = { text ->
showAnnotationDialog = text
},
)
}
CompositionLocalProvider(
LocalContextMenuRepresentation provides highlightRepresentation,
) {
SelectionContainer {
RenderMarkdown(
content = content,
onLinkClick = onLinkClick,
fontScale = zoomLevel,
highlightedTexts = articleHighlights.map { it.text },
)
}
}
Spacer(Modifier.height(32.dp))
@@ -614,3 +684,53 @@ fun ArticleReaderScreen(
)
}
}
/**
* Custom context menu representation that adds "Highlight" and "Highlight with Note"
* items to the right-click menu inside a SelectionContainer.
*
* How it works: The SelectionContainer provides a "Copy" item that has access to the
* selected text. Our items piggyback on Copy's onClick calling it first to put the
* selected text on the clipboard, then reading the clipboard to get the text.
*/
private class HighlightContextMenuRepresentation(
private val delegate: ContextMenuRepresentation,
private val clipboardManager: androidx.compose.ui.platform.ClipboardManager,
private val onHighlight: (String) -> Unit,
private val onHighlightWithNote: (String) -> Unit,
) : ContextMenuRepresentation {
@Composable
override fun Representation(
state: ContextMenuState,
items: () -> List<ContextMenuItem>,
) {
val extendedItems = {
val original = items()
val copyItem = original.find { it.label == "Copy" }
if (copyItem != null) {
original +
listOf(
ContextMenuItem("Highlight") {
copyItem.onClick()
val text = clipboardManager.getText()?.text
if (!text.isNullOrBlank()) {
onHighlight(text)
}
},
ContextMenuItem("Highlight with Note") {
copyItem.onClick()
val text = clipboardManager.getText()?.text
if (!text.isNullOrBlank()) {
onHighlightWithNote(text)
}
},
)
} else {
original
}
}
delegate.Representation(state, extendedItems)
}
}
@@ -96,7 +96,9 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -118,6 +120,7 @@ fun UserProfileScreen(
onBack: () -> Unit,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
@@ -157,6 +160,8 @@ fun UserProfileScreen(
var selectedTab by remember { mutableStateOf(0) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
val pictureEvents = remember { mutableStateListOf<PictureEvent>() }
val articleEvents = remember { mutableStateListOf<LongTextNoteEvent>() }
val highlightEvents = remember { mutableStateListOf<HighlightEvent>() }
// Follow state
val followState =
@@ -344,6 +349,60 @@ fun UserProfileScreen(
}
}
// Subscribe to long-form articles (kind 30023) for reads tab
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
articleEvents.clear()
SubscriptionConfig(
subId = generateSubId("articles-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(LongTextNoteEvent.KIND),
limit = 50,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent && articleEvents.none { it.id == event.id }) {
articleEvents.add(event)
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to highlight events (kind 9802) for highlights tab
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
highlightEvents.clear()
SubscriptionConfig(
subId = generateSubId("hl-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(HighlightEvent.KIND),
limit = 100,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is HighlightEvent && highlightEvents.none { it.id == event.id }) {
highlightEvents.add(event)
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Scroll state for detecting scroll direction
val listState = rememberLazyListState()
var showFloatingHeader by remember { mutableStateOf(false) }
@@ -629,8 +688,20 @@ fun UserProfileScreen(
Text("Notes", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
Text(
"Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) {
Text("Gallery", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) {
Text(
"Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
}
}
@@ -726,6 +797,38 @@ fun UserProfileScreen(
}
1 -> {
if (articleEvents.isEmpty()) {
item(key = "no-articles") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No long-form articles",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
items(
articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt },
key = { "art-${it.id}" },
) { article ->
LongFormCard(
event = article,
localCache = localCache,
onAuthorClick = { onNavigateToProfile(article.pubKey) },
onClick = {
val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}"
onNavigateToArticle(addressTag)
},
)
}
}
}
2 -> {
item(key = "gallery") {
GalleryTab(
pictureEvents = pictureEvents,
@@ -734,6 +837,33 @@ fun UserProfileScreen(
)
}
}
3 -> {
if (highlightEvents.isEmpty()) {
item(key = "no-highlights") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No published highlights",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
items(
highlightEvents.sortedByDescending { it.createdAt },
key = { "hl-${it.id}" },
) { highlight ->
PublishedHighlightCard(
highlight = highlight,
localCache = localCache,
)
}
}
}
}
}
}
@@ -940,3 +1070,64 @@ private suspend fun updateProfileDisplayName(
onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error"))
}
}
@Composable
private fun PublishedHighlightCard(
highlight: HighlightEvent,
localCache: DesktopLocalCache,
) {
val articleAddress = highlight.inPostAddress()
val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source"
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Quoted highlight text
Text(
text = "\u201C${highlight.quote()}\u201D",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
)
// Note/comment
val comment = highlight.comment()
if (!comment.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
Text(
text = comment,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Context (surrounding paragraph)
val context = highlight.context()
if (!context.isNullOrBlank() && context != highlight.quote()) {
Spacer(Modifier.height(8.dp))
Text(
text = context.take(200) + if (context.length > 200) "\u2026" else "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
Spacer(Modifier.height(8.dp))
// Source article reference
if (articleAddress != null) {
Text(
text = "from ${articleAddress.dTag.ifBlank { "article" }}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -97,6 +97,8 @@ fun DeckColumnContainer(
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: DesktopDraftStore,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -106,8 +108,6 @@ 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 =
@@ -140,6 +140,7 @@ fun DeckColumnContainer(
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
compactMode = true,
onShowComposeDialog = onShowComposeDialog,
@@ -148,6 +149,7 @@ fun DeckColumnContainer(
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
)
if (currentOverlay != null) {
Surface(
@@ -162,11 +164,13 @@ fun DeckColumnContainer(
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() },
)
}
@@ -186,6 +190,7 @@ internal fun RootContent(
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
appScope: CoroutineScope,
compactMode: Boolean = false,
onShowComposeDialog: () -> Unit,
@@ -194,6 +199,7 @@ internal fun RootContent(
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {},
) {
val scope = rememberCoroutineScope()
@@ -290,6 +296,7 @@ internal fun RootContent(
onBack = {},
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback,
)
}
@@ -352,10 +359,9 @@ internal fun RootContent(
}
is DeckColumnType.Editor -> {
val draftStore = remember { DesktopDraftStore(scope) }
ArticleEditorScreen(
draftSlug = columnType.draftSlug,
draftStore = draftStore,
draftStore = draftStore ?: remember { DesktopDraftStore(scope) },
account = account,
relayManager = relayManager,
onBack = {},
@@ -364,10 +370,9 @@ internal fun RootContent(
}
DeckColumnType.Drafts -> {
val draftStore = remember { DesktopDraftStore(scope) }
DraftsScreen(
draftStore = draftStore,
onOpenEditor = {},
draftStore = draftStore ?: remember { DesktopDraftStore(scope) },
onOpenEditor = { slug -> onNavigateToEditor(slug) },
)
}
@@ -400,11 +405,13 @@ internal fun OverlayContent(
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onBack: () -> Unit,
) {
when (screen) {
@@ -419,6 +426,7 @@ internal fun OverlayContent(
onBack = onBack,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback,
)
}
@@ -452,6 +460,18 @@ internal fun OverlayContent(
)
}
is DesktopScreen.Editor -> {
val overlayScope = androidx.compose.runtime.rememberCoroutineScope()
ArticleEditorScreen(
draftSlug = screen.draftSlug,
draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) },
account = account,
relayManager = relayManager,
onBack = onBack,
onPublished = onBack,
)
}
else -> {
androidx.compose.material3.Text(
"Unsupported screen type",
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
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.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
@@ -61,6 +62,8 @@ fun DeckLayout(
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -113,6 +116,8 @@ fun DeckLayout(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
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.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
@@ -99,6 +100,8 @@ fun SinglePaneLayout(
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -173,6 +176,8 @@ fun SinglePaneLayout(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -180,6 +185,7 @@ fun SinglePaneLayout(
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
)
if (currentOverlay != null) {
Surface(
@@ -193,11 +199,14 @@ fun SinglePaneLayout(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() },
)
}
@@ -0,0 +1,208 @@
/*
* 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
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.runtime.Composable
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.unit.dp
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.launch
@Composable
fun ArticleHighlightsPanel(
highlights: List<HighlightData>,
highlightStore: DesktopHighlightStore,
articleContent: String,
signer: NostrSigner?,
relayManager: DesktopRelayConnectionManager?,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
var editTarget by remember { mutableStateOf<HighlightData?>(null) }
Column(modifier = modifier.fillMaxWidth().padding(top = 8.dp)) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
highlights.forEach { highlight ->
HighlightPanelCard(
highlight = highlight,
onDelete = {
scope.launch { highlightStore.removeHighlight(highlight.id) }
},
onEditNote = { editTarget = highlight },
onPublish =
if (!highlight.published && signer != null && relayManager != null) {
{
scope.launch {
val context =
HighlightPublishAction.extractContext(
articleContent,
highlight.text,
)
val event =
HighlightPublishAction.publish(
highlightText = highlight.text,
articleAddressTag = highlight.articleAddressTag,
note = highlight.note,
context = context,
signer = signer,
)
relayManager.broadcastToAll(event)
highlightStore.markPublished(highlight.id, event.id)
}
}
} else {
null
},
)
}
}
}
editTarget?.let { highlight ->
HighlightAnnotationDialog(
selectedText = highlight.text,
onConfirm = { note ->
scope.launch { highlightStore.updateNote(highlight.id, note) }
editTarget = null
},
onDismiss = { editTarget = null },
)
}
}
@Composable
private fun HighlightPanelCard(
highlight: HighlightData,
onDelete: () -> Unit,
onEditNote: () -> Unit,
onPublish: (() -> Unit)?,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
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(8.dp))
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Published status
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,
)
Text(
text = if (highlight.published) "Published" else "Private",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
Spacer(Modifier.weight(1f))
// Publish button
if (onPublish != null) {
IconButton(onClick = onPublish, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Public,
contentDescription = "Publish to relays",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
IconButton(onClick = onEditNote, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit note",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}