diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt index a744a3473..7e978be46 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt @@ -53,7 +53,9 @@ fun ArticleHeader( ) { Column(modifier = modifier.fillMaxWidth()) { // Banner image - if (!bannerUrl.isNullOrBlank()) { + if (!bannerUrl.isNullOrBlank() && + (bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://")) + ) { AsyncImage( model = bannerUrl, contentDescription = "Article banner", @@ -79,7 +81,9 @@ fun ArticleHeader( // Author + metadata row Row(verticalAlignment = Alignment.CenterVertically) { - if (!authorPicture.isNullOrBlank()) { + if (!authorPicture.isNullOrBlank() && + (authorPicture.startsWith("https://") || authorPicture.startsWith("http://")) + ) { AsyncImage( model = authorPicture, contentDescription = "Author", diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt index 78fcf20e7..292fb6729 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt @@ -37,6 +37,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)") +private val TRAILING_HASHES_REGEX = Regex("#+$") + data class TocEntry( val level: Int, val text: String, @@ -60,13 +63,13 @@ fun extractTableOfContents(markdown: String): List { } if (inCodeBlock) return@forEach - val match = Regex("^(#{1,6})\\s+(.+)").find(trimmed) + val match = HEADING_REGEX.find(trimmed) if (match != null) { val level = match.groupValues[1].length val text = match.groupValues[2] .trim() - .replace(Regex("#+$"), "") + .replace(TRAILING_HASHES_REGEX, "") .trim() if (text.isNotEmpty() && level <= 3) { entries.add(TocEntry(level = level, text = text, index = headingIndex)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt index 6e5682006..28a22e080 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt @@ -74,18 +74,20 @@ fun MetadataPanel( ) { OutlinedTextField( value = title, - onValueChange = onTitleChange, + onValueChange = { if (it.length <= 256) onTitleChange(it) }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + supportingText = { Text("${title.length}/256") }, ) OutlinedTextField( value = summary, - onValueChange = onSummaryChange, + onValueChange = { if (it.length <= 1024) onSummaryChange(it) }, label = { Text("Summary") }, maxLines = 3, modifier = Modifier.fillMaxWidth(), + supportingText = { Text("${summary.length}/1024") }, ) OutlinedTextField( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/highlight/HighlightCreator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/highlight/HighlightCreator.kt deleted file mode 100644 index 0991e16aa..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/highlight/HighlightCreator.kt +++ /dev/null @@ -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") - } - }, - ) -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/ArticleMediaRenderer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/ArticleMediaRenderer.kt deleted file mode 100644 index 5681f8c12..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/ArticleMediaRenderer.kt +++ /dev/null @@ -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) -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt index efd070459..dd640810e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt @@ -37,7 +37,7 @@ private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning") @Composable fun RenderMarkdown( content: String, - mediaRenderer: ArticleMediaRenderer, + onLinkClick: (String) -> Unit, modifier: Modifier = Modifier, ) { val astNode = @@ -46,12 +46,12 @@ fun RenderMarkdown( } val uriHandler = - remember(mediaRenderer) { + remember(onLinkClick) { object : UriHandler { override fun openUri(uri: String) { val scheme = uri.substringBefore(":").lowercase() if (scheme in ALLOWED_SCHEMES) { - mediaRenderer.onLinkClick(uri) + onLinkClick(uri) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt index 2e0e828d7..d9c151b01 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.utils.TimeUtils * Handles title, summary, image, tags, and d-tag for addressable events. */ object LongFormPublishAction { + private const val MAX_CONTENT_BYTES = 100_000 + /** * Publishes a long-form text note (NIP-23 kind 30023). * @@ -56,6 +58,10 @@ object LongFormPublishAction { 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 = LongTextNoteEvent.build( description = content, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt index 382402935..b326186bd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt @@ -32,6 +32,14 @@ object ReadingTimeCalculator { private const val PROSE_WPM = 238.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 { var proseWords = 0 var codeWords = 0 @@ -47,24 +55,24 @@ object ReadingTimeCalculator { } if (inCodeBlock) { - codeWords += trimmed.split("\\s+".toRegex()).count { it.isNotBlank() } + codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() } return@forEach } // Count images - val imageMatches = Regex("!\\[.*?]\\(.*?\\)").findAll(trimmed) + val imageMatches = IMAGE_REGEX.findAll(trimmed) imageCount += imageMatches.count() // Strip markdown syntax for word counting val stripped = trimmed - .replace(Regex("!\\[.*?]\\(.*?\\)"), "") // images - .replace(Regex("\\[([^]]*)]\\([^)]*\\)"), "$1") // links -> text only - .replace(Regex("[*_~`#>]"), "") // formatting - .replace(Regex("^-\\s+|^\\d+\\.\\s+"), "") // list markers - .replace(Regex("^---+$|^\\*\\*\\*+$"), "") // horizontal rules + .replace(IMAGE_STRIP_REGEX, "") // images + .replace(LINK_REGEX, "$1") // links -> text only + .replace(FORMATTING_REGEX, "") // formatting + .replace(LIST_MARKER_REGEX, "") // list markers + .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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 9a503158d..815048931 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -147,6 +147,12 @@ sealed class DesktopScreen { val addressTag: String, ) : DesktopScreen() + data class Editor( + val draftSlug: String? = null, + ) : DesktopScreen() + + data object Drafts : DesktopScreen() + data object Settings : DesktopScreen() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt index 15e247821..ac0dbaade 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt @@ -61,6 +61,7 @@ class DesktopDraftStore( ) { private val mapper = jacksonObjectMapper() private val mutex = Mutex() + private var cachedIndex: MutableMap? = null private val _drafts = MutableStateFlow>(emptyList()) val drafts: StateFlow> = _drafts.asStateFlow() @@ -77,7 +78,10 @@ class DesktopDraftStore( private val indexFile: File get() = File(draftsDir, "index.json") 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 { val sanitized = slug - .replace("..", "") .replace("/", "") .replace("\\", "") .replace("\u0000", "") @@ -133,9 +136,10 @@ class DesktopDraftStore( atomicWrite(contentFile, content) // Update index - val index = loadIndexMap().toMutableMap() + val index = loadIndexMap() index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString()) atomicWriteIndex(index) + cachedIndex = index // Refresh state _drafts.value = @@ -172,9 +176,10 @@ class DesktopDraftStore( mutex.withLock { File(draftsDir, "$safeSlug.md").delete() - val index = loadIndexMap().toMutableMap() + val index = loadIndexMap() index.remove(safeSlug) atomicWriteIndex(index) + cachedIndex = index _drafts.value = index.entries @@ -189,10 +194,11 @@ class DesktopDraftStore( suspend fun markPublished(slug: String) { val safeSlug = sanitizeSlug(slug) mutex.withLock { - val index = loadIndexMap().toMutableMap() + val index = loadIndexMap() val existing = index[safeSlug] ?: return index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString()) atomicWriteIndex(index) + cachedIndex = index _drafts.value = index.entries @@ -201,14 +207,21 @@ class DesktopDraftStore( } } - private fun loadIndexMap(): Map { - if (!indexFile.exists()) return emptyMap() - return try { - mapper.readValue(indexFile) - } catch (e: Exception) { - System.err.println("Failed to read drafts index: ${e.message}") - emptyMap() - } + private fun loadIndexMap(): MutableMap { + cachedIndex?.let { return it } + val loaded: MutableMap = + if (!indexFile.exists()) { + mutableMapOf() + } else { + try { + mapper.readValue>(indexFile) + } catch (e: Exception) { + System.err.println("Failed to read drafts index: ${e.message}") + mutableMapOf() + } + } + cachedIndex = loaded + return loaded } private suspend fun loadIndex() { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 04fb56ac2..fee6d8c3b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -576,6 +576,24 @@ object FilterBuilders { 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. * diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt index a793b8e55..0edc4631c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt @@ -62,18 +62,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel -import com.vitorpamplona.amethyst.commons.compose.markdown.ArticleMediaRenderer import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore import com.vitorpamplona.amethyst.desktop.service.drafts.DraftMetadata +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.awt.Desktop import java.net.URI -private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") +private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning") @Composable fun ArticleEditorScreen( @@ -94,6 +94,12 @@ fun ArticleEditorScreen( var contentField by remember { mutableStateOf(TextFieldValue("")) } var publishing by remember { mutableStateOf(false) } var saveMessage by remember { mutableStateOf(null) } + var debouncedContent by remember { mutableStateOf("") } + + LaunchedEffect(contentField.text) { + delay(300) + debouncedContent = contentField.text + } // Load existing draft LaunchedEffect(draftSlug) { @@ -120,23 +126,11 @@ fun ArticleEditorScreen( } } - val mediaRenderer = + val onLinkClick: (String) -> Unit = remember { - object : ArticleMediaRenderer { - @Composable - override fun renderImage( - url: String, - alt: String?, - ) { - // Simple text placeholder for editor preview - Text( - "[Image: ${alt ?: url}]", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - override fun onLinkClick(url: String) { + { url: String -> + val scheme = url.substringBefore(":").lowercase() + if (scheme in ALLOWED_SCHEMES) { try { Desktop.getDesktop().browse(URI(url)) } catch (_: Exception) { @@ -179,6 +173,8 @@ fun ArticleEditorScreen( dTag = slug.ifBlank { draftStore.slugFromTitle(title) }, 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) draftStore.markPublished(slug) onPublished() @@ -198,7 +194,7 @@ fun ArticleEditorScreen( // Ctrl/Cmd+S to save if (event.type == KeyEventType.KeyDown && event.key == Key.S && - (if (isMacOS) event.isMetaPressed else event.isMetaPressed) + event.isMetaPressed ) { saveDraft() true @@ -316,10 +312,10 @@ fun ArticleEditorScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Column(modifier = Modifier.widthIn(max = 680.dp)) { - if (contentField.text.isNotBlank()) { + if (debouncedContent.isNotBlank()) { RenderMarkdown( - content = contentField.text, - mediaRenderer = mediaRenderer, + content = debouncedContent, + onLinkClick = onLinkClick, ) } else { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index 56a140259..b7220448f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon 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.TableOfContents 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.model.nip23LongContent.ReadingTimeCalculator 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.network.DesktopRelayConnectionManager 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.rememberSubscription -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import java.text.SimpleDateFormat -import java.util.Date +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter 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". @@ -100,7 +99,6 @@ fun ArticleReaderScreen( subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, onNavigateToProfile: (String) -> Unit = {}, - onNavigateToThread: (String) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() @@ -118,32 +116,18 @@ fun ArticleReaderScreen( // Active ToC entry tracking (placeholder — no scroll-position-based tracking yet) var activeTocIndex by remember { mutableStateOf(null) } - // Media renderer for markdown - val mediaRenderer = + // Link click handler for markdown + val onLinkClick: (String) -> Unit = remember { - object : ArticleMediaRenderer { - @Composable - override fun renderImage( - url: String, - alt: String?, - ) { - // TODO: Replace with image loading library (coil3 not available on desktop) - Text( - text = "[Image: ${alt ?: url}]", - 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) { - } + { 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( - subId = "article-${addressTag.hashCode()}-${System.currentTimeMillis()}", - filters = - listOf( - Filter( - kinds = listOf(LongTextNoteEvent.KIND), - authors = listOf(pubkey), - tags = mapOf("d" to listOf(dTag)), - limit = 1, - ), - ), + subId = "article-${addressTag.hashCode()}", + filters = listOf(FilterBuilders.longFormByAddress(pubkey, dTag)), relays = configuredRelays, onEvent = { event, _, _, _ -> if (event is LongTextNoteEvent) { @@ -201,7 +177,11 @@ fun ArticleReaderScreen( val publishedAt = article?.let { art -> 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 @@ -231,15 +211,6 @@ fun ArticleReaderScreen( 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 @@ -326,7 +297,7 @@ fun ArticleReaderScreen( // Markdown body RenderMarkdown( content = content, - mediaRenderer = mediaRenderer, + onLinkClick = onLinkClick, ) Spacer(Modifier.height(32.dp)) @@ -350,21 +321,6 @@ fun ArticleReaderScreen( 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)) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 0b45e6e7f..6f69cb9d5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -68,13 +68,19 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscr import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import java.text.SimpleDateFormat -import java.util.Date +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter 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. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index 3deb62b25..17a2eb781 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -132,6 +132,9 @@ fun DeckColumnType.icon(): ImageVector = DeckColumnType.MyProfile -> Icons.Default.Person DeckColumnType.Chess -> Icons.Default.Extension 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.Thread -> Icons.AutoMirrored.Filled.Article is DeckColumnType.Hashtag -> Icons.Default.Tag diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 4e3ff3016..79559bafc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -46,7 +46,11 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator 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.DraftsScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen @@ -139,6 +143,7 @@ fun DeckColumnContainer( onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, ) if (currentOverlay != null) { Surface( @@ -182,6 +187,7 @@ internal fun RootContent( onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, + onNavigateToArticle: (String) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -232,7 +238,7 @@ internal fun RootContent( localCache = localCache, account = account, 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 -> { SearchScreen( 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 -> { androidx.compose.material3.Text( "Unsupported screen type", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index 2b1642244..c3666b866 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -51,6 +51,16 @@ sealed class DeckColumnType { val noteId: String, ) : DeckColumnType() + data class Article( + val addressTag: String, + ) : DeckColumnType() + + data class Editor( + val draftSlug: String? = null, + ) : DeckColumnType() + + object Drafts : DeckColumnType() + data class Hashtag( val tag: String, ) : DeckColumnType() @@ -67,6 +77,9 @@ sealed class DeckColumnType { MyProfile -> "Profile" Chess -> "Chess" Settings -> "Settings" + is Article -> "Article" + is Editor -> "New Article" + Drafts -> "Drafts" is Profile -> "Profile" is Thread -> "Thread" is Hashtag -> "#$tag" @@ -84,6 +97,9 @@ sealed class DeckColumnType { MyProfile -> "my_profile" Chess -> "chess" Settings -> "settings" + is Article -> "article" + is Editor -> "editor" + Drafts -> "drafts" is Profile -> "profile" is Thread -> "thread" is Hashtag -> "hashtag" diff --git a/docs/plans/2026-03-24-long-form-reads-manual-testing.md b/docs/plans/2026-03-24-long-form-reads-manual-testing.md new file mode 100644 index 000000000..93a9e01c6 --- /dev/null +++ b/docs/plans/2026-03-24-long-form-reads-manual-testing.md @@ -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/.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 `` | 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 | +|--|-------|----------|-------| +| | | | | +| | | | | +| | | | | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/DraftLongTextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/DraftLongTextNoteEvent.kt deleted file mode 100644 index f76ba3bc7..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/DraftLongTextNoteEvent.kt +++ /dev/null @@ -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>, - content: String, - sig: HexKey, -) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - AddressableEvent, - EventHintProvider, - PubKeyHintProvider, - AddressHintProvider, - PublishedAtProvider, - RootScope, - SearchableEvent { - override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content - - override fun eventHints(): List { - val qHints = tags.mapNotNull(QTag::parseEventAsHint) - val nip19Hints = citedNIP19().eventHints() - return qHints + nip19Hints - } - - override fun linkedEventIds(): List { - val qHints = tags.mapNotNull(QTag::parseEventId) - val nip19Hints = citedNIP19().eventIds() - return qHints + nip19Hints - } - - override fun addressHints(): List { - val qHints = tags.mapNotNull(QTag::parseAddressAsHint) - val nip19Hints = citedNIP19().addressHints() - return qHints + nip19Hints - } - - override fun linkedAddressIds(): List { - val qHints = tags.mapNotNull(QTag::parseAddressId) - val nip19Hints = citedNIP19().addressIds() - return qHints + nip19Hints - } - - override fun pubKeyHints(): List { - val pHints = tags.mapNotNull(PTag::parseAsHint) - val nip19Hints = citedNIP19().pubKeyHints() - return pHints + nip19Hints - } - - override fun linkedPubKeys(): List { - val pHints = tags.mapNotNull(PTag::parseKey) - val nip19Hints = citedNIP19().pubKeys() - return pHints + nip19Hints - } - - override fun dTag() = tags.dTag() - - override fun address() = Address(kind, pubKey, dTag()) - - override fun addressTag() = Address.assemble(kind, pubKey, dTag()) - - fun topics() = hashtags() - - fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - - fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - - fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - - override fun publishedAt(): Long? { - val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse) - if (publishedAt == null) return null - return if (publishedAt <= createdAt) publishedAt else null - } - - companion object { - const val KIND = 30024 - - @OptIn(ExperimentalUuidApi::class) - fun build( - description: String, - title: String, - summary: String? = null, - image: String? = null, - publishedAt: Long? = null, - dTag: String = Uuid.random().toString(), - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, description, createdAt) { - dTag(dTag) - alt("Draft blog post: $title") - - addUnique(TitleTag.assemble(title)) - summary?.let { addUnique(SummaryTag.assemble(it)) } - image?.let { addUnique(ImageTag.assemble(it)) } - publishedAt?.let { addUnique(PublishedAtTag.assemble(it)) } - - initializer() - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 07110b734..0239da5a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -58,7 +58,6 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.DraftLongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -273,7 +272,6 @@ class EventFactory { LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) - DraftLongTextNoteEvent.KIND -> DraftLongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig) MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig) MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig)