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