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:
nrobi144
2026-03-24 07:18:42 +02:00
parent 4fcbffdb3b
commit 328fcf86d7
20 changed files with 368 additions and 419 deletions
@@ -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",
@@ -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))
@@ -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(
@@ -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")
}
},
)
}
@@ -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)
}
@@ -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)
} }
} }
} }
@@ -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,
@@ -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()
} }
@@ -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() {
@@ -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.
* *
@@ -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(
@@ -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.
@@ -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
@@ -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",
@@ -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 |
|--|-------|----------|-------|
| | | | |
| | | | |
| | | | |
@@ -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)