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
@@ -0,0 +1,363 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.editor
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
/**
* State holder for a markdown editor with selection-aware formatting.
*
* Solves the focus/selection bug: toolbar buttons steal focus from TextField,
* collapsing the selection. We cache the last known selection on every value
* change, and toolbar operations use the cached selection.
*/
class MarkdownEditorState(
initial: String = "",
) {
var value by mutableStateOf(TextFieldValue(initial))
private set
/** Cached selection — updated on every onValueChange, survives focus loss. */
var lastSelection: TextRange = TextRange.Zero
private set
fun onValueChange(newValue: TextFieldValue) {
value = newValue
// Only cache non-zero selections (focus loss sends collapsed range)
if (newValue.selection.length > 0 || lastSelection == TextRange.Zero) {
lastSelection = newValue.selection
}
// Also cache cursor position when no selection
if (newValue.selection.collapsed) {
lastSelection = newValue.selection
}
}
fun loadContent(content: String) {
value = TextFieldValue(content)
lastSelection = TextRange.Zero
}
val text: String get() = value.text
// --- Active state detection (uses current value.selection for display) ---
val isBold: Boolean
get() = isWrapped("**", "**")
val isItalic: Boolean
get() = isWrappedItalic()
val isStrikethrough: Boolean
get() = isWrapped("~~", "~~")
val isInlineCode: Boolean
get() = isWrapped("`", "`")
val isBlockquote: Boolean
get() = isLinePrefix("> ")
val isUnorderedList: Boolean
get() = isLinePrefix("- ")
val isOrderedList: Boolean
get() {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
val line = text.substring(lineStart)
return line.matches(Regex("^\\d+\\.\\s.*"))
}
val isTaskList: Boolean
get() = isLinePrefix("- [ ] ") || isLinePrefix("- [x] ")
val headingLevel: Int?
get() {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
val line = text.substring(lineStart)
return when {
line.startsWith("### ") -> 3
line.startsWith("## ") -> 2
line.startsWith("# ") -> 1
else -> null
}
}
// --- Formatting operations (use lastSelection to survive focus loss) ---
fun toggleBold() {
applyToggleWrap("**", "**")
}
fun toggleItalic() {
applyToggleWrapItalic()
}
fun toggleStrikethrough() {
applyToggleWrap("~~", "~~")
}
fun toggleInlineCode() {
applyToggleWrap("`", "`")
}
fun setHeading(level: Int?) {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
val line = text.substring(lineStart)
// Remove existing heading prefix
val stripped =
when {
line.startsWith("### ") -> line.removePrefix("### ")
line.startsWith("## ") -> line.removePrefix("## ")
line.startsWith("# ") -> line.removePrefix("# ")
else -> line
}
val oldPrefixLen =
when {
line.startsWith("### ") -> 4
line.startsWith("## ") -> 3
line.startsWith("# ") -> 2
else -> 0
}
val newPrefix =
when (level) {
1 -> "# "
2 -> "## "
3 -> "### "
else -> ""
}
val lineEnd = text.indexOf('\n', lineStart).let { if (it == -1) text.length else it }
val newText = text.substring(0, lineStart) + newPrefix + stripped + text.substring(lineEnd)
val shift = newPrefix.length - oldPrefixLen
value =
TextFieldValue(
text = newText,
selection = TextRange((sel.min + shift).coerceAtLeast(lineStart), (sel.max + shift).coerceAtLeast(lineStart)),
)
lastSelection = value.selection
}
fun toggleBlockquote() {
applyToggleLinePrefix("> ")
}
fun toggleUnorderedList() {
applyToggleLinePrefix("- ")
}
fun toggleOrderedList() {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
val line = text.substring(lineStart)
if (line.matches(Regex("^\\d+\\.\\s.*"))) {
// Remove ordered list prefix
val prefixEnd = line.indexOf(". ") + 2
val newText = text.substring(0, lineStart) + line.substring(prefixEnd) + text.substring(lineStart + line.indexOf('\n').let { if (it == -1) line.length else it })
value =
TextFieldValue(
text = text.substring(0, lineStart) + line.substring(prefixEnd),
selection = TextRange((sel.min - prefixEnd).coerceAtLeast(lineStart)),
)
} else {
applyToggleLinePrefix("1. ")
}
lastSelection = value.selection
}
fun toggleTaskList() {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
when {
text.startsWith("- [ ] ", lineStart) -> {
// Remove task list prefix
val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
val shift = 6
value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
lastSelection = value.selection
}
text.startsWith("- [x] ", lineStart) -> {
val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
val shift = 6
value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
lastSelection = value.selection
}
else -> {
applyToggleLinePrefix("- [ ] ")
}
}
}
fun toggleCodeBlock() {
applyToggleWrap("```\n", "\n```")
}
fun insertHorizontalRule() {
val sel = lastSelection
val insert = "\n---\n"
val newText = text.substring(0, sel.min) + insert + text.substring(sel.max)
value = TextFieldValue(text = newText, selection = TextRange(sel.min + insert.length))
lastSelection = value.selection
}
fun insertLink() {
val sel = lastSelection
val selected = text.substring(sel.min, sel.max)
if (selected.isNotEmpty()) {
val newText = text.substring(0, sel.min) + "[$selected](url)" + text.substring(sel.max)
val urlStart = sel.min + selected.length + 3
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
} else {
val newText = text.substring(0, sel.min) + "[](url)" + text.substring(sel.min)
value = TextFieldValue(text = newText, selection = TextRange(sel.min + 1))
}
lastSelection = value.selection
}
fun insertImage() {
val sel = lastSelection
val selected = text.substring(sel.min, sel.max)
if (selected.isNotEmpty()) {
val newText = text.substring(0, sel.min) + "![$selected](url)" + text.substring(sel.max)
val urlStart = sel.min + selected.length + 4
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
} else {
val newText = text.substring(0, sel.min) + "![alt](url)" + text.substring(sel.min)
val urlStart = sel.min + 7
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
}
lastSelection = value.selection
}
// --- Private helpers ---
private fun isWrapped(
prefix: String,
suffix: String,
): Boolean {
val sel = value.selection
val start = sel.min
val end = sel.max
return start >= prefix.length &&
end + suffix.length <= text.length &&
text.substring(start - prefix.length, start) == prefix &&
text.substring(end, end + suffix.length) == suffix
}
private fun isWrappedItalic(): Boolean {
val sel = value.selection
val start = sel.min
val end = sel.max
if (start < 1 || end + 1 > text.length) return false
if (text[start - 1] != '*' || text[end] != '*') return false
val hasBoldBefore = start >= 2 && text[start - 2] == '*'
val hasBoldAfter = end + 1 < text.length && text[end + 1] == '*'
return !hasBoldBefore && !hasBoldAfter
}
private fun isLinePrefix(prefix: String): Boolean {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
return text.startsWith(prefix, lineStart)
}
private fun applyToggleWrap(
prefix: String,
suffix: String,
) {
val sel = lastSelection
val start = sel.min
val end = sel.max
val wrapped =
start >= prefix.length &&
end + suffix.length <= text.length &&
text.substring(start - prefix.length, start) == prefix &&
text.substring(end, end + suffix.length) == suffix
value =
if (wrapped) {
val newText =
text.substring(0, start - prefix.length) +
text.substring(start, end) +
text.substring(end + suffix.length)
TextFieldValue(newText, TextRange(start - prefix.length, end - prefix.length))
} else if (start == end) {
val newText = text.substring(0, start) + prefix + suffix + text.substring(start)
TextFieldValue(newText, TextRange(start + prefix.length))
} else {
val newText = text.substring(0, start) + prefix + text.substring(start, end) + suffix + text.substring(end)
TextFieldValue(newText, TextRange(start + prefix.length, end + prefix.length))
}
lastSelection = value.selection
}
private fun applyToggleWrapItalic() {
val sel = lastSelection
val start = sel.min
val end = sel.max
val isItalic =
start >= 1 &&
end + 1 <= text.length &&
text[start - 1] == '*' &&
text[end] == '*' &&
!(start >= 2 && text[start - 2] == '*') &&
!(end + 1 < text.length && text[end + 1] == '*')
if (isItalic) {
val newText = text.substring(0, start - 1) + text.substring(start, end) + text.substring(end + 1)
value = TextFieldValue(newText, TextRange(start - 1, end - 1))
} else {
applyToggleWrap("*", "*")
return
}
lastSelection = value.selection
}
private fun applyToggleLinePrefix(prefix: String) {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
value =
if (text.startsWith(prefix, lineStart)) {
val newText = text.substring(0, lineStart) + text.substring(lineStart + prefix.length)
val shift = prefix.length
TextFieldValue(newText, TextRange((sel.min - shift).coerceAtLeast(lineStart), (sel.max - shift).coerceAtLeast(lineStart)))
} else {
val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart)
TextFieldValue(newText, TextRange(sel.min + prefix.length, sel.max + prefix.length))
}
lastSelection = value.selection
}
}
@@ -22,67 +22,163 @@ package com.vitorpamplona.amethyst.commons.compose.editor
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.FormatBold
import androidx.compose.material.icons.filled.FormatItalic
import androidx.compose.material.icons.filled.FormatListBulleted
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.FormatStrikethrough
import androidx.compose.material.icons.filled.HorizontalRule
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/** /**
* Toolbar for inserting markdown formatting at cursor position. * Markdown toolbar with Material icons, grouped formatting buttons, and active state.
* Each button calls [onInsert] with the prefix and suffix to wrap around selection. * Uses [MarkdownEditorState] for selection-aware toggle behavior.
*
* Buttons use `focusProperties { canFocus = false }` to prevent stealing focus
* from the editor TextField, preserving the user's text selection.
*/ */
@Composable @Composable
fun MarkdownToolbar( fun MarkdownToolbar(
onInsert: (prefix: String, suffix: String) -> Unit, state: MarkdownEditorState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier.padding(vertical = 4.dp), modifier = modifier.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
ToolbarButton(label = "B", fontWeight = FontWeight.Bold) { // --- Headings ---
onInsert("**", "**") ToolbarButton(label = "H1", active = state.headingLevel == 1) { state.setHeading(if (state.headingLevel == 1) null else 1) }
ToolbarButton(label = "H2", active = state.headingLevel == 2) { state.setHeading(if (state.headingLevel == 2) null else 2) }
ToolbarButton(label = "H3", active = state.headingLevel == 3) { state.setHeading(if (state.headingLevel == 3) null else 3) }
Separator()
// --- Inline formatting ---
ToolbarIconButton(Icons.Default.FormatBold, "Bold", state.isBold) { state.toggleBold() }
ToolbarIconButton(Icons.Default.FormatItalic, "Italic", state.isItalic) { state.toggleItalic() }
ToolbarIconButton(Icons.Default.FormatStrikethrough, "Strikethrough", state.isStrikethrough) { state.toggleStrikethrough() }
ToolbarIconButton(Icons.Default.Code, "Inline code", state.isInlineCode) { state.toggleInlineCode() }
Separator()
// --- Lists ---
ToolbarIconButton(Icons.Default.FormatListBulleted, "Bullet list", state.isUnorderedList) { state.toggleUnorderedList() }
ToolbarIconButton(Icons.Default.FormatListNumbered, "Numbered list", state.isOrderedList) { state.toggleOrderedList() }
ToolbarIconButton(Icons.Default.Checklist, "Task list", state.isTaskList) { state.toggleTaskList() }
Separator()
// --- Block elements ---
ToolbarIconButton(Icons.Default.FormatQuote, "Blockquote", state.isBlockquote) { state.toggleBlockquote() }
ToolbarButton(label = "```", active = false) { state.toggleCodeBlock() }
ToolbarIconButton(Icons.Default.HorizontalRule, "Horizontal rule", false) { state.insertHorizontalRule() }
Separator()
// --- Insert ---
ToolbarIconButton(Icons.Default.Link, "Link", false) { state.insertLink() }
ToolbarIconButton(Icons.Default.Image, "Image", false) { state.insertImage() }
}
}
@Composable
private fun Separator() {
VerticalDivider(
modifier = Modifier.height(24.dp).padding(horizontal = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
@Composable
private fun ToolbarIconButton(
icon: ImageVector,
contentDescription: String,
active: Boolean,
onClick: () -> Unit,
) {
val containerColor =
if (active) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
} }
ToolbarButton(label = "I") { val contentColor =
onInsert("*", "*") if (active) {
} MaterialTheme.colorScheme.onPrimary
ToolbarButton(label = "H") { } else {
onInsert("## ", "") MaterialTheme.colorScheme.onSurfaceVariant
}
ToolbarButton(label = "[]") {
onInsert("[", "](url)")
}
ToolbarButton(label = "img") {
onInsert("![alt](", ")")
}
ToolbarButton(label = "<>") {
onInsert("```\n", "\n```")
}
ToolbarButton(label = ">") {
onInsert("> ", "")
} }
SmallFloatingActionButton(
onClick = onClick,
containerColor = containerColor,
contentColor = contentColor,
modifier =
Modifier
.size(32.dp)
.focusProperties { canFocus = false },
) {
Icon(
icon,
contentDescription = contentDescription,
modifier = Modifier.size(18.dp),
)
} }
} }
@Composable @Composable
private fun ToolbarButton( private fun ToolbarButton(
label: String, label: String,
fontWeight: FontWeight = FontWeight.Normal, active: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
FilledTonalIconButton(onClick = onClick) { val containerColor =
if (active) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor =
if (active) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
SmallFloatingActionButton(
onClick = onClick,
containerColor = containerColor,
contentColor = contentColor,
modifier =
Modifier
.size(32.dp)
.focusProperties { canFocus = false },
) {
Text( Text(
text = label, text = label,
fontWeight = fontWeight, fontSize = 11.sp,
fontFamily = FontFamily.Monospace, color = contentColor,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} }
} }
@@ -34,7 +34,18 @@ import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.material3.RichText
private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning") private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning", "highlight")
/**
* Escapes markdown special characters inside highlighted text so it doesn't
* break the markdown parser when wrapped in a link.
*/
private fun escapeMarkdownInLink(text: String): String =
text
.replace("[", "\\[")
.replace("]", "\\]")
.replace("(", "\\(")
.replace(")", "\\)")
@Composable @Composable
fun RenderMarkdown( fun RenderMarkdown(
@@ -50,10 +61,11 @@ fun RenderMarkdown(
content content
} else { } else {
var result = content var result = content
highlightedTexts.sortedByDescending { it.length }.forEach { text -> highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text ->
val idx = result.indexOf(text) val idx = result.indexOf(text)
if (idx >= 0) { if (idx >= 0) {
result = result.replaceFirst(text, "***$text***") val escaped = escapeMarkdownInLink(text)
result = result.replaceFirst(text, "[$escaped](highlight://$index)")
} }
} }
result result
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -609,6 +610,13 @@ fun MainContent(
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) 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 // Subscribe to incoming DMs and process into chatroomList
LaunchedEffect(account) { LaunchedEffect(account) {
relayManager.connectedRelays.first { it.isNotEmpty() } relayManager.connectedRelays.first { it.isNotEmpty() }
@@ -728,6 +736,8 @@ fun MainContent(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
@@ -765,6 +775,8 @@ fun MainContent(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
@@ -57,9 +57,9 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily 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.dp
import androidx.compose.ui.unit.sp 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.MarkdownToolbar
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
@@ -91,14 +91,14 @@ fun ArticleEditorScreen(
var bannerUrl by remember { mutableStateOf("") } var bannerUrl by remember { mutableStateOf("") }
var tags by remember { mutableStateOf<List<String>>(emptyList()) } var tags by remember { mutableStateOf<List<String>>(emptyList()) }
var slug by remember { mutableStateOf(draftSlug ?: "") } var slug by remember { mutableStateOf(draftSlug ?: "") }
var contentField by remember { mutableStateOf(TextFieldValue("")) } val editorState = remember { MarkdownEditorState() }
var publishing by remember { mutableStateOf(false) } var publishing by remember { mutableStateOf(false) }
var saveMessage by remember { mutableStateOf<String?>(null) } var saveMessage by remember { mutableStateOf<String?>(null) }
var debouncedContent by remember { mutableStateOf("") } var debouncedContent by remember { mutableStateOf("") }
LaunchedEffect(contentField.text) { LaunchedEffect(editorState.text) {
delay(300) delay(300)
debouncedContent = contentField.text debouncedContent = editorState.text
} }
// Load existing draft // Load existing draft
@@ -114,7 +114,7 @@ fun ArticleEditorScreen(
slug = draftSlug slug = draftSlug
} }
if (body != null) { if (body != null) {
contentField = TextFieldValue(body) editorState.loadContent(body)
} }
} }
} }
@@ -145,7 +145,7 @@ fun ArticleEditorScreen(
scope.launch { scope.launch {
draftStore.saveDraft( draftStore.saveDraft(
slug = slug, slug = slug,
content = contentField.text, content = editorState.text,
metadata = metadata =
DraftMetadata( DraftMetadata(
title = title, title = title,
@@ -166,7 +166,7 @@ fun ArticleEditorScreen(
val event = val event =
LongFormPublishAction.publish( LongFormPublishAction.publish(
title = title, title = title,
content = contentField.text, content = editorState.text,
summary = summary.ifBlank { null }, summary = summary.ifBlank { null },
image = bannerUrl.ifBlank { null }, image = bannerUrl.ifBlank { null },
tags = tags, tags = tags,
@@ -191,13 +191,37 @@ fun ArticleEditorScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
// Ctrl/Cmd+S to save if (event.type == KeyEventType.KeyDown && event.isMetaPressed) {
if (event.type == KeyEventType.KeyDown && when (event.key) {
event.key == Key.S && Key.S -> {
event.isMetaPressed saveDraft()
) { true
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 { } else {
false false
} }
@@ -226,7 +250,7 @@ fun ArticleEditorScreen(
} }
Button( Button(
onClick = { publishArticle() }, onClick = { publishArticle() },
enabled = !publishing && title.isNotBlank() && contentField.text.isNotBlank(), enabled = !publishing && title.isNotBlank() && editorState.text.isNotBlank(),
) { ) {
Text(if (publishing) "Publishing..." else "Publish") Text(if (publishing) "Publishing..." else "Publish")
} }
@@ -249,27 +273,8 @@ fun ArticleEditorScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Markdown toolbar // Markdown toolbar — selection-aware toggle behavior
MarkdownToolbar( MarkdownToolbar(state = editorState)
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),
)
},
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
@@ -279,9 +284,9 @@ fun ArticleEditorScreen(
) { ) {
// Source editor // Source editor
TextField( TextField(
value = contentField, value = editorState.value,
onValueChange = { onValueChange = {
contentField = it editorState.onValueChange(it)
saveMessage = null saveMessage = null
}, },
modifier = modifier =
@@ -20,6 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui 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.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -45,6 +52,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
@@ -83,6 +90,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscriptio
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.highlights.ArticleHighlightsPanel
import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -147,14 +155,13 @@ fun ArticleReaderScreen(
// Coroutine scope for highlight operations // Coroutine scope for highlight operations
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Highlight state // Highlight state — collect outside let to ensure proper Compose subscription
val articleHighlights = val allHighlights by (highlightStore?.highlights ?: kotlinx.coroutines.flow.MutableStateFlow(emptyMap()))
highlightStore?.let { store -> .collectAsState()
val allHighlights by store.highlights.collectAsState() val articleHighlights = allHighlights[addressTag] ?: emptyList()
allHighlights[addressTag] ?: emptyList()
} ?: emptyList()
var showAnnotationDialog by remember { mutableStateOf<String?>(null) } var showAnnotationDialog by remember { mutableStateOf<String?>(null) }
var showHighlightsPanel by remember { mutableStateOf(false) }
val focusRequester = val focusRequester =
remember { remember {
androidx.compose.ui.focus androidx.compose.ui.focus
@@ -166,16 +173,24 @@ fun ArticleReaderScreen(
// Link click handler for markdown // Link click handler for markdown
val onLinkClick: (String) -> Unit = val onLinkClick: (String) -> Unit =
remember { remember(articleHighlights) {
{ url: String -> { url: String ->
if (url.startsWith("nostr:")) { when {
// TODO: Parse nostr: URI and navigate url.startsWith("highlight://") -> {
} else { showHighlightsPanel = true
try { }
java.awt.Desktop
.getDesktop() url.startsWith("nostr:") -> {
.browse(java.net.URI(url)) // TODO: Parse nostr: URI and navigate
} catch (_: Exception) { }
else -> {
try {
java.awt.Desktop
.getDesktop()
.browse(java.net.URI(url))
} catch (_: Exception) {
}
} }
} }
} }
@@ -381,27 +396,6 @@ fun ArticleReaderScreen(
true true
} }
Key.H -> {
val selectedText = clipboardManager.getText()?.text
if (!selectedText.isNullOrBlank() && highlightStore != null) {
if (event.isShiftPressed) {
showAnnotationDialog = selectedText
} else {
scope.launch {
highlightStore.addHighlight(
articleAddressTag = addressTag,
text = selectedText,
note = null,
articleTitle = title,
)
}
}
true
} else {
false
}
}
else -> { else -> {
false false
} }
@@ -523,14 +517,90 @@ fun ArticleReaderScreen(
thickness = 1.dp, thickness = 1.dp,
) )
// Markdown body with selectable text // Collapsible highlights section
SelectionContainer { if (articleHighlights.isNotEmpty()) {
RenderMarkdown( Row(
content = content, modifier =
onLinkClick = onLinkClick, Modifier
fontScale = zoomLevel, .fillMaxWidth()
highlightedTexts = articleHighlights.map { it.text }, .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)) 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.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -118,6 +120,7 @@ fun UserProfileScreen(
onBack: () -> Unit, onBack: () -> Unit,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
@@ -157,6 +160,8 @@ fun UserProfileScreen(
var selectedTab by remember { mutableStateOf(0) } var selectedTab by remember { mutableStateOf(0) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) } var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
val pictureEvents = remember { mutableStateListOf<PictureEvent>() } val pictureEvents = remember { mutableStateListOf<PictureEvent>() }
val articleEvents = remember { mutableStateListOf<LongTextNoteEvent>() }
val highlightEvents = remember { mutableStateListOf<HighlightEvent>() }
// Follow state // Follow state
val followState = 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 // Scroll state for detecting scroll direction
val listState = rememberLazyListState() val listState = rememberLazyListState()
var showFloatingHeader by remember { mutableStateOf(false) } var showFloatingHeader by remember { mutableStateOf(false) }
@@ -629,8 +688,20 @@ fun UserProfileScreen(
Text("Notes", modifier = Modifier.padding(12.dp)) Text("Notes", modifier = Modifier.padding(12.dp))
} }
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { 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)) 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 -> { 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") { item(key = "gallery") {
GalleryTab( GalleryTab(
pictureEvents = pictureEvents, 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")) 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, iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: DesktopDraftStore,
appScope: CoroutineScope, appScope: CoroutineScope,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -106,8 +108,6 @@ fun DeckColumnContainer(
val navState = remember(column.id) { ColumnNavigationState() } val navState = remember(column.id) { ColumnNavigationState() }
val navStack by navState.stack.collectAsState() val navStack by navState.stack.collectAsState()
val currentOverlay = navStack.lastOrNull() val currentOverlay = navStack.lastOrNull()
val containerScope = rememberCoroutineScope()
val highlightStore = remember { DesktopHighlightStore(containerScope) }
Column( Column(
modifier = modifier =
@@ -140,6 +140,7 @@ fun DeckColumnContainer(
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore, highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
compactMode = true, compactMode = true,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
@@ -148,6 +149,7 @@ fun DeckColumnContainer(
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
) )
if (currentOverlay != null) { if (currentOverlay != null) {
Surface( Surface(
@@ -162,11 +164,13 @@ fun DeckColumnContainer(
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore, highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() }, onBack = { navState.pop() },
) )
} }
@@ -186,6 +190,7 @@ internal fun RootContent(
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null, highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
appScope: CoroutineScope, appScope: CoroutineScope,
compactMode: Boolean = false, compactMode: Boolean = false,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
@@ -194,6 +199,7 @@ internal fun RootContent(
onNavigateToProfile: (String) -> Unit, onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit, onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -290,6 +296,7 @@ internal fun RootContent(
onBack = {}, onBack = {},
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
) )
} }
@@ -352,10 +359,9 @@ internal fun RootContent(
} }
is DeckColumnType.Editor -> { is DeckColumnType.Editor -> {
val draftStore = remember { DesktopDraftStore(scope) }
ArticleEditorScreen( ArticleEditorScreen(
draftSlug = columnType.draftSlug, draftSlug = columnType.draftSlug,
draftStore = draftStore, draftStore = draftStore ?: remember { DesktopDraftStore(scope) },
account = account, account = account,
relayManager = relayManager, relayManager = relayManager,
onBack = {}, onBack = {},
@@ -364,10 +370,9 @@ internal fun RootContent(
} }
DeckColumnType.Drafts -> { DeckColumnType.Drafts -> {
val draftStore = remember { DesktopDraftStore(scope) }
DraftsScreen( DraftsScreen(
draftStore = draftStore, draftStore = draftStore ?: remember { DesktopDraftStore(scope) },
onOpenEditor = {}, onOpenEditor = { slug -> onNavigateToEditor(slug) },
) )
} }
@@ -400,11 +405,13 @@ internal fun OverlayContent(
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null, highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit, onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit, onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit, onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
when (screen) { when (screen) {
@@ -419,6 +426,7 @@ internal fun OverlayContent(
onBack = onBack, onBack = onBack,
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback, 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 -> { else -> {
androidx.compose.material3.Text( androidx.compose.material3.Text(
"Unsupported screen type", "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.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
@@ -61,6 +62,8 @@ fun DeckLayout(
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope, appScope: CoroutineScope,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -113,6 +116,8 @@ fun DeckLayout(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, 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.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
@@ -99,6 +100,8 @@ fun SinglePaneLayout(
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope, appScope: CoroutineScope,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -173,6 +176,8 @@ fun SinglePaneLayout(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
@@ -180,6 +185,7 @@ fun SinglePaneLayout(
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
) )
if (currentOverlay != null) { if (currentOverlay != null) {
Surface( Surface(
@@ -193,11 +199,14 @@ fun SinglePaneLayout(
account = account, account = account,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() }, 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,
)
}
}
}
}
}