refactor(reads): address all review findings — security, performance, simplicity
P1 Critical: - Add 300ms debounce on editor preview to prevent AST re-parse per keystroke - Hoist Regex constants in ReadingTimeCalculator and TableOfContents - Add URI scheme allowlist to editor onLinkClick (was missing vs reader) - Add MAX_CONTENT_BYTES (100KB) validation before publish P2 Important: - Delete MarkdownSpikeScreen (278 LOC spike artifact) - Delete HighlightCreator (95 LOC, zero callers — YAGNI) - Delete DraftLongTextNoteEvent (162 LOC, no consumers — YAGNI) - Replace ArticleMediaRenderer interface with onLinkClick lambda - Extract inline subscription to FilterBuilders.longFormByAddress() - Replace SimpleDateFormat with thread-safe DateTimeFormatter - Remove dead placeholders (bookmark button, reactions row, unused param) - Cache draft index in memory to avoid redundant disk reads - Add TODO for publish-before-relay-ack issue P3 Nice-to-have: - Remove timestamp from subscription subId for stability - Remove redundant slug ".." removal (regex handles it) - Add metadata field length limits (title 256, summary 1024) - Fix identical isMacOS branches in editor - Validate image URLs (http/https only) in ArticleHeader Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+6
-2
@@ -53,7 +53,9 @@ fun ArticleHeader(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
// Banner image
|
// Banner image
|
||||||
if (!bannerUrl.isNullOrBlank()) {
|
if (!bannerUrl.isNullOrBlank() &&
|
||||||
|
(bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://"))
|
||||||
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = bannerUrl,
|
model = bannerUrl,
|
||||||
contentDescription = "Article banner",
|
contentDescription = "Article banner",
|
||||||
@@ -79,7 +81,9 @@ fun ArticleHeader(
|
|||||||
|
|
||||||
// Author + metadata row
|
// Author + metadata row
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (!authorPicture.isNullOrBlank()) {
|
if (!authorPicture.isNullOrBlank() &&
|
||||||
|
(authorPicture.startsWith("https://") || authorPicture.startsWith("http://"))
|
||||||
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = authorPicture,
|
model = authorPicture,
|
||||||
contentDescription = "Author",
|
contentDescription = "Author",
|
||||||
|
|||||||
+5
-2
@@ -37,6 +37,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)")
|
||||||
|
private val TRAILING_HASHES_REGEX = Regex("#+$")
|
||||||
|
|
||||||
data class TocEntry(
|
data class TocEntry(
|
||||||
val level: Int,
|
val level: Int,
|
||||||
val text: String,
|
val text: String,
|
||||||
@@ -60,13 +63,13 @@ fun extractTableOfContents(markdown: String): List<TocEntry> {
|
|||||||
}
|
}
|
||||||
if (inCodeBlock) return@forEach
|
if (inCodeBlock) return@forEach
|
||||||
|
|
||||||
val match = Regex("^(#{1,6})\\s+(.+)").find(trimmed)
|
val match = HEADING_REGEX.find(trimmed)
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
val level = match.groupValues[1].length
|
val level = match.groupValues[1].length
|
||||||
val text =
|
val text =
|
||||||
match.groupValues[2]
|
match.groupValues[2]
|
||||||
.trim()
|
.trim()
|
||||||
.replace(Regex("#+$"), "")
|
.replace(TRAILING_HASHES_REGEX, "")
|
||||||
.trim()
|
.trim()
|
||||||
if (text.isNotEmpty() && level <= 3) {
|
if (text.isNotEmpty() && level <= 3) {
|
||||||
entries.add(TocEntry(level = level, text = text, index = headingIndex))
|
entries.add(TocEntry(level = level, text = text, index = headingIndex))
|
||||||
|
|||||||
+4
-2
@@ -74,18 +74,20 @@ fun MetadataPanel(
|
|||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = onTitleChange,
|
onValueChange = { if (it.length <= 256) onTitleChange(it) },
|
||||||
label = { Text("Title") },
|
label = { Text("Title") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
supportingText = { Text("${title.length}/256") },
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = summary,
|
value = summary,
|
||||||
onValueChange = onSummaryChange,
|
onValueChange = { if (it.length <= 1024) onSummaryChange(it) },
|
||||||
label = { Text("Summary") },
|
label = { Text("Summary") },
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
supportingText = { Text("${summary.length}/1024") },
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|||||||
-94
@@ -1,94 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.markdown
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for platform-specific media rendering in markdown articles.
|
|
||||||
* Desktop and Android provide different implementations for image loading and navigation.
|
|
||||||
*/
|
|
||||||
interface ArticleMediaRenderer {
|
|
||||||
@Composable
|
|
||||||
fun renderImage(
|
|
||||||
url: String,
|
|
||||||
alt: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun onLinkClick(url: String)
|
|
||||||
}
|
|
||||||
+3
-3
@@ -37,7 +37,7 @@ private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning")
|
|||||||
@Composable
|
@Composable
|
||||||
fun RenderMarkdown(
|
fun RenderMarkdown(
|
||||||
content: String,
|
content: String,
|
||||||
mediaRenderer: ArticleMediaRenderer,
|
onLinkClick: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val astNode =
|
val astNode =
|
||||||
@@ -46,12 +46,12 @@ fun RenderMarkdown(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uriHandler =
|
val uriHandler =
|
||||||
remember(mediaRenderer) {
|
remember(onLinkClick) {
|
||||||
object : UriHandler {
|
object : UriHandler {
|
||||||
override fun openUri(uri: String) {
|
override fun openUri(uri: String) {
|
||||||
val scheme = uri.substringBefore(":").lowercase()
|
val scheme = uri.substringBefore(":").lowercase()
|
||||||
if (scheme in ALLOWED_SCHEMES) {
|
if (scheme in ALLOWED_SCHEMES) {
|
||||||
mediaRenderer.onLinkClick(uri)
|
onLinkClick(uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
* Handles title, summary, image, tags, and d-tag for addressable events.
|
* Handles title, summary, image, tags, and d-tag for addressable events.
|
||||||
*/
|
*/
|
||||||
object LongFormPublishAction {
|
object LongFormPublishAction {
|
||||||
|
private const val MAX_CONTENT_BYTES = 100_000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes a long-form text note (NIP-23 kind 30023).
|
* Publishes a long-form text note (NIP-23 kind 30023).
|
||||||
*
|
*
|
||||||
@@ -56,6 +58,10 @@ object LongFormPublishAction {
|
|||||||
throw IllegalStateException("Cannot publish: signer is not writeable")
|
throw IllegalStateException("Cannot publish: signer is not writeable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (content.toByteArray().size > MAX_CONTENT_BYTES) {
|
||||||
|
throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
|
||||||
|
}
|
||||||
|
|
||||||
val template =
|
val template =
|
||||||
LongTextNoteEvent.build(
|
LongTextNoteEvent.build(
|
||||||
description = content,
|
description = content,
|
||||||
|
|||||||
+16
-8
@@ -32,6 +32,14 @@ object ReadingTimeCalculator {
|
|||||||
private const val PROSE_WPM = 238.0
|
private const val PROSE_WPM = 238.0
|
||||||
private const val CODE_WPM = 80.0
|
private const val CODE_WPM = 80.0
|
||||||
|
|
||||||
|
private val WHITESPACE_REGEX = "\\s+".toRegex()
|
||||||
|
private val IMAGE_REGEX = Regex("!\\[.*?]\\(.*?\\)")
|
||||||
|
private val IMAGE_STRIP_REGEX = Regex("!\\[.*?]\\(.*?\\)")
|
||||||
|
private val LINK_REGEX = Regex("\\[([^]]*)]\\([^)]*\\)")
|
||||||
|
private val FORMATTING_REGEX = Regex("[*_~`#>]")
|
||||||
|
private val LIST_MARKER_REGEX = Regex("^-\\s+|^\\d+\\.\\s+")
|
||||||
|
private val HORIZONTAL_RULE_REGEX = Regex("^---+$|^\\*\\*\\*+$")
|
||||||
|
|
||||||
fun calculate(markdownContent: String): Int {
|
fun calculate(markdownContent: String): Int {
|
||||||
var proseWords = 0
|
var proseWords = 0
|
||||||
var codeWords = 0
|
var codeWords = 0
|
||||||
@@ -47,24 +55,24 @@ object ReadingTimeCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
codeWords += trimmed.split("\\s+".toRegex()).count { it.isNotBlank() }
|
codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() }
|
||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count images
|
// Count images
|
||||||
val imageMatches = Regex("!\\[.*?]\\(.*?\\)").findAll(trimmed)
|
val imageMatches = IMAGE_REGEX.findAll(trimmed)
|
||||||
imageCount += imageMatches.count()
|
imageCount += imageMatches.count()
|
||||||
|
|
||||||
// Strip markdown syntax for word counting
|
// Strip markdown syntax for word counting
|
||||||
val stripped =
|
val stripped =
|
||||||
trimmed
|
trimmed
|
||||||
.replace(Regex("!\\[.*?]\\(.*?\\)"), "") // images
|
.replace(IMAGE_STRIP_REGEX, "") // images
|
||||||
.replace(Regex("\\[([^]]*)]\\([^)]*\\)"), "$1") // links -> text only
|
.replace(LINK_REGEX, "$1") // links -> text only
|
||||||
.replace(Regex("[*_~`#>]"), "") // formatting
|
.replace(FORMATTING_REGEX, "") // formatting
|
||||||
.replace(Regex("^-\\s+|^\\d+\\.\\s+"), "") // list markers
|
.replace(LIST_MARKER_REGEX, "") // list markers
|
||||||
.replace(Regex("^---+$|^\\*\\*\\*+$"), "") // horizontal rules
|
.replace(HORIZONTAL_RULE_REGEX, "") // horizontal rules
|
||||||
|
|
||||||
proseWords += stripped.split("\\s+".toRegex()).count { it.isNotBlank() }
|
proseWords += stripped.split(WHITESPACE_REGEX).count { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Medium's image time decay: 12 sec first, -1 each, min 3
|
// Medium's image time decay: 12 sec first, -1 each, min 3
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ sealed class DesktopScreen {
|
|||||||
val addressTag: String,
|
val addressTag: String,
|
||||||
) : DesktopScreen()
|
) : DesktopScreen()
|
||||||
|
|
||||||
|
data class Editor(
|
||||||
|
val draftSlug: String? = null,
|
||||||
|
) : DesktopScreen()
|
||||||
|
|
||||||
|
data object Drafts : DesktopScreen()
|
||||||
|
|
||||||
data object Settings : DesktopScreen()
|
data object Settings : DesktopScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-13
@@ -61,6 +61,7 @@ class DesktopDraftStore(
|
|||||||
) {
|
) {
|
||||||
private val mapper = jacksonObjectMapper()
|
private val mapper = jacksonObjectMapper()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
private var cachedIndex: MutableMap<String, DraftMetadata>? = null
|
||||||
|
|
||||||
private val _drafts = MutableStateFlow<List<DraftEntry>>(emptyList())
|
private val _drafts = MutableStateFlow<List<DraftEntry>>(emptyList())
|
||||||
val drafts: StateFlow<List<DraftEntry>> = _drafts.asStateFlow()
|
val drafts: StateFlow<List<DraftEntry>> = _drafts.asStateFlow()
|
||||||
@@ -77,7 +78,10 @@ class DesktopDraftStore(
|
|||||||
private val indexFile: File get() = File(draftsDir, "index.json")
|
private val indexFile: File get() = File(draftsDir, "index.json")
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch(Dispatchers.IO) { loadIndex() }
|
scope.launch(Dispatchers.IO) {
|
||||||
|
cachedIndex = null
|
||||||
|
loadIndex()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,7 +90,6 @@ class DesktopDraftStore(
|
|||||||
private fun sanitizeSlug(slug: String): String {
|
private fun sanitizeSlug(slug: String): String {
|
||||||
val sanitized =
|
val sanitized =
|
||||||
slug
|
slug
|
||||||
.replace("..", "")
|
|
||||||
.replace("/", "")
|
.replace("/", "")
|
||||||
.replace("\\", "")
|
.replace("\\", "")
|
||||||
.replace("\u0000", "")
|
.replace("\u0000", "")
|
||||||
@@ -133,9 +136,10 @@ class DesktopDraftStore(
|
|||||||
atomicWrite(contentFile, content)
|
atomicWrite(contentFile, content)
|
||||||
|
|
||||||
// Update index
|
// Update index
|
||||||
val index = loadIndexMap().toMutableMap()
|
val index = loadIndexMap()
|
||||||
index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString())
|
index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString())
|
||||||
atomicWriteIndex(index)
|
atomicWriteIndex(index)
|
||||||
|
cachedIndex = index
|
||||||
|
|
||||||
// Refresh state
|
// Refresh state
|
||||||
_drafts.value =
|
_drafts.value =
|
||||||
@@ -172,9 +176,10 @@ class DesktopDraftStore(
|
|||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
File(draftsDir, "$safeSlug.md").delete()
|
File(draftsDir, "$safeSlug.md").delete()
|
||||||
|
|
||||||
val index = loadIndexMap().toMutableMap()
|
val index = loadIndexMap()
|
||||||
index.remove(safeSlug)
|
index.remove(safeSlug)
|
||||||
atomicWriteIndex(index)
|
atomicWriteIndex(index)
|
||||||
|
cachedIndex = index
|
||||||
|
|
||||||
_drafts.value =
|
_drafts.value =
|
||||||
index.entries
|
index.entries
|
||||||
@@ -189,10 +194,11 @@ class DesktopDraftStore(
|
|||||||
suspend fun markPublished(slug: String) {
|
suspend fun markPublished(slug: String) {
|
||||||
val safeSlug = sanitizeSlug(slug)
|
val safeSlug = sanitizeSlug(slug)
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val index = loadIndexMap().toMutableMap()
|
val index = loadIndexMap()
|
||||||
val existing = index[safeSlug] ?: return
|
val existing = index[safeSlug] ?: return
|
||||||
index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString())
|
index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString())
|
||||||
atomicWriteIndex(index)
|
atomicWriteIndex(index)
|
||||||
|
cachedIndex = index
|
||||||
|
|
||||||
_drafts.value =
|
_drafts.value =
|
||||||
index.entries
|
index.entries
|
||||||
@@ -201,14 +207,21 @@ class DesktopDraftStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadIndexMap(): Map<String, DraftMetadata> {
|
private fun loadIndexMap(): MutableMap<String, DraftMetadata> {
|
||||||
if (!indexFile.exists()) return emptyMap()
|
cachedIndex?.let { return it }
|
||||||
return try {
|
val loaded: MutableMap<String, DraftMetadata> =
|
||||||
mapper.readValue(indexFile)
|
if (!indexFile.exists()) {
|
||||||
} catch (e: Exception) {
|
mutableMapOf()
|
||||||
System.err.println("Failed to read drafts index: ${e.message}")
|
} else {
|
||||||
emptyMap()
|
try {
|
||||||
}
|
mapper.readValue<MutableMap<String, DraftMetadata>>(indexFile)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
System.err.println("Failed to read drafts index: ${e.message}")
|
||||||
|
mutableMapOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedIndex = loaded
|
||||||
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadIndex() {
|
private suspend fun loadIndex() {
|
||||||
|
|||||||
+18
@@ -576,6 +576,24 @@ object FilterBuilders {
|
|||||||
until = until,
|
until = until,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a filter for a specific long-form article by author pubkey and d-tag.
|
||||||
|
*
|
||||||
|
* @param pubkey Author public key (hex-encoded, 64 chars)
|
||||||
|
* @param dTag The d-tag (slug) identifier for the addressable event
|
||||||
|
* @return Filter for a specific long-form article
|
||||||
|
*/
|
||||||
|
fun longFormByAddress(
|
||||||
|
pubkey: String,
|
||||||
|
dTag: String,
|
||||||
|
): Filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(30023), // LongTextNoteEvent.KIND
|
||||||
|
authors = listOf(pubkey),
|
||||||
|
tags = mapOf("d" to listOf(dTag)),
|
||||||
|
limit = 1,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a filter for long-form content (kind 30023) from specific authors.
|
* Creates a filter for long-form content (kind 30023) from specific authors.
|
||||||
*
|
*
|
||||||
|
|||||||
+18
-22
@@ -62,18 +62,18 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar
|
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar
|
||||||
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
|
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
|
||||||
import com.vitorpamplona.amethyst.commons.compose.markdown.ArticleMediaRenderer
|
|
||||||
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
|
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction
|
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftMetadata
|
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftMetadata
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.awt.Desktop
|
import java.awt.Desktop
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning")
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArticleEditorScreen(
|
fun ArticleEditorScreen(
|
||||||
@@ -94,6 +94,12 @@ fun ArticleEditorScreen(
|
|||||||
var contentField by remember { mutableStateOf(TextFieldValue("")) }
|
var contentField by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
var publishing by remember { mutableStateOf(false) }
|
var publishing by remember { mutableStateOf(false) }
|
||||||
var saveMessage by remember { mutableStateOf<String?>(null) }
|
var saveMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var debouncedContent by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(contentField.text) {
|
||||||
|
delay(300)
|
||||||
|
debouncedContent = contentField.text
|
||||||
|
}
|
||||||
|
|
||||||
// Load existing draft
|
// Load existing draft
|
||||||
LaunchedEffect(draftSlug) {
|
LaunchedEffect(draftSlug) {
|
||||||
@@ -120,23 +126,11 @@ fun ArticleEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaRenderer =
|
val onLinkClick: (String) -> Unit =
|
||||||
remember {
|
remember {
|
||||||
object : ArticleMediaRenderer {
|
{ url: String ->
|
||||||
@Composable
|
val scheme = url.substringBefore(":").lowercase()
|
||||||
override fun renderImage(
|
if (scheme in ALLOWED_SCHEMES) {
|
||||||
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 {
|
try {
|
||||||
Desktop.getDesktop().browse(URI(url))
|
Desktop.getDesktop().browse(URI(url))
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -179,6 +173,8 @@ fun ArticleEditorScreen(
|
|||||||
dTag = slug.ifBlank { draftStore.slugFromTitle(title) },
|
dTag = slug.ifBlank { draftStore.slugFromTitle(title) },
|
||||||
signer = account.signer,
|
signer = account.signer,
|
||||||
)
|
)
|
||||||
|
// TODO: send() is fire-and-forget; markPublished runs before relay ack.
|
||||||
|
// Consider waiting for relay OK response before marking as published.
|
||||||
relayManager.send(event)
|
relayManager.send(event)
|
||||||
draftStore.markPublished(slug)
|
draftStore.markPublished(slug)
|
||||||
onPublished()
|
onPublished()
|
||||||
@@ -198,7 +194,7 @@ fun ArticleEditorScreen(
|
|||||||
// Ctrl/Cmd+S to save
|
// Ctrl/Cmd+S to save
|
||||||
if (event.type == KeyEventType.KeyDown &&
|
if (event.type == KeyEventType.KeyDown &&
|
||||||
event.key == Key.S &&
|
event.key == Key.S &&
|
||||||
(if (isMacOS) event.isMetaPressed else event.isMetaPressed)
|
event.isMetaPressed
|
||||||
) {
|
) {
|
||||||
saveDraft()
|
saveDraft()
|
||||||
true
|
true
|
||||||
@@ -316,10 +312,10 @@ fun ArticleEditorScreen(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.widthIn(max = 680.dp)) {
|
Column(modifier = Modifier.widthIn(max = 680.dp)) {
|
||||||
if (contentField.text.isNotBlank()) {
|
if (debouncedContent.isNotBlank()) {
|
||||||
RenderMarkdown(
|
RenderMarkdown(
|
||||||
content = contentField.text,
|
content = debouncedContent,
|
||||||
mediaRenderer = mediaRenderer,
|
onLinkClick = onLinkClick,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+24
-68
@@ -36,7 +36,6 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.BookmarkBorder
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -56,7 +55,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader
|
import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader
|
||||||
import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents
|
import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents
|
||||||
import com.vitorpamplona.amethyst.commons.compose.article.extractTableOfContents
|
import com.vitorpamplona.amethyst.commons.compose.article.extractTableOfContents
|
||||||
import com.vitorpamplona.amethyst.commons.compose.markdown.ArticleMediaRenderer
|
|
||||||
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
|
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.ReadingTimeCalculator
|
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.ReadingTimeCalculator
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||||
@@ -65,15 +63,16 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState
|
|||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import java.text.SimpleDateFormat
|
import java.time.Instant
|
||||||
import java.util.Date
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private val articleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
|
private val articleDateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a NIP-23 address tag in the format "30023:pubkey:d-tag".
|
* Parses a NIP-23 address tag in the format "30023:pubkey:d-tag".
|
||||||
@@ -100,7 +99,6 @@ fun ArticleReaderScreen(
|
|||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
onNavigateToThread: (String) -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
@@ -118,32 +116,18 @@ fun ArticleReaderScreen(
|
|||||||
// Active ToC entry tracking (placeholder — no scroll-position-based tracking yet)
|
// Active ToC entry tracking (placeholder — no scroll-position-based tracking yet)
|
||||||
var activeTocIndex by remember { mutableStateOf<Int?>(null) }
|
var activeTocIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
// Media renderer for markdown
|
// Link click handler for markdown
|
||||||
val mediaRenderer =
|
val onLinkClick: (String) -> Unit =
|
||||||
remember {
|
remember {
|
||||||
object : ArticleMediaRenderer {
|
{ url: String ->
|
||||||
@Composable
|
if (url.startsWith("nostr:")) {
|
||||||
override fun renderImage(
|
// TODO: Parse nostr: URI and navigate
|
||||||
url: String,
|
} else {
|
||||||
alt: String?,
|
try {
|
||||||
) {
|
java.awt.Desktop
|
||||||
// TODO: Replace with image loading library (coil3 not available on desktop)
|
.getDesktop()
|
||||||
Text(
|
.browse(java.net.URI(url))
|
||||||
text = "[Image: ${alt ?: url}]",
|
} catch (_: Exception) {
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLinkClick(url: String) {
|
|
||||||
if (url.startsWith("nostr:")) {
|
|
||||||
// TODO: Parse nostr: URI and navigate
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
java.awt.Desktop
|
|
||||||
.getDesktop()
|
|
||||||
.browse(java.net.URI(url))
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,16 +147,8 @@ fun ArticleReaderScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
SubscriptionConfig(
|
SubscriptionConfig(
|
||||||
subId = "article-${addressTag.hashCode()}-${System.currentTimeMillis()}",
|
subId = "article-${addressTag.hashCode()}",
|
||||||
filters =
|
filters = listOf(FilterBuilders.longFormByAddress(pubkey, dTag)),
|
||||||
listOf(
|
|
||||||
Filter(
|
|
||||||
kinds = listOf(LongTextNoteEvent.KIND),
|
|
||||||
authors = listOf(pubkey),
|
|
||||||
tags = mapOf("d" to listOf(dTag)),
|
|
||||||
limit = 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
relays = configuredRelays,
|
relays = configuredRelays,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, _, _ ->
|
||||||
if (event is LongTextNoteEvent) {
|
if (event is LongTextNoteEvent) {
|
||||||
@@ -201,7 +177,11 @@ fun ArticleReaderScreen(
|
|||||||
val publishedAt =
|
val publishedAt =
|
||||||
article?.let { art ->
|
article?.let { art ->
|
||||||
val ts = art.publishedAt() ?: art.createdAt
|
val ts = art.publishedAt() ?: art.createdAt
|
||||||
articleDateFormat.format(Date(ts * 1000))
|
Instant
|
||||||
|
.ofEpochSecond(ts)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toLocalDate()
|
||||||
|
.format(articleDateFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Author info from local cache
|
// Author info from local cache
|
||||||
@@ -231,15 +211,6 @@ fun ArticleReaderScreen(
|
|||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bookmark placeholder
|
|
||||||
IconButton(onClick = { /* TODO: bookmark */ }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.BookmarkBorder,
|
|
||||||
contentDescription = "Bookmark",
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading / error / content states
|
// Loading / error / content states
|
||||||
@@ -326,7 +297,7 @@ fun ArticleReaderScreen(
|
|||||||
// Markdown body
|
// Markdown body
|
||||||
RenderMarkdown(
|
RenderMarkdown(
|
||||||
content = content,
|
content = content,
|
||||||
mediaRenderer = mediaRenderer,
|
onLinkClick = onLinkClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
@@ -350,21 +321,6 @@ fun ArticleReaderScreen(
|
|||||||
|
|
||||||
HorizontalDivider(thickness = 1.dp)
|
HorizontalDivider(thickness = 1.dp)
|
||||||
|
|
||||||
// Reactions placeholder row
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Reactions coming soon",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(48.dp))
|
Spacer(Modifier.height(48.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,19 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscr
|
|||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import java.text.SimpleDateFormat
|
import java.time.Instant
|
||||||
import java.util.Date
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
|
private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
|
||||||
|
|
||||||
private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000))
|
private fun formatDate(timestamp: Long): String =
|
||||||
|
Instant
|
||||||
|
.ofEpochSecond(timestamp)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toLocalDate()
|
||||||
|
.format(dateFormat)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card displaying long-form content (NIP-23) with title, summary, and image.
|
* Card displaying long-form content (NIP-23) with title, summary, and image.
|
||||||
|
|||||||
+3
@@ -132,6 +132,9 @@ fun DeckColumnType.icon(): ImageVector =
|
|||||||
DeckColumnType.MyProfile -> Icons.Default.Person
|
DeckColumnType.MyProfile -> Icons.Default.Person
|
||||||
DeckColumnType.Chess -> Icons.Default.Extension
|
DeckColumnType.Chess -> Icons.Default.Extension
|
||||||
DeckColumnType.Settings -> Icons.Default.Settings
|
DeckColumnType.Settings -> Icons.Default.Settings
|
||||||
|
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
|
||||||
|
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
|
||||||
|
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
|
||||||
is DeckColumnType.Profile -> Icons.Default.Person
|
is DeckColumnType.Profile -> Icons.Default.Person
|
||||||
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
|
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
|
||||||
is DeckColumnType.Hashtag -> Icons.Default.Tag
|
is DeckColumnType.Hashtag -> Icons.Default.Tag
|
||||||
|
|||||||
+51
-1
@@ -46,7 +46,11 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
|||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
||||||
|
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen
|
import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen
|
||||||
@@ -139,6 +143,7 @@ fun DeckColumnContainer(
|
|||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
|
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||||
)
|
)
|
||||||
if (currentOverlay != null) {
|
if (currentOverlay != null) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -182,6 +187,7 @@ internal fun RootContent(
|
|||||||
onZapFeedback: (ZapFeedback) -> Unit,
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
onNavigateToProfile: (String) -> Unit,
|
onNavigateToProfile: (String) -> Unit,
|
||||||
onNavigateToThread: (String) -> Unit,
|
onNavigateToThread: (String) -> Unit,
|
||||||
|
onNavigateToArticle: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -232,7 +238,7 @@ internal fun RootContent(
|
|||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
account = account,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToArticle = onNavigateToThread,
|
onNavigateToArticle = onNavigateToArticle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +329,38 @@ internal fun RootContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is DeckColumnType.Article -> {
|
||||||
|
ArticleReaderScreen(
|
||||||
|
addressTag = columnType.addressTag,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onBack = {},
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DeckColumnType.Editor -> {
|
||||||
|
val draftStore = remember { DesktopDraftStore(scope) }
|
||||||
|
ArticleEditorScreen(
|
||||||
|
draftSlug = columnType.draftSlug,
|
||||||
|
draftStore = draftStore,
|
||||||
|
account = account,
|
||||||
|
relayManager = relayManager,
|
||||||
|
onBack = {},
|
||||||
|
onPublished = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckColumnType.Drafts -> {
|
||||||
|
val draftStore = remember { DesktopDraftStore(scope) }
|
||||||
|
DraftsScreen(
|
||||||
|
draftStore = draftStore,
|
||||||
|
onOpenEditor = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is DeckColumnType.Hashtag -> {
|
is DeckColumnType.Hashtag -> {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
@@ -383,6 +421,18 @@ internal fun OverlayContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is DesktopScreen.Article -> {
|
||||||
|
ArticleReaderScreen(
|
||||||
|
addressTag = screen.addressTag,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onBack = onBack,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
androidx.compose.material3.Text(
|
androidx.compose.material3.Text(
|
||||||
"Unsupported screen type",
|
"Unsupported screen type",
|
||||||
|
|||||||
+16
@@ -51,6 +51,16 @@ sealed class DeckColumnType {
|
|||||||
val noteId: String,
|
val noteId: String,
|
||||||
) : DeckColumnType()
|
) : DeckColumnType()
|
||||||
|
|
||||||
|
data class Article(
|
||||||
|
val addressTag: String,
|
||||||
|
) : DeckColumnType()
|
||||||
|
|
||||||
|
data class Editor(
|
||||||
|
val draftSlug: String? = null,
|
||||||
|
) : DeckColumnType()
|
||||||
|
|
||||||
|
object Drafts : DeckColumnType()
|
||||||
|
|
||||||
data class Hashtag(
|
data class Hashtag(
|
||||||
val tag: String,
|
val tag: String,
|
||||||
) : DeckColumnType()
|
) : DeckColumnType()
|
||||||
@@ -67,6 +77,9 @@ sealed class DeckColumnType {
|
|||||||
MyProfile -> "Profile"
|
MyProfile -> "Profile"
|
||||||
Chess -> "Chess"
|
Chess -> "Chess"
|
||||||
Settings -> "Settings"
|
Settings -> "Settings"
|
||||||
|
is Article -> "Article"
|
||||||
|
is Editor -> "New Article"
|
||||||
|
Drafts -> "Drafts"
|
||||||
is Profile -> "Profile"
|
is Profile -> "Profile"
|
||||||
is Thread -> "Thread"
|
is Thread -> "Thread"
|
||||||
is Hashtag -> "#$tag"
|
is Hashtag -> "#$tag"
|
||||||
@@ -84,6 +97,9 @@ sealed class DeckColumnType {
|
|||||||
MyProfile -> "my_profile"
|
MyProfile -> "my_profile"
|
||||||
Chess -> "chess"
|
Chess -> "chess"
|
||||||
Settings -> "settings"
|
Settings -> "settings"
|
||||||
|
is Article -> "article"
|
||||||
|
is Editor -> "editor"
|
||||||
|
Drafts -> "drafts"
|
||||||
is Profile -> "profile"
|
is Profile -> "profile"
|
||||||
is Thread -> "thread"
|
is Thread -> "thread"
|
||||||
is Hashtag -> "hashtag"
|
is Hashtag -> "hashtag"
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
title: "Long-Form Reads — Manual Testing Sheet"
|
||||||
|
date: 2026-03-24
|
||||||
|
branch: features/long-form-content
|
||||||
|
---
|
||||||
|
|
||||||
|
# Long-Form Reads — Manual Testing Sheet
|
||||||
|
|
||||||
|
**Run:** `cd AmethystMultiplatform-long-form && ./gradlew :desktopApp:run`
|
||||||
|
|
||||||
|
## Pre-Test Setup
|
||||||
|
|
||||||
|
- [x] App launches without crash
|
||||||
|
- [x] Login with existing account (needs relay connections)
|
||||||
|
- [x] Navigate to Reads tab in sidebar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ReadsScreen Feed
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 1.1 | Feed loads articles | Click Reads in sidebar | Long-form article cards appear | |
|
||||||
|
| 1.2 | Reading time shown | Check article cards | "X min read" displayed on each card | |
|
||||||
|
| 1.3 | Global/Following toggle | Click Global/Following chips | Feed switches between modes | |
|
||||||
|
| 1.4 | Article click navigates | Click any article card | ArticleReaderScreen opens (not ThreadScreen) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ArticleReaderScreen
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 2.1 | Article loads | Click article from Reads feed | Content renders with markdown formatting | |
|
||||||
|
| 2.2 | Title + metadata | Check header area | Title, author name, reading time, date displayed | |
|
||||||
|
| 2.3 | Banner image | Open article with banner | Hero image renders at top (if article has `image` tag) | |
|
||||||
|
| 2.4 | Markdown headings | Scroll through article | H1-H3 render with different sizes | |
|
||||||
|
| 2.5 | Bold/italic/code | Check formatting | **Bold**, *italic*, `inline code` render correctly | |
|
||||||
|
| 2.6 | Code blocks | Find code block | Monospace font, distinct background | |
|
||||||
|
| 2.7 | Links clickable | Click a URL link | Opens in system browser | |
|
||||||
|
| 2.8 | nostr: links | Click a nostr: link | Does NOT open OS error dialog (scheme filtered) | |
|
||||||
|
| 2.9 | Images in content | Find article with images | Images render via Coil | |
|
||||||
|
| 2.10 | Back button | Click ← Back | Returns to ReadsScreen | |
|
||||||
|
| 2.11 | Content width | Check article body | Max ~680dp centered column | |
|
||||||
|
| 2.12 | Loading state | Open article (watch transition) | "Loading article..." shown briefly | |
|
||||||
|
| 2.13 | Error state | Open invalid address tag (if testable) | "Article not found" message | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Table of Contents
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 3.1 | ToC visible | Open article on wide window (>1100dp) | ToC sidebar appears on left with heading list | |
|
||||||
|
| 3.2 | ToC hidden | Resize window to <900dp | ToC sidebar disappears | |
|
||||||
|
| 3.3 | Heading hierarchy | Check ToC entries | H2 indented less than H3 | |
|
||||||
|
| 3.4 | Click heading | Click a ToC entry | Active entry highlights (scroll-to is TODO) | |
|
||||||
|
| 3.5 | No headings | Open article with no markdown headings | ToC sidebar not shown or empty | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Article Editor
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 4.1 | Navigate to editor | Drafts tab → New Draft (or via menu) | Editor screen opens with split pane | |
|
||||||
|
| 4.2 | Split pane | Check layout | Source left, preview right | |
|
||||||
|
| 4.3 | Live preview | Type markdown in source pane | Preview updates after ~300ms | |
|
||||||
|
| 4.4 | Toolbar: Bold | Click B button | `**text**` inserted at cursor | |
|
||||||
|
| 4.5 | Toolbar: Italic | Click I button | `*text*` inserted | |
|
||||||
|
| 4.6 | Toolbar: Heading | Click H button | `## ` inserted | |
|
||||||
|
| 4.7 | Toolbar: Link | Click link button | `[text](url)` inserted | |
|
||||||
|
| 4.8 | Toolbar: Code | Click code button | Backticks inserted | |
|
||||||
|
| 4.9 | Toolbar: Quote | Click quote button | `> ` inserted | |
|
||||||
|
| 4.10 | Metadata: Title | Enter title | Title field accepts input, max 256 chars | |
|
||||||
|
| 4.11 | Metadata: Summary | Enter summary | Summary field accepts input, max 1024 chars | |
|
||||||
|
| 4.12 | Metadata: Tags | Type tag + Enter | Tag chip added | |
|
||||||
|
| 4.13 | Metadata: Slug | Enter slug | Auto-sanitized (no special chars) | |
|
||||||
|
| 4.14 | Ctrl+S save | Press Ctrl+S (or Cmd+S) | Draft saved to disk | |
|
||||||
|
| 4.15 | Back button | Click ← Back | Returns to previous screen | |
|
||||||
|
| 4.16 | Preview link safety | Add `[click](javascript:alert(1))` in source | Link NOT clickable in preview (scheme blocked) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Draft Storage
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 5.1 | Draft saved | Create draft, save, check filesystem | `~/.amethyst/drafts/<slug>.md` exists | |
|
||||||
|
| 5.2 | Index file | Check filesystem | `~/.amethyst/drafts/index.json` exists with metadata | |
|
||||||
|
| 5.3 | Drafts screen | Navigate to Drafts | Lists saved drafts with title, date | |
|
||||||
|
| 5.4 | Resume editing | Click a draft in list | Editor opens with content restored | |
|
||||||
|
| 5.5 | Delete draft | Click delete on a draft | Confirmation dialog → draft removed | |
|
||||||
|
| 5.6 | Slug sanitization | Try slug with `../` or special chars | Slug sanitized to safe characters | |
|
||||||
|
| 5.7 | Directory permissions | `ls -la ~/.amethyst/drafts/` | Dir permissions 700 (Unix) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Publish
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 6.1 | Publish button | Fill title + content, click Publish | Event signed and sent to relays | |
|
||||||
|
| 6.2 | Publish feedback | After publish | Success snackbar / confirmation | |
|
||||||
|
| 6.3 | Published in feed | After publish, check Reads feed | Your article appears in Global feed | |
|
||||||
|
| 6.4 | Re-publish (replace) | Edit same draft, publish again | Article updated (same d-tag) | |
|
||||||
|
| 6.5 | Size limit | Try publishing >100KB content | Error message about content too large | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Typography (Visual)
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 7.1 | Body text | Read article body | Georgia-style serif, ~18sp, generous line height | |
|
||||||
|
| 7.2 | Content centered | Check horizontal layout | Content centered with max ~680dp width | |
|
||||||
|
| 7.3 | Dark mode | Check dark theme | Text ~#E0E0E0 on dark background, comfortable contrast | |
|
||||||
|
| 7.4 | Blockquotes | Find blockquote | Left border/indent, slightly larger text | |
|
||||||
|
| 7.5 | Tables | Find table | Renders with columns and rows | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Checks
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 8.1 | XSS in markdown | Article with `<script>alert(1)</script>` | Rendered as literal text, no execution | |
|
||||||
|
| 8.2 | URI scheme: javascript | Link `[x](javascript:alert(1))` in article | Link not clickable / filtered | |
|
||||||
|
| 8.3 | URI scheme: file | Link `[x](file:///etc/passwd)` in article | Link not clickable / filtered | |
|
||||||
|
| 8.4 | Image URL validation | Article with `file:///etc/passwd` as banner | Image not loaded | |
|
||||||
|
| 8.5 | Slug traversal | Set slug to `../../.ssh/keys` | Sanitized to safe string | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Edge Cases
|
||||||
|
|
||||||
|
| # | Test | Steps | Expected | Pass? |
|
||||||
|
|---|------|-------|----------|-------|
|
||||||
|
| 9.1 | Empty article | Article with no content | Reader shows empty body, no crash | |
|
||||||
|
| 9.2 | No relays connected | Disconnect all relays → open article | "Connecting to relays..." loading state | |
|
||||||
|
| 9.3 | Very long article | Article with 10k+ words | Renders without freeze (may be slow) | |
|
||||||
|
| 9.4 | No banner image | Article without `image` tag | Header renders without banner, no crash | |
|
||||||
|
| 9.5 | No author metadata | Article from unknown pubkey | Shows pubkey hex, no profile pic | |
|
||||||
|
| 9.6 | Window resize | Resize during article reading | Layout adapts, ToC shows/hides | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
_Record any bugs, unexpected behavior, or UX issues here:_
|
||||||
|
|
||||||
|
| | Issue | Severity | Notes |
|
||||||
|
|--|-------|----------|-------|
|
||||||
|
| | | | |
|
||||||
|
| | | | |
|
||||||
|
| | | | |
|
||||||
-161
@@ -1,161 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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,7 +58,6 @@ 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
|
||||||
@@ -273,7 +272,6 @@ 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