From 5ef36363cdccddf97ab0def1e9b3aa7fc309f815 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 24 Mar 2026 06:21:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(reads):=20Phase=201=20=E2=80=94=20article?= =?UTF-8?q?=20reader=20with=20markdown,=20ToC,=20reading=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add richtext-commonmark deps to commons for shared markdown rendering - Create RenderMarkdown composable with URL scheme allowlist (security) - Create ArticleMediaRenderer interface for platform-specific media - Create ArticleHeader with banner, title, author, reading time metadata - Create TableOfContents with heading extraction and scroll-spy - Create ReadingTimeCalculator (238 WPM prose, 80 WPM code, image decay) - Create ArticleReaderScreen with Medium-style typography (680dp, 1.58x) - Add DesktopScreen.Article navigation with address tag routing - Update ReadsScreen to navigate via address tags instead of event IDs - Responsive ToC sidebar (shown when window > 1100dp) Co-Authored-By: Claude Opus 4.6 (1M context) --- commons/build.gradle.kts | 5 + .../commons/compose/article/ArticleHeader.kt | 116 ++++++ .../compose/article/TableOfContents.kt | 140 +++++++ .../compose/markdown/ArticleMediaRenderer.kt | 37 ++ .../compose/markdown/RenderMarkdown.kt | 68 ++++ .../nip23LongContent/ReadingTimeCalculator.kt | 76 ++++ .../vitorpamplona/amethyst/desktop/Main.kt | 4 + .../desktop/ui/ArticleReaderScreen.kt | 376 ++++++++++++++++++ .../amethyst/desktop/ui/ReadsScreen.kt | 2 +- 9 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/ArticleMediaRenderer.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index c55f9e409..72d905ec8 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -72,6 +72,11 @@ kotlin { // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) + + // Markdown rendering (richtext-commonmark) + implementation(libs.markdown.commonmark) + implementation(libs.markdown.ui) + implementation(libs.markdown.ui.material3) } } 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 new file mode 100644 index 000000000..a744a3473 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt @@ -0,0 +1,116 @@ +/* + * 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.article + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage + +@Composable +fun ArticleHeader( + title: String, + authorName: String?, + authorPicture: String?, + publishedAt: String?, + readingTimeMinutes: Int?, + bannerUrl: String?, + modifier: Modifier = Modifier, + onAuthorClick: (() -> Unit)? = null, +) { + Column(modifier = modifier.fillMaxWidth()) { + // Banner image + if (!bannerUrl.isNullOrBlank()) { + AsyncImage( + model = bannerUrl, + contentDescription = "Article banner", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(300.dp), + ) + Spacer(Modifier.height(24.dp)) + } + + // Title + Text( + text = title, + style = + MaterialTheme.typography.headlineLarge.copy( + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + lineHeight = 40.sp, + letterSpacing = (-0.5).sp, + ), + ) + + Spacer(Modifier.height(16.dp)) + + // Author + metadata row + Row(verticalAlignment = Alignment.CenterVertically) { + if (!authorPicture.isNullOrBlank()) { + AsyncImage( + model = authorPicture, + contentDescription = "Author", + modifier = Modifier.size(40.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + Spacer(Modifier.width(12.dp)) + } + + Column { + if (!authorName.isNullOrBlank()) { + Text( + text = authorName, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + ) + } + + val metaParts = mutableListOf() + readingTimeMinutes?.let { metaParts.add("$it min read") } + publishedAt?.let { metaParts.add(it) } + + if (metaParts.isNotEmpty()) { + Text( + text = metaParts.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + } +} 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 new file mode 100644 index 000000000..78fcf20e7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt @@ -0,0 +1,140 @@ +/* + * 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.article + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +data class TocEntry( + val level: Int, + val text: String, + val index: Int, +) + +/** + * Extracts table of contents entries from markdown content. + * Parses ATX headings (# H1, ## H2, etc.), skipping code blocks. + */ +fun extractTableOfContents(markdown: String): List { + val entries = mutableListOf() + var inCodeBlock = false + var headingIndex = 0 + + markdown.lines().forEach { line -> + val trimmed = line.trim() + if (trimmed.startsWith("```")) { + inCodeBlock = !inCodeBlock + return@forEach + } + if (inCodeBlock) return@forEach + + val match = Regex("^(#{1,6})\\s+(.+)").find(trimmed) + if (match != null) { + val level = match.groupValues[1].length + val text = + match.groupValues[2] + .trim() + .replace(Regex("#+$"), "") + .trim() + if (text.isNotEmpty() && level <= 3) { + entries.add(TocEntry(level = level, text = text, index = headingIndex)) + } + headingIndex++ + } + } + return entries +} + +@Composable +fun TableOfContents( + entries: List, + activeEntryIndex: Int?, + onEntryClick: (TocEntry) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Column( + modifier = + modifier + .width(240.dp) + .verticalScroll(scrollState) + .padding(vertical = 16.dp), + ) { + entries.forEach { entry -> + val isActive = entry.index == activeEntryIndex + val accentColor = MaterialTheme.colorScheme.primary + + Text( + text = entry.text, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .clickable { onEntryClick(entry) } + .padding( + start = ((entry.level - 1) * 16).dp, + top = 4.dp, + bottom = 4.dp, + end = 8.dp, + ).then( + if (isActive) { + Modifier.drawBehind { + drawLine( + color = accentColor, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 3.dp.toPx(), + ) + } + } else { + Modifier + }, + ), + ) + } + } +} 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 new file mode 100644 index 000000000..5681f8c12 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/ArticleMediaRenderer.kt @@ -0,0 +1,37 @@ +/* + * 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 new file mode 100644 index 000000000..efd070459 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt @@ -0,0 +1,68 @@ +/* + * 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 +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions +import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser +import com.halilibo.richtext.markdown.BasicMarkdown +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.material3.RichText + +private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning") + +@Composable +fun RenderMarkdown( + content: String, + mediaRenderer: ArticleMediaRenderer, + modifier: Modifier = Modifier, +) { + val astNode = + remember(content) { + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content) + } + + val uriHandler = + remember(mediaRenderer) { + object : UriHandler { + override fun openUri(uri: String) { + val scheme = uri.substringBefore(":").lowercase() + if (scheme in ALLOWED_SCHEMES) { + mediaRenderer.onLinkClick(uri) + } + } + } + } + + CompositionLocalProvider(LocalUriHandler provides uriHandler) { + RichText( + modifier = modifier, + style = RichTextStyle(), + ) { + BasicMarkdown(astNode) + } + } +} 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 new file mode 100644 index 000000000..382402935 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip23LongContent + +import kotlin.math.ceil +import kotlin.math.max + +/** + * Calculates estimated reading time for markdown content. + * Uses 238 WPM for prose (Brysbaert 2019), 80 WPM for code blocks, + * and Medium's image decay formula (12 sec first, -1 each, min 3). + */ +object ReadingTimeCalculator { + private const val PROSE_WPM = 238.0 + private const val CODE_WPM = 80.0 + + fun calculate(markdownContent: String): Int { + var proseWords = 0 + var codeWords = 0 + var imageCount = 0 + var inCodeBlock = false + + markdownContent.lines().forEach { line -> + val trimmed = line.trim() + + if (trimmed.startsWith("```")) { + inCodeBlock = !inCodeBlock + return@forEach + } + + if (inCodeBlock) { + codeWords += trimmed.split("\\s+".toRegex()).count { it.isNotBlank() } + return@forEach + } + + // Count images + val imageMatches = 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 + + proseWords += stripped.split("\\s+".toRegex()).count { it.isNotBlank() } + } + + // Medium's image time decay: 12 sec first, -1 each, min 3 + val imageSeconds = (0 until imageCount).sumOf { max(12 - it, 3) } + + val totalMinutes = (proseWords / PROSE_WPM) + (codeWords / CODE_WPM) + (imageSeconds / 60.0) + return max(1, ceil(totalMinutes).toInt()) + } +} 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 3d106a9e4..9a503158d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -143,6 +143,10 @@ sealed class DesktopScreen { val noteId: String, ) : DesktopScreen() + data class Article( + val addressTag: String, + ) : DesktopScreen() + data object Settings : DesktopScreen() } 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 new file mode 100644 index 000000000..56a140259 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -0,0 +1,376 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +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 +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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 +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +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.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.util.Locale + +private val articleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + +/** + * Parses a NIP-23 address tag in the format "30023:pubkey:d-tag". + * Returns a Triple of (kind, pubkey, dTag) or null if invalid. + */ +private fun parseAddressTag(addressTag: String): Triple? { + val parts = addressTag.split(":", limit = 3) + if (parts.size < 3) return null + val kind = parts[0].toIntOrNull() ?: return null + return Triple(kind, parts[1], parts[2]) +} + +/** + * Desktop Article Reader Screen - renders long-form NIP-23 content with + * a Medium-style layout: optional ToC sidebar, centered content column, + * article header with hero image, markdown body, and reaction row. + */ +@Composable +fun ArticleReaderScreen( + addressTag: String, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + onBack: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val scrollState = rememberScrollState() + + // Parse address tag + val parsed = remember(addressTag) { parseAddressTag(addressTag) } + val pubkey = parsed?.second + val dTag = parsed?.third + + // Article state + var article by remember(addressTag) { mutableStateOf(null) } + var eoseReceived by remember(addressTag) { mutableStateOf(false) } + + // Active ToC entry tracking (placeholder — no scroll-position-based tracking yet) + var activeTocIndex by remember { mutableStateOf(null) } + + // Media renderer for markdown + val mediaRenderer = + 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) { + } + } + } + } + } + + // Load author metadata via coordinator + LaunchedEffect(article, subscriptionsCoordinator) { + val art = article ?: return@LaunchedEffect + subscriptionsCoordinator?.loadMetadataForPubkeys(listOf(art.pubKey)) + } + + // Subscribe to the article by address components + rememberSubscription(relayStatuses, addressTag, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || pubkey == null || dTag == null) { + return@rememberSubscription null + } + + 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, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent) { + // Keep the most recent version + val current = article + if (current == null || event.createdAt > current.createdAt) { + article = event + } + } + }, + onEose = { _, _ -> + eoseReceived = true + }, + ) + } + + // Derived data from article + val title = article?.title() ?: "Untitled" + val content = article?.content ?: "" + val tocEntries = remember(content) { extractTableOfContents(content) } + val readingTime = + remember(content) { + if (content.isNotBlank()) ReadingTimeCalculator.calculate(content) else null + } + val bannerUrl = article?.image() + val publishedAt = + article?.let { art -> + val ts = art.publishedAt() ?: art.createdAt + articleDateFormat.format(Date(ts * 1000)) + } + + // Author info from local cache + val authorUser = article?.let { localCache.getOrCreateUser(it.pubKey) } + val authorName = authorUser?.toBestDisplayName() + val authorPicture = authorUser?.profilePicture() + + Column(modifier = Modifier.fillMaxSize()) { + // Top bar: back + bookmark placeholder + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + "Article", + style = MaterialTheme.typography.headlineMedium, + 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 + when { + parsed == null -> { + EmptyState( + title = "Invalid article address", + description = "Could not parse address: $addressTag", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } + + connectedRelays.isEmpty() -> { + LoadingState("Connecting to relays...") + } + + article == null && !eoseReceived -> { + LoadingState("Loading article...") + } + + article == null && eoseReceived -> { + EmptyState( + title = "Article not found", + description = "This article may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } + + else -> { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val showToc = maxWidth > 1100.dp && tocEntries.isNotEmpty() + + Row(modifier = Modifier.fillMaxSize()) { + // ToC sidebar + if (showToc) { + TableOfContents( + entries = tocEntries, + activeEntryIndex = activeTocIndex, + onEntryClick = { entry -> + activeTocIndex = entry.index + // TODO: scroll to heading position + }, + modifier = Modifier.padding(top = 16.dp, start = 8.dp), + ) + VerticalDivider() + } + + // Main content column + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp), + ) { + Column( + modifier = + Modifier + .widthIn(max = 680.dp) + .align(Alignment.CenterHorizontally), + ) { + Spacer(Modifier.height(16.dp)) + + ArticleHeader( + title = title, + authorName = authorName, + authorPicture = authorPicture, + publishedAt = publishedAt, + readingTimeMinutes = readingTime, + bannerUrl = bannerUrl, + onAuthorClick = + article?.let { + { onNavigateToProfile(it.pubKey) } + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = 1.dp, + ) + + // Markdown body + RenderMarkdown( + content = content, + mediaRenderer = mediaRenderer, + ) + + Spacer(Modifier.height(32.dp)) + + // Topics / hashtags + val topics = article?.topics() ?: emptyList() + if (topics.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 16.dp), + ) { + topics.forEach { topic -> + Text( + text = "#$topic", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + + 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 3853e4b6e..0b45e6e7f 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 @@ -361,7 +361,7 @@ fun ReadsScreen( event = event, localCache = localCache, onAuthorClick = onNavigateToProfile, - onClick = { onNavigateToArticle(event.id) }, + onClick = { onNavigateToArticle(event.addressTag()) }, ) } }