From 4fcbffdb3bf776bb057e31dafe653a842ddf2185 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 24 Mar 2026 06:25:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(reads):=20Phase=202=20=E2=80=94=20editor,?= =?UTF-8?q?=20drafts,=20publish,=20highlights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../commons/compose/editor/MarkdownToolbar.kt | 88 +++++ .../commons/compose/editor/MetadataPanel.kt | 158 ++++++++ .../compose/highlight/HighlightCreator.kt | 94 +++++ .../nip23LongContent/LongFormPublishAction.kt | 73 ++++ .../service/drafts/DesktopDraftStore.kt | 276 ++++++++++++++ .../desktop/ui/ArticleEditorScreen.kt | 336 ++++++++++++++++++ .../amethyst/desktop/ui/DraftsScreen.kt | 188 ++++++++++ .../DraftLongTextNoteEvent.kt | 161 +++++++++ .../quartz/utils/EventFactory.kt | 2 + 9 files changed, 1376 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/highlight/HighlightCreator.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/DraftLongTextNoteEvent.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt new file mode 100644 index 000000000..5e37379ec --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt @@ -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, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt new file mode 100644 index 000000000..6e5682006 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt @@ -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, + onTagsChange: (List) -> 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") }, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/highlight/HighlightCreator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/highlight/HighlightCreator.kt new file mode 100644 index 000000000..0991e16aa --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/highlight/HighlightCreator.kt @@ -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") + } + }, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt new file mode 100644 index 000000000..2e0e828d7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt @@ -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, + 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) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt new file mode 100644 index 000000000..15e247821 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt @@ -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 = 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>(emptyList()) + val drafts: StateFlow> = _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 { + 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) { + 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 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt new file mode 100644 index 000000000..a793b8e55 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt @@ -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>(emptyList()) } + var slug by remember { mutableStateOf(draftSlug ?: "") } + var contentField by remember { mutableStateOf(TextFieldValue("")) } + var publishing by remember { mutableStateOf(false) } + var saveMessage by remember { mutableStateOf(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, + ) + } + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt new file mode 100644 index 000000000..64d383860 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -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(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, + ) + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/DraftLongTextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/DraftLongTextNoteEvent.kt new file mode 100644 index 000000000..f76ba3bc7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/DraftLongTextNoteEvent.kt @@ -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>, + 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 { + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + return qHints + nip19Hints + } + + override fun linkedEventIds(): List { + val qHints = tags.mapNotNull(QTag::parseEventId) + val nip19Hints = citedNIP19().eventIds() + return qHints + nip19Hints + } + + override fun addressHints(): List { + val qHints = tags.mapNotNull(QTag::parseAddressAsHint) + val nip19Hints = citedNIP19().addressHints() + return qHints + nip19Hints + } + + override fun linkedAddressIds(): List { + val qHints = tags.mapNotNull(QTag::parseAddressId) + val nip19Hints = citedNIP19().addressIds() + return qHints + nip19Hints + } + + override fun pubKeyHints(): List { + val pHints = tags.mapNotNull(PTag::parseAsHint) + val nip19Hints = citedNIP19().pubKeyHints() + return pHints + nip19Hints + } + + override fun linkedPubKeys(): List { + 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.() -> 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() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 0239da5a8..07110b734 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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)