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:
+363
@@ -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) + "" + 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) + "" + 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
-32
@@ -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("")
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -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,
|
||||||
|
|||||||
+43
-38
@@ -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 =
|
||||||
|
|||||||
+165
-45
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+191
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+27
-7
@@ -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,
|
||||||
|
|||||||
+9
@@ -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() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+208
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user