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:
+88
@@ -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("")
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
@@ -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") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+73
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+276
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+336
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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.GenericRepostEvent
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.DraftLongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||||
@@ -272,6 +273,7 @@ class EventFactory {
|
|||||||
LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LongTextNoteEvent.KIND -> LongTextNoteEvent(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)
|
MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig)
|
MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig)
|
MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
|||||||
Reference in New Issue
Block a user