feat(reads): Phase 2 — editor, drafts, publish, highlights

- Create DraftLongTextNoteEvent (kind 30024) in quartz with EventFactory registration
- Create LongFormPublishAction for signing + publishing kind 30023 events
- Create split-pane ArticleEditorScreen with live markdown preview
- Create MarkdownToolbar (bold, italic, heading, link, image, code, quote)
- Create MetadataPanel (title, summary, banner, tags, slug)
- Create DesktopDraftStore with JSON index + .md files, atomic writes, slug sanitization
- Create DraftsScreen with draft list, delete, new draft button
- Create HighlightCreator dialog for kind 9802 highlight creation
- Add Drafts nav rail entry and Editor/Drafts screen routing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-24 06:25:39 +02:00
parent 5ef36363cd
commit 4fcbffdb3b
9 changed files with 1376 additions and 0 deletions
@@ -0,0 +1,88 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Toolbar for inserting markdown formatting at cursor position.
* Each button calls [onInsert] with the prefix and suffix to wrap around selection.
*/
@Composable
fun MarkdownToolbar(
onInsert: (prefix: String, suffix: String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
ToolbarButton(label = "B", fontWeight = FontWeight.Bold) {
onInsert("**", "**")
}
ToolbarButton(label = "I") {
onInsert("*", "*")
}
ToolbarButton(label = "H") {
onInsert("## ", "")
}
ToolbarButton(label = "[]") {
onInsert("[", "](url)")
}
ToolbarButton(label = "img") {
onInsert("![alt](", ")")
}
ToolbarButton(label = "<>") {
onInsert("```\n", "\n```")
}
ToolbarButton(label = ">") {
onInsert("> ", "")
}
}
}
@Composable
private fun ToolbarButton(
label: String,
fontWeight: FontWeight = FontWeight.Normal,
onClick: () -> Unit,
) {
FilledTonalIconButton(onClick = onClick) {
Text(
text = label,
fontWeight = fontWeight,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@@ -0,0 +1,158 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp
/**
* Form fields for article metadata: title, summary, banner, tags, slug.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MetadataPanel(
title: String,
onTitleChange: (String) -> Unit,
summary: String,
onSummaryChange: (String) -> Unit,
bannerUrl: String,
onBannerUrlChange: (String) -> Unit,
tags: List<String>,
onTagsChange: (List<String>) -> Unit,
slug: String,
onSlugChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var tagInput by remember { mutableStateOf("") }
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = title,
onValueChange = onTitleChange,
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = summary,
onValueChange = onSummaryChange,
label = { Text("Summary") },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = bannerUrl,
onValueChange = onBannerUrlChange,
label = { Text("Banner Image URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Tags chip input
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = tagInput,
onValueChange = { tagInput = it },
label = { Text("Add tag (Enter to add)") },
singleLine = true,
modifier =
Modifier.weight(1f).onKeyEvent { event ->
if (event.key == Key.Enter && tagInput.isNotBlank()) {
val newTag = tagInput.trim().lowercase()
if (newTag !in tags) {
onTagsChange(tags + newTag)
}
tagInput = ""
true
} else {
false
}
},
)
}
if (tags.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
tags.forEach { tag ->
AssistChip(
onClick = { onTagsChange(tags - tag) },
label = { Text(tag) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = "Remove $tag",
modifier = Modifier.size(16.dp),
)
},
)
}
}
}
}
OutlinedTextField(
value = slug,
onValueChange = onSlugChange,
label = { Text("Slug (d-tag)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Used as the unique identifier for this article") },
)
}
}
@@ -0,0 +1,94 @@
/*
* 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.highlight
import androidx.compose.foundation.layout.Column
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.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
/**
* Dialog for creating a NIP-84 highlight from selected text.
* Shows the highlighted text (read-only) and a comment input field.
*/
@Composable
fun HighlightCreator(
highlightText: String,
onConfirm: (highlightText: String, comment: String) -> Unit,
onDismiss: () -> Unit,
) {
var comment by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create Highlight") },
text = {
Column {
Text(
text = "Selected text:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
text = highlightText,
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 8.dp),
maxLines = 6,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text("Comment (optional)") },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(highlightText, comment) }) {
Text("Highlight")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@@ -0,0 +1,73 @@
/*
* 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.model.nip23LongContent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared action for publishing long-form content (NIP-23 kind 30023).
* Handles title, summary, image, tags, and d-tag for addressable events.
*/
object LongFormPublishAction {
/**
* Publishes a long-form text note (NIP-23 kind 30023).
*
* @param title The article title
* @param content The markdown body content
* @param summary Optional article summary
* @param image Optional banner image URL
* @param tags List of hashtag topics
* @param dTag Unique identifier for this addressable event (slug)
* @param signer The NostrSigner to sign the event
* @return Signed LongTextNoteEvent ready to broadcast
* @throws IllegalStateException if signer is not writeable
*/
suspend fun publish(
title: String,
content: String,
summary: String?,
image: String?,
tags: List<String>,
dTag: String,
signer: NostrSigner,
): LongTextNoteEvent {
if (!signer.isWriteable()) {
throw IllegalStateException("Cannot publish: signer is not writeable")
}
val template =
LongTextNoteEvent.build(
description = content,
title = title,
summary = summary,
image = image,
publishedAt = TimeUtils.now(),
dTag = dTag,
) {
tags.forEach { hashtag(it) }
}
return signer.sign(template)
}
}
@@ -0,0 +1,276 @@
/*
* 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.service.drafts
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermission
import java.time.Instant
data class DraftMetadata(
val title: String = "",
val summary: String? = null,
val image: String? = null,
val tags: List<String> = emptyList(),
val createdAt: String = Instant.now().toString(),
val updatedAt: String = Instant.now().toString(),
val published: Boolean = false,
)
data class DraftEntry(
val slug: String,
val metadata: DraftMetadata,
)
/**
* Local draft storage for long-form articles.
* Stores markdown content as .md files and metadata in index.json.
* Uses atomic writes and restrictive file permissions.
*/
class DesktopDraftStore(
private val scope: CoroutineScope,
) {
private val mapper = jacksonObjectMapper()
private val mutex = Mutex()
private val _drafts = MutableStateFlow<List<DraftEntry>>(emptyList())
val drafts: StateFlow<List<DraftEntry>> = _drafts.asStateFlow()
private val draftsDir: File by lazy {
val dir = File(System.getProperty("user.home"), ".amethyst/drafts")
if (!dir.exists()) {
dir.mkdirs()
setDirPermissions(dir)
}
dir
}
private val indexFile: File get() = File(draftsDir, "index.json")
init {
scope.launch(Dispatchers.IO) { loadIndex() }
}
/**
* Sanitizes a slug to prevent path traversal and ensure safe filenames.
*/
private fun sanitizeSlug(slug: String): String {
val sanitized =
slug
.replace("..", "")
.replace("/", "")
.replace("\\", "")
.replace("\u0000", "")
.trim()
.lowercase()
.replace(Regex("[^a-z0-9_-]"), "-")
.replace(Regex("-+"), "-")
.trimStart('-')
.trimEnd('-')
.take(128)
require(sanitized.isNotEmpty()) { "Slug cannot be empty after sanitization" }
// Validate canonical path stays within drafts dir
val resolved = File(draftsDir, "$sanitized.md").canonicalPath
require(resolved.startsWith(draftsDir.canonicalPath)) {
"Slug resolves outside drafts directory"
}
return sanitized
}
/**
* Generates a slug from a title. Falls back to timestamp if title is blank.
*/
fun slugFromTitle(title: String): String {
if (title.isBlank()) return "untitled-${Instant.now().epochSecond}"
return sanitizeSlug(title)
}
/**
* Saves or updates a draft. Creates content file and updates index atomically.
*/
suspend fun saveDraft(
slug: String,
content: String,
metadata: DraftMetadata,
) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
// Write content file atomically
val contentFile = File(draftsDir, "$safeSlug.md")
atomicWrite(contentFile, content)
// Update index
val index = loadIndexMap().toMutableMap()
index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString())
atomicWriteIndex(index)
// Refresh state
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
/**
* Loads a draft's content by slug.
*/
suspend fun loadContent(slug: String): String? {
val safeSlug = sanitizeSlug(slug)
val file = File(draftsDir, "$safeSlug.md")
return if (file.exists()) file.readText() else null
}
/**
* Loads a draft's metadata by slug.
*/
suspend fun loadMetadata(slug: String): DraftMetadata? {
val safeSlug = sanitizeSlug(slug)
return mutex.withLock {
loadIndexMap()[safeSlug]
}
}
/**
* Deletes a draft by slug.
*/
suspend fun deleteDraft(slug: String) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
File(draftsDir, "$safeSlug.md").delete()
val index = loadIndexMap().toMutableMap()
index.remove(safeSlug)
atomicWriteIndex(index)
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
/**
* Marks a draft as published.
*/
suspend fun markPublished(slug: String) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
val index = loadIndexMap().toMutableMap()
val existing = index[safeSlug] ?: return
index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString())
atomicWriteIndex(index)
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
private fun loadIndexMap(): Map<String, DraftMetadata> {
if (!indexFile.exists()) return emptyMap()
return try {
mapper.readValue(indexFile)
} catch (e: Exception) {
System.err.println("Failed to read drafts index: ${e.message}")
emptyMap()
}
}
private suspend fun loadIndex() {
mutex.withLock {
_drafts.value =
loadIndexMap()
.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
private fun atomicWrite(
file: File,
content: String,
) {
val tempFile = File(file.parentFile, "${file.name}.tmp")
try {
tempFile.writeText(content)
setFilePermissions(tempFile)
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} finally {
if (tempFile.exists()) tempFile.delete()
}
}
private fun atomicWriteIndex(index: Map<String, DraftMetadata>) {
val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(index)
atomicWrite(indexFile, json)
}
private fun setDirPermissions(dir: File) {
try {
Files.setPosixFilePermissions(
dir.toPath(),
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
),
)
} catch (_: UnsupportedOperationException) {
// Windows
}
}
private fun setFilePermissions(file: File) {
try {
Files.setPosixFilePermissions(
file.toPath(),
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
),
)
} catch (_: UnsupportedOperationException) {
// Windows
}
}
}
@@ -0,0 +1,336 @@
/*
* 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
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
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.MarkdownToolbar
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
import com.vitorpamplona.amethyst.commons.compose.markdown.ArticleMediaRenderer
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftMetadata
import kotlinx.coroutines.launch
import java.awt.Desktop
import java.net.URI
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@Composable
fun ArticleEditorScreen(
draftSlug: String?,
draftStore: DesktopDraftStore,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
onBack: () -> Unit,
onPublished: () -> Unit,
) {
val scope = rememberCoroutineScope()
var title by remember { mutableStateOf("") }
var summary by remember { mutableStateOf("") }
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("")) }
var publishing by remember { mutableStateOf(false) }
var saveMessage by remember { mutableStateOf<String?>(null) }
// Load existing draft
LaunchedEffect(draftSlug) {
if (draftSlug != null) {
val meta = draftStore.loadMetadata(draftSlug)
val body = draftStore.loadContent(draftSlug)
if (meta != null) {
title = meta.title
summary = meta.summary ?: ""
bannerUrl = meta.image ?: ""
tags = meta.tags
slug = draftSlug
}
if (body != null) {
contentField = TextFieldValue(body)
}
}
}
// Auto-generate slug from title if creating a new draft
LaunchedEffect(title) {
if (draftSlug == null && title.isNotBlank()) {
slug = draftStore.slugFromTitle(title)
}
}
val mediaRenderer =
remember {
object : ArticleMediaRenderer {
@Composable
override fun renderImage(
url: String,
alt: String?,
) {
// Simple text placeholder for editor preview
Text(
"[Image: ${alt ?: url}]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
override fun onLinkClick(url: String) {
try {
Desktop.getDesktop().browse(URI(url))
} catch (_: Exception) {
// Ignore unsupported or malformed URLs
}
}
}
}
fun saveDraft() {
if (slug.isBlank()) return
scope.launch {
draftStore.saveDraft(
slug = slug,
content = contentField.text,
metadata =
DraftMetadata(
title = title,
summary = summary.ifBlank { null },
image = bannerUrl.ifBlank { null },
tags = tags,
),
)
saveMessage = "Saved"
}
}
fun publishArticle() {
if (publishing) return
publishing = true
scope.launch {
try {
val event =
LongFormPublishAction.publish(
title = title,
content = contentField.text,
summary = summary.ifBlank { null },
image = bannerUrl.ifBlank { null },
tags = tags,
dTag = slug.ifBlank { draftStore.slugFromTitle(title) },
signer = account.signer,
)
relayManager.send(event)
draftStore.markPublished(slug)
onPublished()
} catch (e: Exception) {
saveMessage = "Publish failed: ${e.message}"
} finally {
publishing = false
}
}
}
Column(
modifier =
Modifier
.fillMaxSize()
.onPreviewKeyEvent { event ->
// Ctrl/Cmd+S to save
if (event.type == KeyEventType.KeyDown &&
event.key == Key.S &&
(if (isMacOS) event.isMetaPressed else event.isMetaPressed)
) {
saveDraft()
true
} else {
false
}
},
) {
// Top bar
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) {
Text("Back")
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
saveMessage?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
OutlinedButton(onClick = { saveDraft() }) {
Text("Save")
}
Button(
onClick = { publishArticle() },
enabled = !publishing && title.isNotBlank() && contentField.text.isNotBlank(),
) {
Text(if (publishing) "Publishing..." else "Publish")
}
}
}
// Metadata panel (collapsible)
MetadataPanel(
title = title,
onTitleChange = { title = it },
summary = summary,
onSummaryChange = { summary = it },
bannerUrl = bannerUrl,
onBannerUrlChange = { bannerUrl = it },
tags = tags,
onTagsChange = { tags = it },
slug = slug,
onSlugChange = { slug = it },
)
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),
)
},
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Split pane: source left, preview right
Row(
modifier = Modifier.fillMaxSize().weight(1f),
) {
// Source editor
TextField(
value = contentField,
onValueChange = {
contentField = it
saveMessage = null
},
modifier =
Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = 4.dp),
textStyle =
TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface,
),
placeholder = { Text("Write your article in markdown...") },
)
VerticalDivider()
// Preview
SelectionContainer {
Column(
modifier =
Modifier
.weight(1f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(modifier = Modifier.widthIn(max = 680.dp)) {
if (contentField.text.isNotBlank()) {
RenderMarkdown(
content = contentField.text,
mediaRenderer = mediaRenderer,
)
} else {
Text(
"Preview will appear here...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
@@ -0,0 +1,188 @@
/*
* 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
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry
import kotlinx.coroutines.launch
@Composable
fun DraftsScreen(
draftStore: DesktopDraftStore,
onOpenEditor: (slug: String?) -> Unit,
) {
val drafts by draftStore.drafts.collectAsState()
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Drafts",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Button(onClick = { onOpenEditor(null) }) {
Icon(Icons.Default.Add, contentDescription = null)
Text("New Draft", modifier = Modifier.padding(start = 4.dp))
}
}
Spacer(Modifier.height(16.dp))
if (drafts.isEmpty()) {
Text(
"No drafts yet. Click \"New Draft\" to start writing.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(drafts, key = { it.slug }) { entry ->
DraftCard(
entry = entry,
onClick = { onOpenEditor(entry.slug) },
onDelete = { deleteTarget = entry },
)
}
}
}
}
// Delete confirmation dialog
deleteTarget?.let { entry ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text("Delete Draft") },
text = {
Text(
"Delete \"${entry.metadata.title.ifBlank { entry.slug }}\"? This cannot be undone.",
)
},
confirmButton = {
TextButton(onClick = {
scope.launch { draftStore.deleteDraft(entry.slug) }
deleteTarget = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text("Cancel")
}
},
)
}
}
@Composable
private fun DraftCard(
entry: DraftEntry,
onClick: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.metadata.title.ifBlank { "Untitled" },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = entry.metadata.updatedAt.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (entry.metadata.published) {
Text(
text = "Published",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete draft",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -0,0 +1,161 @@
/*
* 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.quartz.nip23LongContent
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
import com.vitorpamplona.quartz.nip19Bech32.addressHints
import com.vitorpamplona.quartz.nip19Bech32.addressIds
import com.vitorpamplona.quartz.nip19Bech32.eventHints
import com.vitorpamplona.quartz.nip19Bech32.eventIds
import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints
import com.vitorpamplona.quartz.nip19Bech32.pubKeys
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* NIP-23 Draft Long-form Content (kind 30024).
* Same structure as LongTextNoteEvent (kind 30023) but for unpublished drafts.
*/
@Immutable
class DraftLongTextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressableEvent,
EventHintProvider,
PubKeyHintProvider,
AddressHintProvider,
PublishedAtProvider,
RootScope,
SearchableEvent {
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content
override fun eventHints(): List<EventIdHint> {
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
val nip19Hints = citedNIP19().eventHints()
return qHints + nip19Hints
}
override fun linkedEventIds(): List<HexKey> {
val qHints = tags.mapNotNull(QTag::parseEventId)
val nip19Hints = citedNIP19().eventIds()
return qHints + nip19Hints
}
override fun addressHints(): List<AddressHint> {
val qHints = tags.mapNotNull(QTag::parseAddressAsHint)
val nip19Hints = citedNIP19().addressHints()
return qHints + nip19Hints
}
override fun linkedAddressIds(): List<String> {
val qHints = tags.mapNotNull(QTag::parseAddressId)
val nip19Hints = citedNIP19().addressIds()
return qHints + nip19Hints
}
override fun pubKeyHints(): List<PubKeyHint> {
val pHints = tags.mapNotNull(PTag::parseAsHint)
val nip19Hints = citedNIP19().pubKeyHints()
return pHints + nip19Hints
}
override fun linkedPubKeys(): List<HexKey> {
val pHints = tags.mapNotNull(PTag::parseKey)
val nip19Hints = citedNIP19().pubKeys()
return pHints + nip19Hints
}
override fun dTag() = tags.dTag()
override fun address() = Address(kind, pubKey, dTag())
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
fun topics() = hashtags()
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
override fun publishedAt(): Long? {
val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
if (publishedAt == null) return null
return if (publishedAt <= createdAt) publishedAt else null
}
companion object {
const val KIND = 30024
@OptIn(ExperimentalUuidApi::class)
fun build(
description: String,
title: String,
summary: String? = null,
image: String? = null,
publishedAt: Long? = null,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DraftLongTextNoteEvent>.() -> Unit = {},
) = eventTemplate(KIND, description, createdAt) {
dTag(dTag)
alt("Draft blog post: $title")
addUnique(TitleTag.assemble(title))
summary?.let { addUnique(SummaryTag.assemble(it)) }
image?.let { addUnique(ImageTag.assemble(it)) }
publishedAt?.let { addUnique(PublishedAtTag.assemble(it)) }
initializer()
}
}
}
@@ -58,6 +58,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.DraftLongTextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
@@ -272,6 +273,7 @@ class EventFactory {
LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
DraftLongTextNoteEvent.KIND -> DraftLongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig)
MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig)
MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig)