diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt new file mode 100644 index 000000000..7c69d6fdb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt @@ -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 + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt index 5e37379ec..4709625b1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt @@ -22,67 +22,163 @@ package com.vitorpamplona.amethyst.commons.compose.editor import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height 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.SmallFloatingActionButton import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** - * Toolbar for inserting markdown formatting at cursor position. - * Each button calls [onInsert] with the prefix and suffix to wrap around selection. + * Markdown toolbar with Material icons, grouped formatting buttons, and active state. + * 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 fun MarkdownToolbar( - onInsert: (prefix: String, suffix: String) -> Unit, + state: MarkdownEditorState, modifier: Modifier = Modifier, ) { Row( 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) { - onInsert("**", "**") + // --- Headings --- + 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") { - onInsert("*", "*") - } - ToolbarButton(label = "H") { - onInsert("## ", "") - } - ToolbarButton(label = "[]") { - onInsert("[", "](url)") - } - ToolbarButton(label = "img") { - onInsert("![alt](", ")") - } - ToolbarButton(label = "<>") { - onInsert("```\n", "\n```") - } - ToolbarButton(label = ">") { - onInsert("> ", "") + 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 }, + ) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(18.dp), + ) } } @Composable private fun ToolbarButton( label: String, - fontWeight: FontWeight = FontWeight.Normal, + active: Boolean, 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 = label, - fontWeight = fontWeight, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSecondaryContainer, + fontSize = 11.sp, + color = contentColor, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt index 8838d3d5c..c31109d4a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt @@ -34,7 +34,18 @@ import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.RichTextStyle 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 fun RenderMarkdown( @@ -50,10 +61,11 @@ fun RenderMarkdown( content } else { var result = content - highlightedTexts.sortedByDescending { it.length }.forEach { text -> + highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text -> val idx = result.indexOf(text) if (idx >= 0) { - result = result.replaceFirst(text, "***$text***") + val escaped = escapeMarkdownInLink(text) + result = result.replaceFirst(text, "[$escaped](highlight://$index)") } } result diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 01abd33d1..dcc369138 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt index 0edc4631c..c0debf9ac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt @@ -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>(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(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 = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index abcd83b09..4bd362e90 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -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(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, + ) { + 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) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 15f0a0de6..c79b4e368 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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(null) } val pictureEvents = remember { mutableStateListOf() } + val articleEvents = remember { mutableStateListOf() } + val highlightEvents = remember { mutableStateListOf() } // 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, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 5bb66c29d..1da11dbd6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -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", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 519d93217..e9f23f353 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 52c64dca2..7afabc73a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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() }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt new file mode 100644 index 000000000..3fec86bff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt @@ -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, + highlightStore: DesktopHighlightStore, + articleContent: String, + signer: NostrSigner?, + relayManager: DesktopRelayConnectionManager?, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + var editTarget by remember { mutableStateOf(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, + ) + } + } + } + } +}