diff --git a/README.md b/README.md index a19f16550..dce91b635 100644 --- a/README.md +++ b/README.md @@ -405,24 +405,24 @@ to `onPause` methods. ### Feature Parity Table -| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes | -| :--- | :--- | :---: | :---: | :--- | -| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. | -| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. | -| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. | -| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. | -| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. | -| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. | -| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. | -| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. | -| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. | -| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. | -| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. | -| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. | -| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. | -| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. | -| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. | -| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. | +| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes | +|:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------| +| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | | +| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | | +| | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | | +| | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | | +| | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | | +| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. | +| | GZip Compression | ✅ Full | ✅ Full | | +| | BitSet | ✅ Full | ✅ Full | | +| | LargeCache | ✅ Full | ✅ Full | | +| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | | +| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. | +| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. | +| **Utilities** | URL Encoding / Decoding | ✅ Full | ✅ Full | | +| | Unicode Normalization | ✅ Full | ✅ Full | | +| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. | +| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. | ## Contributing diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt index 49dfbc440..76cd1f644 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -34,12 +34,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Approval import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.HourglassTop import androidx.compose.material.icons.filled.Recommend import androidx.compose.material.icons.filled.RemoveDone @@ -84,7 +81,6 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent @@ -106,8 +102,7 @@ fun RenderAttestationPreview() { arrayOf( arrayOf("d", "af5aa898:fe108febb997:1773941524"), arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"), - arrayOf("s", "verified"), - arrayOf("v", "valid"), + arrayOf("s", "valid"), arrayOf("client", "attestr.xyz"), ), ) @@ -161,19 +156,17 @@ fun RenderAttestation( accountViewModel: AccountViewModel, nav: INav, ) { - val validity = remember(noteEvent) { noteEvent.validity() } val status = remember(noteEvent) { noteEvent.status() } val validFrom = remember(noteEvent) { noteEvent.validFrom() } val validTo = remember(noteEvent) { noteEvent.validTo() } val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } - val statusColor = remember(status, validity) { attestationColor(status, validity) } - val statusIcon = remember(status, validity) { attestationIcon(status, validity) } - val statusLabel = attestationStatusLabel(status, validity) + val statusColor = remember(status) { attestationColor(status) } + val statusIcon = remember(status) { attestationIcon(status) } + val statusLabel = attestationStatusLabel(status) val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } - val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() } Column( modifier = @@ -257,13 +250,6 @@ fun RenderAttestation( ) } } - } else if (aboutPubkey != null) { - LoadUser(aboutPubkey, accountViewModel) { - if (it != null) { - Spacer(modifier = DoubleVertSpacer) - UserCompose(it, accountViewModel = accountViewModel, nav = nav) - } - } } } @@ -590,49 +576,31 @@ fun RenderAttestorProficiency( } } -private fun attestationColor( - status: AttestationStatus?, - validity: Validity?, -): Color = +private fun attestationColor(status: AttestationStatus?): Color = when { - validity == Validity.INVALID -> Color(0xFFB71C1C) - validity == Validity.VALID -> Color(0xFF2E7D32) + status == AttestationStatus.INVALID -> Color(0xFFB71C1C) + status == AttestationStatus.VALID -> Color(0xFF2E7D32) status == AttestationStatus.REVOKED -> Color(0xFFB21CB7) - status == AttestationStatus.REJECTED -> Color(0xFFB71C1C) - status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32) status == AttestationStatus.VERIFYING -> Color(0xFF173CF5) - status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0) else -> Color(0xFF757575) } -private fun attestationIcon( - status: AttestationStatus?, - validity: Validity?, -): ImageVector = +private fun attestationIcon(status: AttestationStatus?): ImageVector = when { - validity == Validity.INVALID -> Icons.Default.Close - validity == Validity.VALID -> Icons.Default.CheckCircle + status == AttestationStatus.INVALID -> Icons.Default.Close + status == AttestationStatus.VALID -> Icons.Default.CheckCircle status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone - status == AttestationStatus.REJECTED -> Icons.Default.Delete - status == AttestationStatus.VERIFIED -> Icons.Default.Approval status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop - status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty else -> Icons.Default.ErrorOutline } @Composable -private fun attestationStatusLabel( - status: AttestationStatus?, - validity: Validity?, -): String = +private fun attestationStatusLabel(status: AttestationStatus?): String = when { - validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) - validity == Validity.VALID -> stringRes(R.string.attestation_valid) + status == AttestationStatus.INVALID -> stringRes(R.string.attestation_invalid) + status == AttestationStatus.VALID -> stringRes(R.string.attestation_valid) status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked) - status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected) - status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified) status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) - status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted) else -> stringRes(R.string.attestation) } 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..7e978be46 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt @@ -0,0 +1,120 @@ +/* + * 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() && + (bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://")) + ) { + 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() && + (authorPicture.startsWith("https://") || authorPicture.startsWith("http://")) + ) { + 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..292fb6729 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt @@ -0,0 +1,143 @@ +/* + * 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 + +private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)") +private val TRAILING_HASHES_REGEX = Regex("#+$") + +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 = HEADING_REGEX.find(trimmed) + if (match != null) { + val level = match.groupValues[1].length + val text = + match.groupValues[2] + .trim() + .replace(TRAILING_HASHES_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/editor/MarkdownEditorState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt new file mode 100644 index 000000000..7c69d6fdb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.compose.editor + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +/** + * State holder for a markdown editor with selection-aware formatting. + * + * Solves the focus/selection bug: toolbar buttons steal focus from TextField, + * collapsing the selection. We cache the last known selection on every value + * change, and toolbar operations use the cached selection. + */ +class MarkdownEditorState( + initial: String = "", +) { + var value by mutableStateOf(TextFieldValue(initial)) + private set + + /** Cached selection — updated on every onValueChange, survives focus loss. */ + var lastSelection: TextRange = TextRange.Zero + private set + + fun onValueChange(newValue: TextFieldValue) { + value = newValue + // Only cache non-zero selections (focus loss sends collapsed range) + if (newValue.selection.length > 0 || lastSelection == TextRange.Zero) { + lastSelection = newValue.selection + } + // Also cache cursor position when no selection + if (newValue.selection.collapsed) { + lastSelection = newValue.selection + } + } + + fun loadContent(content: String) { + value = TextFieldValue(content) + lastSelection = TextRange.Zero + } + + val text: String get() = value.text + + // --- Active state detection (uses current value.selection for display) --- + + val isBold: Boolean + get() = isWrapped("**", "**") + + val isItalic: Boolean + get() = isWrappedItalic() + + val isStrikethrough: Boolean + get() = isWrapped("~~", "~~") + + val isInlineCode: Boolean + get() = isWrapped("`", "`") + + val isBlockquote: Boolean + get() = isLinePrefix("> ") + + val isUnorderedList: Boolean + get() = isLinePrefix("- ") + + val isOrderedList: Boolean + get() { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + val line = text.substring(lineStart) + return line.matches(Regex("^\\d+\\.\\s.*")) + } + + val isTaskList: Boolean + get() = isLinePrefix("- [ ] ") || isLinePrefix("- [x] ") + + val headingLevel: Int? + get() { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + val line = text.substring(lineStart) + return when { + line.startsWith("### ") -> 3 + line.startsWith("## ") -> 2 + line.startsWith("# ") -> 1 + else -> null + } + } + + // --- Formatting operations (use lastSelection to survive focus loss) --- + + fun toggleBold() { + applyToggleWrap("**", "**") + } + + fun toggleItalic() { + applyToggleWrapItalic() + } + + fun toggleStrikethrough() { + applyToggleWrap("~~", "~~") + } + + fun toggleInlineCode() { + applyToggleWrap("`", "`") + } + + fun setHeading(level: Int?) { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + val line = text.substring(lineStart) + + // Remove existing heading prefix + val stripped = + when { + line.startsWith("### ") -> line.removePrefix("### ") + line.startsWith("## ") -> line.removePrefix("## ") + line.startsWith("# ") -> line.removePrefix("# ") + else -> line + } + val oldPrefixLen = + when { + line.startsWith("### ") -> 4 + line.startsWith("## ") -> 3 + line.startsWith("# ") -> 2 + else -> 0 + } + + val newPrefix = + when (level) { + 1 -> "# " + 2 -> "## " + 3 -> "### " + else -> "" + } + + val lineEnd = text.indexOf('\n', lineStart).let { if (it == -1) text.length else it } + val newText = text.substring(0, lineStart) + newPrefix + stripped + text.substring(lineEnd) + val shift = newPrefix.length - oldPrefixLen + + value = + TextFieldValue( + text = newText, + selection = TextRange((sel.min + shift).coerceAtLeast(lineStart), (sel.max + shift).coerceAtLeast(lineStart)), + ) + lastSelection = value.selection + } + + fun toggleBlockquote() { + applyToggleLinePrefix("> ") + } + + fun toggleUnorderedList() { + applyToggleLinePrefix("- ") + } + + fun toggleOrderedList() { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + val line = text.substring(lineStart) + + if (line.matches(Regex("^\\d+\\.\\s.*"))) { + // Remove ordered list prefix + val prefixEnd = line.indexOf(". ") + 2 + val newText = text.substring(0, lineStart) + line.substring(prefixEnd) + text.substring(lineStart + line.indexOf('\n').let { if (it == -1) line.length else it }) + value = + TextFieldValue( + text = text.substring(0, lineStart) + line.substring(prefixEnd), + selection = TextRange((sel.min - prefixEnd).coerceAtLeast(lineStart)), + ) + } else { + applyToggleLinePrefix("1. ") + } + lastSelection = value.selection + } + + fun toggleTaskList() { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + + when { + text.startsWith("- [ ] ", lineStart) -> { + // Remove task list prefix + val newText = text.substring(0, lineStart) + text.substring(lineStart + 6) + val shift = 6 + value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart))) + lastSelection = value.selection + } + + text.startsWith("- [x] ", lineStart) -> { + val newText = text.substring(0, lineStart) + text.substring(lineStart + 6) + val shift = 6 + value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart))) + lastSelection = value.selection + } + + else -> { + applyToggleLinePrefix("- [ ] ") + } + } + } + + fun toggleCodeBlock() { + applyToggleWrap("```\n", "\n```") + } + + fun insertHorizontalRule() { + val sel = lastSelection + val insert = "\n---\n" + val newText = text.substring(0, sel.min) + insert + text.substring(sel.max) + value = TextFieldValue(text = newText, selection = TextRange(sel.min + insert.length)) + lastSelection = value.selection + } + + fun insertLink() { + val sel = lastSelection + val selected = text.substring(sel.min, sel.max) + + if (selected.isNotEmpty()) { + val newText = text.substring(0, sel.min) + "[$selected](url)" + text.substring(sel.max) + val urlStart = sel.min + selected.length + 3 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } else { + val newText = text.substring(0, sel.min) + "[](url)" + text.substring(sel.min) + value = TextFieldValue(text = newText, selection = TextRange(sel.min + 1)) + } + lastSelection = value.selection + } + + fun insertImage() { + val sel = lastSelection + val selected = text.substring(sel.min, sel.max) + + if (selected.isNotEmpty()) { + val newText = text.substring(0, sel.min) + "![$selected](url)" + text.substring(sel.max) + val urlStart = sel.min + selected.length + 4 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } else { + val newText = text.substring(0, sel.min) + "![alt](url)" + text.substring(sel.min) + val urlStart = sel.min + 7 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } + lastSelection = value.selection + } + + // --- Private helpers --- + + private fun isWrapped( + prefix: String, + suffix: String, + ): Boolean { + val sel = value.selection + val start = sel.min + val end = sel.max + return start >= prefix.length && + end + suffix.length <= text.length && + text.substring(start - prefix.length, start) == prefix && + text.substring(end, end + suffix.length) == suffix + } + + private fun isWrappedItalic(): Boolean { + val sel = value.selection + val start = sel.min + val end = sel.max + if (start < 1 || end + 1 > text.length) return false + if (text[start - 1] != '*' || text[end] != '*') return false + val hasBoldBefore = start >= 2 && text[start - 2] == '*' + val hasBoldAfter = end + 1 < text.length && text[end + 1] == '*' + return !hasBoldBefore && !hasBoldAfter + } + + private fun isLinePrefix(prefix: String): Boolean { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + return text.startsWith(prefix, lineStart) + } + + private fun applyToggleWrap( + prefix: String, + suffix: String, + ) { + val sel = lastSelection + val start = sel.min + val end = sel.max + + val wrapped = + start >= prefix.length && + end + suffix.length <= text.length && + text.substring(start - prefix.length, start) == prefix && + text.substring(end, end + suffix.length) == suffix + + value = + if (wrapped) { + val newText = + text.substring(0, start - prefix.length) + + text.substring(start, end) + + text.substring(end + suffix.length) + TextFieldValue(newText, TextRange(start - prefix.length, end - prefix.length)) + } else if (start == end) { + val newText = text.substring(0, start) + prefix + suffix + text.substring(start) + TextFieldValue(newText, TextRange(start + prefix.length)) + } else { + val newText = text.substring(0, start) + prefix + text.substring(start, end) + suffix + text.substring(end) + TextFieldValue(newText, TextRange(start + prefix.length, end + prefix.length)) + } + lastSelection = value.selection + } + + private fun applyToggleWrapItalic() { + val sel = lastSelection + val start = sel.min + val end = sel.max + + val isItalic = + start >= 1 && + end + 1 <= text.length && + text[start - 1] == '*' && + text[end] == '*' && + !(start >= 2 && text[start - 2] == '*') && + !(end + 1 < text.length && text[end + 1] == '*') + + if (isItalic) { + val newText = text.substring(0, start - 1) + text.substring(start, end) + text.substring(end + 1) + value = TextFieldValue(newText, TextRange(start - 1, end - 1)) + } else { + applyToggleWrap("*", "*") + return + } + lastSelection = value.selection + } + + private fun applyToggleLinePrefix(prefix: String) { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + + value = + if (text.startsWith(prefix, lineStart)) { + val newText = text.substring(0, lineStart) + text.substring(lineStart + prefix.length) + val shift = prefix.length + TextFieldValue(newText, TextRange((sel.min - shift).coerceAtLeast(lineStart), (sel.max - shift).coerceAtLeast(lineStart))) + } else { + val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart) + TextFieldValue(newText, TextRange(sel.min + prefix.length, sel.max + prefix.length)) + } + lastSelection = value.selection + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt new file mode 100644 index 000000000..4709625b1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.compose.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.FormatBold +import androidx.compose.material.icons.filled.FormatItalic +import androidx.compose.material.icons.filled.FormatListBulleted +import androidx.compose.material.icons.filled.FormatListNumbered +import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.filled.FormatStrikethrough +import androidx.compose.material.icons.filled.HorizontalRule +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Markdown toolbar with Material icons, grouped formatting buttons, and active state. + * Uses [MarkdownEditorState] for selection-aware toggle behavior. + * + * Buttons use `focusProperties { canFocus = false }` to prevent stealing focus + * from the editor TextField, preserving the user's text selection. + */ +@Composable +fun MarkdownToolbar( + state: MarkdownEditorState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // --- Headings --- + ToolbarButton(label = "H1", active = state.headingLevel == 1) { state.setHeading(if (state.headingLevel == 1) null else 1) } + ToolbarButton(label = "H2", active = state.headingLevel == 2) { state.setHeading(if (state.headingLevel == 2) null else 2) } + ToolbarButton(label = "H3", active = state.headingLevel == 3) { state.setHeading(if (state.headingLevel == 3) null else 3) } + + Separator() + + // --- Inline formatting --- + ToolbarIconButton(Icons.Default.FormatBold, "Bold", state.isBold) { state.toggleBold() } + ToolbarIconButton(Icons.Default.FormatItalic, "Italic", state.isItalic) { state.toggleItalic() } + ToolbarIconButton(Icons.Default.FormatStrikethrough, "Strikethrough", state.isStrikethrough) { state.toggleStrikethrough() } + ToolbarIconButton(Icons.Default.Code, "Inline code", state.isInlineCode) { state.toggleInlineCode() } + + Separator() + + // --- Lists --- + ToolbarIconButton(Icons.Default.FormatListBulleted, "Bullet list", state.isUnorderedList) { state.toggleUnorderedList() } + ToolbarIconButton(Icons.Default.FormatListNumbered, "Numbered list", state.isOrderedList) { state.toggleOrderedList() } + ToolbarIconButton(Icons.Default.Checklist, "Task list", state.isTaskList) { state.toggleTaskList() } + + Separator() + + // --- Block elements --- + ToolbarIconButton(Icons.Default.FormatQuote, "Blockquote", state.isBlockquote) { state.toggleBlockquote() } + ToolbarButton(label = "```", active = false) { state.toggleCodeBlock() } + ToolbarIconButton(Icons.Default.HorizontalRule, "Horizontal rule", false) { state.insertHorizontalRule() } + + Separator() + + // --- Insert --- + ToolbarIconButton(Icons.Default.Link, "Link", false) { state.insertLink() } + ToolbarIconButton(Icons.Default.Image, "Image", false) { state.insertImage() } + } +} + +@Composable +private fun Separator() { + VerticalDivider( + modifier = Modifier.height(24.dp).padding(horizontal = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) +} + +@Composable +private fun ToolbarIconButton( + icon: ImageVector, + contentDescription: String, + active: Boolean, + onClick: () -> Unit, +) { + val containerColor = + if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (active) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + SmallFloatingActionButton( + onClick = onClick, + containerColor = containerColor, + contentColor = contentColor, + modifier = + Modifier + .size(32.dp) + .focusProperties { canFocus = false }, + ) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun ToolbarButton( + label: String, + active: Boolean, + onClick: () -> Unit, +) { + val containerColor = + if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (active) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + SmallFloatingActionButton( + onClick = onClick, + containerColor = containerColor, + contentColor = contentColor, + modifier = + Modifier + .size(32.dp) + .focusProperties { canFocus = false }, + ) { + Text( + text = label, + fontSize = 11.sp, + color = contentColor, + ) + } +} 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 new file mode 100644 index 000000000..28a22e080 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.compose.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.unit.dp + +/** + * Form fields for article metadata: title, summary, banner, tags, slug. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MetadataPanel( + title: String, + onTitleChange: (String) -> Unit, + summary: String, + onSummaryChange: (String) -> Unit, + bannerUrl: String, + onBannerUrlChange: (String) -> Unit, + tags: List, + onTagsChange: (List) -> Unit, + slug: String, + onSlugChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var tagInput by remember { mutableStateOf("") } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = { if (it.length <= 256) onTitleChange(it) }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("${title.length}/256") }, + ) + + OutlinedTextField( + value = summary, + onValueChange = { if (it.length <= 1024) onSummaryChange(it) }, + label = { Text("Summary") }, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("${summary.length}/1024") }, + ) + + OutlinedTextField( + value = bannerUrl, + onValueChange = onBannerUrlChange, + label = { Text("Banner Image URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Tags chip input + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = tagInput, + onValueChange = { tagInput = it }, + label = { Text("Add tag (Enter to add)") }, + singleLine = true, + modifier = + Modifier.weight(1f).onKeyEvent { event -> + if (event.key == Key.Enter && tagInput.isNotBlank()) { + val newTag = tagInput.trim().lowercase() + if (newTag !in tags) { + onTagsChange(tags + newTag) + } + tagInput = "" + true + } else { + false + } + }, + ) + } + + if (tags.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) { + tags.forEach { tag -> + AssistChip( + onClick = { onTagsChange(tags - tag) }, + label = { Text(tag) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = "Remove $tag", + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } + } + } + + OutlinedTextField( + value = slug, + onValueChange = onSlugChange, + label = { Text("Slug (d-tag)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("Used as the unique identifier for this article") }, + ) + } +} 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..c31109d4a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.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.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.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.unit.Density +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", "highlight") + +/** + * Escapes markdown special characters inside highlighted text so it doesn't + * break the markdown parser when wrapped in a link. + */ +private fun escapeMarkdownInLink(text: String): String = + text + .replace("[", "\\[") + .replace("]", "\\]") + .replace("(", "\\(") + .replace(")", "\\)") + +@Composable +fun RenderMarkdown( + content: String, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier, + fontScale: Float = 1.0f, + highlightedTexts: List = emptyList(), +) { + val processedContent = + remember(content, highlightedTexts) { + if (highlightedTexts.isEmpty()) { + content + } else { + var result = content + highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text -> + val idx = result.indexOf(text) + if (idx >= 0) { + val escaped = escapeMarkdownInLink(text) + result = result.replaceFirst(text, "[$escaped](highlight://$index)") + } + } + result + } + } + + val astNode = + remember(processedContent) { + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent) + } + + val uriHandler = + remember(onLinkClick) { + object : UriHandler { + override fun openUri(uri: String) { + val scheme = uri.substringBefore(":").lowercase() + if (scheme in ALLOWED_SCHEMES) { + onLinkClick(uri) + } + } + } + } + + val currentDensity = LocalDensity.current + val scaledDensity = + remember(fontScale, currentDensity) { + if (fontScale == 1.0f) { + currentDensity + } else { + Density( + density = currentDensity.density * fontScale, + fontScale = currentDensity.fontScale, + ) + } + } + + CompositionLocalProvider( + LocalUriHandler provides uriHandler, + LocalDensity provides scaledDensity, + ) { + RichText( + modifier = modifier, + style = RichTextStyle(), + ) { + BasicMarkdown(astNode) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt similarity index 62% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt index 62b539920..4e1d86628 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt @@ -18,24 +18,15 @@ * 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.experimental.attestations.recommendation.tags +package com.vitorpamplona.amethyst.commons.model.highlights -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure - -class DescriptionTag { - companion object { - const val TAG_NAME = "desc" - - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() - - fun parse(tag: Array): String? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - return tag[1] - } - - fun assemble(description: String) = arrayOf(TAG_NAME, description) - } -} +data class HighlightData( + val id: String, + val text: String, + val note: String? = null, + val articleAddressTag: String, + val articleTitle: String? = null, + val createdAt: Long, + val published: Boolean = false, + val eventId: String? = null, +) 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 new file mode 100644 index 000000000..d9c151b01 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip23LongContent + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Shared action for publishing long-form content (NIP-23 kind 30023). + * Handles title, summary, image, tags, and d-tag for addressable events. + */ +object LongFormPublishAction { + private const val MAX_CONTENT_BYTES = 100_000 + + /** + * Publishes a long-form text note (NIP-23 kind 30023). + * + * @param title The article title + * @param content The markdown body content + * @param summary Optional article summary + * @param image Optional banner image URL + * @param tags List of hashtag topics + * @param dTag Unique identifier for this addressable event (slug) + * @param signer The NostrSigner to sign the event + * @return Signed LongTextNoteEvent ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun publish( + title: String, + content: String, + summary: String?, + image: String?, + tags: List, + dTag: String, + signer: NostrSigner, + ): LongTextNoteEvent { + if (!signer.isWriteable()) { + 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, + title = title, + summary = summary, + image = image, + publishedAt = TimeUtils.now(), + dTag = dTag, + ) { + tags.forEach { hashtag(it) } + } + + return signer.sign(template) + } +} 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..b326186bd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt @@ -0,0 +1,84 @@ +/* + * 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 + + 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 + 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(WHITESPACE_REGEX).count { it.isNotBlank() } + return@forEach + } + + // Count images + val imageMatches = IMAGE_REGEX.findAll(trimmed) + imageCount += imageMatches.count() + + // Strip markdown syntax for word counting + val stripped = + trimmed + .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(WHITESPACE_REGEX).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..dcc369138 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator @@ -143,6 +144,16 @@ sealed class DesktopScreen { val noteId: String, ) : DesktopScreen() + data class Article( + val addressTag: String, + ) : DesktopScreen() + + data class Editor( + val draftSlug: String? = null, + ) : DesktopScreen() + + data object Drafts : DesktopScreen() + data object Settings : DesktopScreen() } @@ -369,6 +380,8 @@ fun main() { Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) }) + Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) }) Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) @@ -597,6 +610,13 @@ fun MainContent( DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) } + val highlightStore = remember { DesktopHighlightStore(appScope) } + val draftStore = + remember { + com.vitorpamplona.amethyst.desktop.service.drafts + .DesktopDraftStore(appScope) + } + // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() } @@ -716,6 +736,8 @@ fun MainContent( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, @@ -753,6 +775,8 @@ fun MainContent( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, 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 new file mode 100644 index 000000000..ac0dbaade --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.drafts + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermission +import java.time.Instant + +data class DraftMetadata( + val title: String = "", + val summary: String? = null, + val image: String? = null, + val tags: List = emptyList(), + val createdAt: String = Instant.now().toString(), + val updatedAt: String = Instant.now().toString(), + val published: Boolean = false, +) + +data class DraftEntry( + val slug: String, + val metadata: DraftMetadata, +) + +/** + * Local draft storage for long-form articles. + * Stores markdown content as .md files and metadata in index.json. + * Uses atomic writes and restrictive file permissions. + */ +class DesktopDraftStore( + private val scope: CoroutineScope, +) { + private val mapper = jacksonObjectMapper() + private val mutex = Mutex() + private var cachedIndex: MutableMap? = null + + private val _drafts = MutableStateFlow>(emptyList()) + val drafts: StateFlow> = _drafts.asStateFlow() + + private val draftsDir: File by lazy { + val dir = File(System.getProperty("user.home"), ".amethyst/drafts") + if (!dir.exists()) { + dir.mkdirs() + setDirPermissions(dir) + } + dir + } + + private val indexFile: File get() = File(draftsDir, "index.json") + + init { + scope.launch(Dispatchers.IO) { + cachedIndex = null + loadIndex() + } + } + + /** + * Sanitizes a slug to prevent path traversal and ensure safe filenames. + */ + private fun sanitizeSlug(slug: String): String { + val sanitized = + slug + .replace("/", "") + .replace("\\", "") + .replace("\u0000", "") + .trim() + .lowercase() + .replace(Regex("[^a-z0-9_-]"), "-") + .replace(Regex("-+"), "-") + .trimStart('-') + .trimEnd('-') + .take(128) + + require(sanitized.isNotEmpty()) { "Slug cannot be empty after sanitization" } + + // Validate canonical path stays within drafts dir + val resolved = File(draftsDir, "$sanitized.md").canonicalPath + require(resolved.startsWith(draftsDir.canonicalPath)) { + "Slug resolves outside drafts directory" + } + + return sanitized + } + + /** + * Generates a slug from a title. Falls back to timestamp if title is blank. + */ + fun slugFromTitle(title: String): String { + if (title.isBlank()) return "untitled-${Instant.now().epochSecond}" + return sanitizeSlug(title) + } + + /** + * Saves or updates a draft. Creates content file and updates index atomically. + */ + suspend fun saveDraft( + slug: String, + content: String, + metadata: DraftMetadata, + ) { + val safeSlug = sanitizeSlug(slug) + + mutex.withLock { + // Write content file atomically + val contentFile = File(draftsDir, "$safeSlug.md") + atomicWrite(contentFile, content) + + // Update index + val index = loadIndexMap() + index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString()) + atomicWriteIndex(index) + cachedIndex = index + + // Refresh state + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + /** + * Loads a draft's content by slug. + */ + suspend fun loadContent(slug: String): String? { + val safeSlug = sanitizeSlug(slug) + val file = File(draftsDir, "$safeSlug.md") + return if (file.exists()) file.readText() else null + } + + /** + * Loads a draft's metadata by slug. + */ + suspend fun loadMetadata(slug: String): DraftMetadata? { + val safeSlug = sanitizeSlug(slug) + return mutex.withLock { + loadIndexMap()[safeSlug] + } + } + + /** + * Deletes a draft by slug. + */ + suspend fun deleteDraft(slug: String) { + val safeSlug = sanitizeSlug(slug) + mutex.withLock { + File(draftsDir, "$safeSlug.md").delete() + + val index = loadIndexMap() + index.remove(safeSlug) + atomicWriteIndex(index) + cachedIndex = index + + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + /** + * Marks a draft as published. + */ + suspend fun markPublished(slug: String) { + val safeSlug = sanitizeSlug(slug) + mutex.withLock { + val index = loadIndexMap() + val existing = index[safeSlug] ?: return + index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString()) + atomicWriteIndex(index) + cachedIndex = index + + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + 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() { + mutex.withLock { + _drafts.value = + loadIndexMap() + .entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + private fun atomicWrite( + file: File, + content: String, + ) { + val tempFile = File(file.parentFile, "${file.name}.tmp") + try { + tempFile.writeText(content) + setFilePermissions(tempFile) + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } finally { + if (tempFile.exists()) tempFile.delete() + } + } + + private fun atomicWriteIndex(index: Map) { + val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(index) + atomicWrite(indexFile, json) + } + + private fun setDirPermissions(dir: File) { + try { + Files.setPosixFilePermissions( + dir.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } + } + + private fun setFilePermissions(file: File) { + try { + Files.setPosixFilePermissions( + file.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt new file mode 100644 index 000000000..16f238ffd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.highlights + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.time.Instant +import java.util.UUID + +/** + * Local highlight storage for article annotations. + * Stores highlights as JSON in ~/.amethyst/highlights/index.json. + * Uses atomic writes following the same pattern as DesktopDraftStore. + */ +class DesktopHighlightStore( + private val scope: CoroutineScope, +) { + private val mapper = jacksonObjectMapper() + private val mutex = Mutex() + + private val _highlights = MutableStateFlow>>(emptyMap()) + val highlights: StateFlow>> = _highlights.asStateFlow() + + private val highlightsDir: File by lazy { + val dir = File(System.getProperty("user.home"), ".amethyst/highlights") + if (!dir.exists()) { + dir.mkdirs() + } + dir + } + + private val indexFile: File get() = File(highlightsDir, "index.json") + + init { + scope.launch(Dispatchers.IO) { + loadIndex() + } + } + + suspend fun addHighlight( + articleAddressTag: String, + text: String, + note: String?, + articleTitle: String?, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + val articleHighlights = current.getOrDefault(articleAddressTag, emptyList()).toMutableList() + + // Avoid duplicate highlights of the same text + if (articleHighlights.any { it.text == text }) return + + articleHighlights.add( + HighlightData( + id = UUID.randomUUID().toString(), + text = text, + note = note, + articleAddressTag = articleAddressTag, + articleTitle = articleTitle, + createdAt = Instant.now().epochSecond, + ), + ) + current[articleAddressTag] = articleHighlights + _highlights.value = current + saveIndex(current) + } + } + + suspend fun updateNote( + highlightId: String, + note: String, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val idx = list.indexOfFirst { it.id == highlightId } + if (idx >= 0) { + current[key] = + list.toMutableList().apply { + set(idx, get(idx).copy(note = note)) + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + suspend fun removeHighlight(highlightId: String) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val filtered = list.filter { it.id != highlightId } + if (filtered.size != list.size) { + if (filtered.isEmpty()) { + current.remove(key) + } else { + current[key] = filtered + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + suspend fun markPublished( + highlightId: String, + eventId: String, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val idx = list.indexOfFirst { it.id == highlightId } + if (idx >= 0) { + current[key] = + list.toMutableList().apply { + set(idx, get(idx).copy(published = true, eventId = eventId)) + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + fun getHighlightsForArticle(addressTag: String): List = _highlights.value[addressTag] ?: emptyList() + + fun getAllHighlights(): Map> = _highlights.value + + private suspend fun loadIndex() { + mutex.withLock { + if (indexFile.exists()) { + try { + val data: Map> = mapper.readValue(indexFile) + _highlights.value = data + } catch (e: Exception) { + // Corrupted file — start fresh + _highlights.value = emptyMap() + } + } + } + } + + private fun saveIndex(data: Map>) { + try { + val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(data) + val tempFile = File(highlightsDir, "index.json.tmp") + tempFile.writeText(json) + Files.move( + tempFile.toPath(), + indexFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } catch (_: Exception) { + // Best effort — don't crash on write failure + } + } +} 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/subscriptions/ProfileSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt index a6bdbc908..da9f93413 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt @@ -104,7 +104,11 @@ fun createUserPostsSubscription( ): SubscriptionConfig = SubscriptionConfig( subId = generateSubId("posts-${pubKeyHex.take(8)}"), - filters = listOf(FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit)), + filters = + listOf( + FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit), + FilterBuilders.longFormFromAuthors(listOf(pubKeyHex), limit = 50), + ), relays = relays, onEvent = onEvent, onEose = onEose, 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 new file mode 100644 index 000000000..c0debf9ac --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownEditorState +import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar +import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel +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 ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning") + +@Composable +fun ArticleEditorScreen( + draftSlug: String?, + draftStore: DesktopDraftStore, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + onBack: () -> Unit, + onPublished: () -> Unit, +) { + val scope = rememberCoroutineScope() + + var title by remember { mutableStateOf("") } + var summary by remember { mutableStateOf("") } + var bannerUrl by remember { mutableStateOf("") } + var tags by remember { mutableStateOf>(emptyList()) } + var slug by remember { mutableStateOf(draftSlug ?: "") } + val editorState = remember { MarkdownEditorState() } + var publishing by remember { mutableStateOf(false) } + var saveMessage by remember { mutableStateOf(null) } + var debouncedContent by remember { mutableStateOf("") } + + LaunchedEffect(editorState.text) { + delay(300) + debouncedContent = editorState.text + } + + // Load existing draft + LaunchedEffect(draftSlug) { + if (draftSlug != null) { + val meta = draftStore.loadMetadata(draftSlug) + val body = draftStore.loadContent(draftSlug) + if (meta != null) { + title = meta.title + summary = meta.summary ?: "" + bannerUrl = meta.image ?: "" + tags = meta.tags + slug = draftSlug + } + if (body != null) { + editorState.loadContent(body) + } + } + } + + // Auto-generate slug from title if creating a new draft + LaunchedEffect(title) { + if (draftSlug == null && title.isNotBlank()) { + slug = draftStore.slugFromTitle(title) + } + } + + val onLinkClick: (String) -> Unit = + remember { + { url: String -> + val scheme = url.substringBefore(":").lowercase() + if (scheme in ALLOWED_SCHEMES) { + try { + Desktop.getDesktop().browse(URI(url)) + } catch (_: Exception) { + // Ignore unsupported or malformed URLs + } + } + } + } + + fun saveDraft() { + if (slug.isBlank()) return + scope.launch { + draftStore.saveDraft( + slug = slug, + content = editorState.text, + metadata = + DraftMetadata( + title = title, + summary = summary.ifBlank { null }, + image = bannerUrl.ifBlank { null }, + tags = tags, + ), + ) + saveMessage = "Saved" + } + } + + fun publishArticle() { + if (publishing) return + publishing = true + scope.launch { + try { + val event = + LongFormPublishAction.publish( + title = title, + content = editorState.text, + summary = summary.ifBlank { null }, + image = bannerUrl.ifBlank { null }, + tags = tags, + 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() + } catch (e: Exception) { + saveMessage = "Publish failed: ${e.message}" + } finally { + publishing = false + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.isMetaPressed) { + when (event.key) { + Key.S -> { + saveDraft() + true + } + + Key.B -> { + editorState.toggleBold() + true + } + + Key.I -> { + editorState.toggleItalic() + true + } + + Key.E -> { + editorState.toggleInlineCode() + true + } + + Key.K -> { + editorState.insertLink() + true + } + + else -> { + false + } + } + } else { + false + } + }, + ) { + // Top bar + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onBack) { + Text("Back") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + saveMessage?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + OutlinedButton(onClick = { saveDraft() }) { + Text("Save") + } + Button( + onClick = { publishArticle() }, + enabled = !publishing && title.isNotBlank() && editorState.text.isNotBlank(), + ) { + Text(if (publishing) "Publishing..." else "Publish") + } + } + } + + // Metadata panel (collapsible) + MetadataPanel( + title = title, + onTitleChange = { title = it }, + summary = summary, + onSummaryChange = { summary = it }, + bannerUrl = bannerUrl, + onBannerUrlChange = { bannerUrl = it }, + tags = tags, + onTagsChange = { tags = it }, + slug = slug, + onSlugChange = { slug = it }, + ) + + Spacer(Modifier.height(8.dp)) + + // Markdown toolbar — selection-aware toggle behavior + MarkdownToolbar(state = editorState) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Split pane: source left, preview right + Row( + modifier = Modifier.fillMaxSize().weight(1f), + ) { + // Source editor + TextField( + value = editorState.value, + onValueChange = { + editorState.onValueChange(it) + saveMessage = null + }, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(end = 4.dp), + textStyle = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + ), + placeholder = { Text("Write your article in markdown...") }, + ) + + VerticalDivider() + + // Preview + SelectionContainer { + Column( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column(modifier = Modifier.widthIn(max = 680.dp)) { + if (debouncedContent.isNotBlank()) { + RenderMarkdown( + content = debouncedContent, + onLinkClick = onLinkClick, + ) + } else { + Text( + "Preview will appear here...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} 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..4bd362e90 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -0,0 +1,736 @@ +/* + * 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.ContextMenuItem +import androidx.compose.foundation.ContextMenuRepresentation +import androidx.compose.foundation.ContextMenuState +import androidx.compose.foundation.LocalContextMenuRepresentation +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +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.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +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.CompositionLocalProvider +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalClipboardManager +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.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.service.highlights.DesktopHighlightStore +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.createReactionsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.highlights.ArticleHighlightsPanel +import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val articleDateFormat = DateTimeFormatter.ofPattern("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?, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + highlightStore: DesktopHighlightStore? = null, + onBack: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> 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) } + + // Zoom level for article text + var zoomLevel by remember { mutableStateOf(1.0f) } + + // Coroutine scope for highlight operations + val scope = rememberCoroutineScope() + + // Highlight state — collect outside let to ensure proper Compose subscription + val allHighlights by (highlightStore?.highlights ?: kotlinx.coroutines.flow.MutableStateFlow(emptyMap())) + .collectAsState() + val articleHighlights = allHighlights[addressTag] ?: emptyList() + + var showAnnotationDialog by remember { mutableStateOf(null) } + var showHighlightsPanel by remember { mutableStateOf(false) } + val focusRequester = + remember { + androidx.compose.ui.focus + .FocusRequester() + } + + // Active ToC entry tracking (placeholder — no scroll-position-based tracking yet) + var activeTocIndex by remember { mutableStateOf(null) } + + // Link click handler for markdown + val onLinkClick: (String) -> Unit = + remember(articleHighlights) { + { url: String -> + when { + url.startsWith("highlight://") -> { + showHighlightsPanel = true + } + + 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()}", + filters = listOf(FilterBuilders.longFormByAddress(pubkey, dTag)), + 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 + }, + ) + } + + // Interaction state + val articleEventId = article?.id + val eventIds = listOfNotNull(articleEventId) + + var zapReceipts by remember { mutableStateOf>(emptyList()) } + var reactionCount by remember { mutableStateOf(0) } + var replyCount by remember { mutableStateOf(0) } + var repostCount by remember { mutableStateOf(0) } + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // Subscribe to zaps + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + createZapsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent) { + val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription + if (zapReceipts.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { + zapReceipts = zapReceipts + receipt + } + } + }, + ) + } + + // Subscribe to reactions + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val reactionIds = mutableSetOf() + createReactionsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is ReactionEvent && reactionIds.add(event.id)) { + reactionCount = reactionIds.size + } + }, + ) + } + + // Subscribe to replies + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val replyIds = mutableSetOf() + createRepliesSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (replyIds.add(event.id)) { + replyCount = replyIds.size + } + }, + ) + } + + // Subscribe to reposts + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val repostIds = mutableSetOf() + createRepostsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is RepostEvent && repostIds.add(event.id)) { + repostCount = repostIds.size + } + }, + ) + } + + // Subscribe to bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = "article-bookmarks-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + bookmarkedEventIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // 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 + Instant + .ofEpochSecond(ts) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(articleDateFormat) + } + + // Author info from local cache + val authorUser = article?.let { localCache.getOrCreateUser(it.pubKey) } + val authorName = authorUser?.toBestDisplayName() + val authorPicture = authorUser?.profilePicture() + + val clipboardManager = LocalClipboardManager.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.isMetaPressed) { + when (event.key) { + Key.Equals -> { + zoomLevel = (zoomLevel + 0.1f).coerceAtMost(2.0f) + true + } + + Key.Minus -> { + zoomLevel = (zoomLevel - 0.1f).coerceAtLeast(0.5f) + true + } + + Key.Zero -> { + zoomLevel = 1.0f + true + } + + else -> { + false + } + } + } else { + false + } + }, + ) { + // 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, + ) + if (zoomLevel != 1.0f) { + Spacer(Modifier.width(8.dp)) + Text( + "${(zoomLevel * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // 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, + ) + + // Collapsible highlights section + if (articleHighlights.isNotEmpty()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + showHighlightsPanel = !showHighlightsPanel + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (showHighlightsPanel) { + Icons.Default.KeyboardArrowDown + } else { + Icons.AutoMirrored.Filled.KeyboardArrowRight + }, + contentDescription = "Toggle highlights", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "${articleHighlights.size} highlight${if (articleHighlights.size != 1) "s" else ""}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (showHighlightsPanel && highlightStore != null) { + ArticleHighlightsPanel( + highlights = articleHighlights, + highlightStore = highlightStore, + articleContent = content, + signer = account?.signer, + relayManager = relayManager, + modifier = Modifier.padding(bottom = 16.dp), + ) + HorizontalDivider( + modifier = Modifier.padding(bottom = 16.dp), + thickness = 1.dp, + ) + } + } + + // Markdown body with right-click highlight via context menu + val defaultRepresentation = LocalContextMenuRepresentation.current + val highlightRepresentation = + remember( + defaultRepresentation, + highlightStore, + addressTag, + title, + ) { + HighlightContextMenuRepresentation( + delegate = defaultRepresentation, + clipboardManager = clipboardManager, + onHighlight = { text -> + scope.launch { + highlightStore?.addHighlight( + articleAddressTag = addressTag, + text = text, + note = null, + articleTitle = title, + ) + } + }, + onHighlightWithNote = { text -> + showAnnotationDialog = text + }, + ) + } + + CompositionLocalProvider( + LocalContextMenuRepresentation provides highlightRepresentation, + ) { + SelectionContainer { + RenderMarkdown( + content = content, + onLinkClick = onLinkClick, + fontScale = zoomLevel, + highlightedTexts = articleHighlights.map { it.text }, + ) + } + } + + 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) + + // Reaction actions + val art = article + if (art != null && account != null) { + Spacer(Modifier.height(16.dp)) + NoteActionsRow( + event = art, + relayManager = relayManager, + localCache = localCache, + account = account, + onReplyClick = { onNavigateToThread(art.id) }, + onZapFeedback = onZapFeedback, + zapCount = zapReceipts.size, + zapAmountSats = zapReceipts.sumOf { it.amountSats }, + zapReceipts = zapReceipts, + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + nwcConnection = nwcConnection, + isBookmarked = articleEventId in bookmarkedEventIds, + bookmarkList = bookmarkList, + onBookmarkChanged = { newList -> + bookmarkList = newList + bookmarkedEventIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(Modifier.height(48.dp)) + } + } + } + } + } + } + } + + showAnnotationDialog?.let { selectedText -> + HighlightAnnotationDialog( + selectedText = selectedText, + onConfirm = { note -> + scope.launch { + highlightStore?.addHighlight( + articleAddressTag = addressTag, + text = selectedText, + note = note, + articleTitle = title, + ) + } + showAnnotationDialog = null + }, + onDismiss = { showAnnotationDialog = null }, + ) + } +} + +/** + * Custom context menu representation that adds "Highlight" and "Highlight with Note" + * items to the right-click menu inside a SelectionContainer. + * + * How it works: The SelectionContainer provides a "Copy" item that has access to the + * selected text. Our items piggyback on Copy's onClick — calling it first to put the + * selected text on the clipboard, then reading the clipboard to get the text. + */ +private class HighlightContextMenuRepresentation( + private val delegate: ContextMenuRepresentation, + private val clipboardManager: androidx.compose.ui.platform.ClipboardManager, + private val onHighlight: (String) -> Unit, + private val onHighlightWithNote: (String) -> Unit, +) : ContextMenuRepresentation { + @Composable + override fun Representation( + state: ContextMenuState, + items: () -> List, + ) { + val extendedItems = { + val original = items() + val copyItem = original.find { it.label == "Copy" } + + if (copyItem != null) { + original + + listOf( + ContextMenuItem("Highlight") { + copyItem.onClick() + val text = clipboardManager.getText()?.text + if (!text.isNullOrBlank()) { + onHighlight(text) + } + }, + ContextMenuItem("Highlight with Note") { + copyItem.onClick() + val text = clipboardManager.getText()?.text + if (!text.isNullOrBlank()) { + onHighlightWithNote(text) + } + }, + ) + } else { + original + } + } + + delegate.Representation(state, extendedItems) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt new file mode 100644 index 000000000..64d383860 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry +import kotlinx.coroutines.launch + +@Composable +fun DraftsScreen( + draftStore: DesktopDraftStore, + onOpenEditor: (slug: String?) -> Unit, +) { + val drafts by draftStore.drafts.collectAsState() + val scope = rememberCoroutineScope() + var deleteTarget by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Drafts", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Button(onClick = { onOpenEditor(null) }) { + Icon(Icons.Default.Add, contentDescription = null) + Text("New Draft", modifier = Modifier.padding(start = 4.dp)) + } + } + + Spacer(Modifier.height(16.dp)) + + if (drafts.isEmpty()) { + Text( + "No drafts yet. Click \"New Draft\" to start writing.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(drafts, key = { it.slug }) { entry -> + DraftCard( + entry = entry, + onClick = { onOpenEditor(entry.slug) }, + onDelete = { deleteTarget = entry }, + ) + } + } + } + } + + // Delete confirmation dialog + deleteTarget?.let { entry -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete Draft") }, + text = { + Text( + "Delete \"${entry.metadata.title.ifBlank { entry.slug }}\"? This cannot be undone.", + ) + }, + confirmButton = { + TextButton(onClick = { + scope.launch { draftStore.deleteDraft(entry.slug) } + deleteTarget = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun DraftCard( + entry: DraftEntry, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.metadata.title.ifBlank { "Untitled" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = entry.metadata.updatedAt.take(10), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (entry.metadata.published) { + Text( + text = "Published", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete draft", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} 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..2d5aba286 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 @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -68,13 +69,20 @@ 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 com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +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. @@ -171,8 +179,11 @@ fun ReadsScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, onNavigateToProfile: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val scope = rememberCoroutineScope() @@ -357,12 +368,27 @@ fun ReadsScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(events, key = { it.id }) { event -> - LongFormCard( - event = event, - localCache = localCache, - onAuthorClick = onNavigateToProfile, - onClick = { onNavigateToArticle(event.id) }, - ) + Column { + LongFormCard( + event = event, + localCache = localCache, + onAuthorClick = onNavigateToProfile, + onClick = { onNavigateToArticle(event.addressTag()) }, + ) + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onNavigateToThread(event.id) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } + HorizontalDivider(thickness = 1.dp) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 15f0a0de6..c79b4e368 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -96,7 +96,9 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -118,6 +120,7 @@ fun UserProfileScreen( onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, + onNavigateToArticle: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -157,6 +160,8 @@ fun UserProfileScreen( var selectedTab by remember { mutableStateOf(0) } var lightboxState by remember { mutableStateOf(null) } val pictureEvents = remember { mutableStateListOf() } + val articleEvents = remember { mutableStateListOf() } + val highlightEvents = remember { mutableStateListOf() } // Follow state val followState = @@ -344,6 +349,60 @@ fun UserProfileScreen( } } + // Subscribe to long-form articles (kind 30023) for reads tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + articleEvents.clear() + SubscriptionConfig( + subId = generateSubId("articles-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 50, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent && articleEvents.none { it.id == event.id }) { + articleEvents.add(event) + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Subscribe to highlight events (kind 9802) for highlights tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + highlightEvents.clear() + SubscriptionConfig( + subId = generateSubId("hl-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(HighlightEvent.KIND), + limit = 100, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is HighlightEvent && highlightEvents.none { it.id == event.id }) { + highlightEvents.add(event) + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Scroll state for detecting scroll direction val listState = rememberLazyListState() var showFloatingHeader by remember { mutableStateOf(false) } @@ -629,8 +688,20 @@ fun UserProfileScreen( Text("Notes", modifier = Modifier.padding(12.dp)) } Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text( + "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { Text("Gallery", modifier = Modifier.padding(12.dp)) } + Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { + Text( + "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } } } @@ -726,6 +797,38 @@ fun UserProfileScreen( } 1 -> { + if (articleEvents.isEmpty()) { + item(key = "no-articles") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No long-form articles", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + items( + articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt }, + key = { "art-${it.id}" }, + ) { article -> + LongFormCard( + event = article, + localCache = localCache, + onAuthorClick = { onNavigateToProfile(article.pubKey) }, + onClick = { + val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" + onNavigateToArticle(addressTag) + }, + ) + } + } + } + + 2 -> { item(key = "gallery") { GalleryTab( pictureEvents = pictureEvents, @@ -734,6 +837,33 @@ fun UserProfileScreen( ) } } + + 3 -> { + if (highlightEvents.isEmpty()) { + item(key = "no-highlights") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No published highlights", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + items( + highlightEvents.sortedByDescending { it.createdAt }, + key = { "hl-${it.id}" }, + ) { highlight -> + PublishedHighlightCard( + highlight = highlight, + localCache = localCache, + ) + } + } + } } } } @@ -940,3 +1070,64 @@ private suspend fun updateProfileDisplayName( onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error")) } } + +@Composable +private fun PublishedHighlightCard( + highlight: HighlightEvent, + localCache: DesktopLocalCache, +) { + val articleAddress = highlight.inPostAddress() + val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source" + + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Quoted highlight text + Text( + text = "\u201C${highlight.quote()}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + + // Note/comment + val comment = highlight.comment() + if (!comment.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + text = comment, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Context (surrounding paragraph) + val context = highlight.context() + if (!context.isNullOrBlank() && context != highlight.quote()) { + Spacer(Modifier.height(8.dp)) + Text( + text = context.take(200) + if (context.length > 200) "\u2026" else "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + + Spacer(Modifier.height(8.dp)) + + // Source article reference + if (articleAddress != null) { + Text( + text = "from ${articleAddress.dTag.ifBlank { "article" }}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt index f7ef606dd..f85a4707c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -53,6 +53,8 @@ private val COLUMN_OPTIONS = DeckColumnType.Messages, DeckColumnType.Search, DeckColumnType.Reads, + DeckColumnType.Drafts, + DeckColumnType.MyHighlights, DeckColumnType.Bookmarks, DeckColumnType.GlobalFeed, DeckColumnType.MyProfile, 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..3bd9fe2ca 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,10 @@ 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 + DeckColumnType.MyHighlights -> 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..1da11dbd6 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 @@ -44,9 +44,14 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +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 @@ -92,6 +97,8 @@ fun DeckColumnContainer( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -132,6 +139,8 @@ fun DeckColumnContainer( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, compactMode = true, onShowComposeDialog = onShowComposeDialog, @@ -139,6 +148,8 @@ fun DeckColumnContainer( onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -152,11 +163,14 @@ fun DeckColumnContainer( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } @@ -175,6 +189,8 @@ internal fun RootContent( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore? = null, + draftStore: DesktopDraftStore? = null, appScope: CoroutineScope, compactMode: Boolean = false, onShowComposeDialog: () -> Unit, @@ -182,6 +198,8 @@ internal fun RootContent( onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, + onNavigateToArticle: (String) -> Unit = {}, + onNavigateToEditor: (String?) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -231,8 +249,11 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + nwcConnection = nwcConnection, onNavigateToProfile = onNavigateToProfile, - onNavigateToArticle = onNavigateToThread, + onNavigateToArticle = onNavigateToArticle, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, ) } @@ -275,6 +296,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -323,6 +345,44 @@ internal fun RootContent( ) } + is DeckColumnType.Article -> { + ArticleReaderScreen( + addressTag = columnType.addressTag, + relayManager = relayManager, + localCache = localCache, + account = account, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + onBack = {}, + onNavigateToProfile = onNavigateToProfile, + ) + } + + is DeckColumnType.Editor -> { + ArticleEditorScreen( + draftSlug = columnType.draftSlug, + draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + account = account, + relayManager = relayManager, + onBack = {}, + onPublished = {}, + ) + } + + DeckColumnType.Drafts -> { + DraftsScreen( + draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + onOpenEditor = { slug -> onNavigateToEditor(slug) }, + ) + } + + DeckColumnType.MyHighlights -> { + com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen( + highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) }, + onNavigateToArticle = onNavigateToArticle, + ) + } + is DeckColumnType.Hashtag -> { SearchScreen( localCache = localCache, @@ -344,11 +404,14 @@ internal fun OverlayContent( account: AccountState.LoggedIn, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore? = null, + draftStore: DesktopDraftStore? = null, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, + onNavigateToArticle: (String) -> Unit = {}, onBack: () -> Unit, ) { when (screen) { @@ -363,6 +426,7 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -383,6 +447,31 @@ internal fun OverlayContent( ) } + is DesktopScreen.Article -> { + ArticleReaderScreen( + addressTag = screen.addressTag, + relayManager = relayManager, + localCache = localCache, + account = account, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + onBack = onBack, + onNavigateToProfile = onNavigateToProfile, + ) + } + + is DesktopScreen.Editor -> { + val overlayScope = androidx.compose.runtime.rememberCoroutineScope() + ArticleEditorScreen( + draftSlug = screen.draftSlug, + draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) }, + account = account, + relayManager = relayManager, + onBack = onBack, + onPublished = onBack, + ) + } + 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..3f5ea27b7 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,18 @@ 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() + + object MyHighlights : DeckColumnType() + data class Hashtag( val tag: String, ) : DeckColumnType() @@ -67,6 +79,10 @@ sealed class DeckColumnType { MyProfile -> "Profile" Chess -> "Chess" Settings -> "Settings" + is Article -> "Article" + is Editor -> "New Article" + Drafts -> "Drafts" + MyHighlights -> "Highlights" is Profile -> "Profile" is Thread -> "Thread" is Hashtag -> "#$tag" @@ -84,6 +100,10 @@ sealed class DeckColumnType { MyProfile -> "my_profile" Chess -> "chess" Settings -> "settings" + is Article -> "article" + is Editor -> "editor" + Drafts -> "drafts" + MyHighlights -> "highlights" is Profile -> "profile" is Thread -> "thread" is Hashtag -> "hashtag" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 519d93217..e9f23f353 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager 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.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm @@ -61,6 +62,8 @@ fun DeckLayout( iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -113,6 +116,8 @@ fun DeckLayout( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index ea458daa2..7afabc73a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager 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.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen @@ -79,6 +80,8 @@ private val navItems = listOf( NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), + NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"), + NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"), NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"), NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), @@ -97,6 +100,8 @@ fun SinglePaneLayout( iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -171,12 +176,16 @@ fun SinglePaneLayout( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -190,11 +199,14 @@ fun SinglePaneLayout( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt new file mode 100644 index 000000000..3fec86bff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt @@ -0,0 +1,208 @@ +/* + * 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.highlights + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.launch + +@Composable +fun ArticleHighlightsPanel( + highlights: List, + highlightStore: DesktopHighlightStore, + articleContent: String, + signer: NostrSigner?, + relayManager: DesktopRelayConnectionManager?, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + var editTarget by remember { mutableStateOf(null) } + + Column(modifier = modifier.fillMaxWidth().padding(top = 8.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + highlights.forEach { highlight -> + HighlightPanelCard( + highlight = highlight, + onDelete = { + scope.launch { highlightStore.removeHighlight(highlight.id) } + }, + onEditNote = { editTarget = highlight }, + onPublish = + if (!highlight.published && signer != null && relayManager != null) { + { + scope.launch { + val context = + HighlightPublishAction.extractContext( + articleContent, + highlight.text, + ) + val event = + HighlightPublishAction.publish( + highlightText = highlight.text, + articleAddressTag = highlight.articleAddressTag, + note = highlight.note, + context = context, + signer = signer, + ) + relayManager.broadcastToAll(event) + highlightStore.markPublished(highlight.id, event.id) + } + } + } else { + null + }, + ) + } + } + } + + editTarget?.let { highlight -> + HighlightAnnotationDialog( + selectedText = highlight.text, + onConfirm = { note -> + scope.launch { highlightStore.updateNote(highlight.id, note) } + editTarget = null + }, + onDismiss = { editTarget = null }, + ) + } +} + +@Composable +private fun HighlightPanelCard( + highlight: HighlightData, + onDelete: () -> Unit, + onEditNote: () -> Unit, + onPublish: (() -> Unit)?, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "\u201C${highlight.text}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurface, + ) + + val noteText = highlight.note + if (!noteText.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = noteText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // Published status + Icon( + imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock, + contentDescription = if (highlight.published) "Published" else "Private", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (highlight.published) "Published" else "Private", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp), + ) + + Spacer(Modifier.weight(1f)) + + // Publish button + if (onPublish != null) { + IconButton(onClick = onPublish, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Public, + contentDescription = "Publish to relays", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + IconButton(onClick = onEditNote, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit note", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt new file mode 100644 index 000000000..d43b51c13 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt @@ -0,0 +1,80 @@ +/* + * 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.highlights + +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.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.unit.dp + +@Composable +fun HighlightAnnotationDialog( + selectedText: String, + onConfirm: (note: String) -> Unit, + onDismiss: () -> Unit, +) { + var note by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Highlight Note") }, + text = { + Column { + Text( + text = "\"$selectedText\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = note, + onValueChange = { note = it }, + label = { Text("Note (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 5, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(note) }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt new file mode 100644 index 000000000..992640b02 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt @@ -0,0 +1,75 @@ +/* + * 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.highlights + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag +import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag +import com.vitorpamplona.quartz.utils.TimeUtils + +object HighlightPublishAction { + suspend fun publish( + highlightText: String, + articleAddressTag: String, + note: String?, + context: String?, + signer: NostrSigner, + ): HighlightEvent { + val tags = mutableListOf>() + + tags.add(AltTag.assemble(HighlightEvent.ALT)) + tags.add(ATag.assemble(articleAddressTag, null)) + + // Tag the article author + val parts = articleAddressTag.split(":", limit = 3) + val pubkey = parts.getOrNull(1) + if (!pubkey.isNullOrBlank()) { + tags.add(PTag.assemble(pubkey, null)) + } + + if (!note.isNullOrBlank()) { + tags.add(CommentTag.assemble(note)) + } + + if (!context.isNullOrBlank()) { + tags.add(ContextTag.assemble(context)) + } + + return signer.sign( + createdAt = TimeUtils.now(), + kind = HighlightEvent.KIND, + tags = tags.toTypedArray(), + content = highlightText, + ) + } + + fun extractContext( + content: String, + highlightText: String, + ): String? { + val paragraphs = content.split("\n\n") + return paragraphs.find { it.contains(highlightText) } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt new file mode 100644 index 000000000..ff417478e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -0,0 +1,234 @@ +/* + * 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.highlights + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun MyHighlightsScreen( + highlightStore: DesktopHighlightStore, + onNavigateToArticle: (addressTag: String) -> Unit, +) { + val allHighlights by highlightStore.highlights.collectAsState() + val scope = rememberCoroutineScope() + var deleteTarget by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + Text( + "Highlights", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(16.dp)) + + if (allHighlights.isEmpty()) { + EmptyState( + title = "No highlights yet", + description = "Select text in an article and choose \"Highlight\" to save passages.", + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + allHighlights.forEach { (addressTag, highlights) -> + val articleTitle = highlights.firstOrNull()?.articleTitle ?: addressTag + + stickyHeader(key = addressTag) { + ArticleGroupHeader( + title = articleTitle, + onClick = { onNavigateToArticle(addressTag) }, + ) + } + + items(highlights, key = { it.id }) { highlight -> + HighlightCard( + highlight = highlight, + onDelete = { deleteTarget = highlight }, + ) + } + } + } + } + } + + deleteTarget?.let { highlight -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete Highlight") }, + text = { + Text( + "Delete this highlight? This cannot be undone.", + ) + }, + confirmButton = { + TextButton(onClick = { + scope.launch { highlightStore.removeHighlight(highlight.id) } + deleteTarget = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun ArticleGroupHeader( + title: String, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun HighlightCard( + highlight: HighlightData, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "\u201C${highlight.text}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurface, + ) + + val noteText = highlight.note + if (!noteText.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = noteText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatTimestamp(highlight.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock, + contentDescription = if (highlight.published) "Published" else "Private", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete highlight", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +private fun formatTimestamp(epochSeconds: Long): String = + Instant + .ofEpochSecond(epochSeconds) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) diff --git a/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md b/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md new file mode 100644 index 000000000..7e3edfccb --- /dev/null +++ b/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md @@ -0,0 +1,410 @@ +--- +title: "feat: Article Highlights & Note-Taking" +type: feat +status: active +date: 2026-03-24 +deepened: 2026-03-24 +origin: docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md +--- + +# feat: Article Highlights & Note-Taking + +## Enhancement Summary + +**Deepened on:** 2026-03-24 +**Research agents used:** text-selection, richtext-rendering, floating-popup, nip84-highlights + +### Key Improvements +1. **Text selection strategy resolved** — Use `LocalTextContextMenu` override (only official API exposing selected text), not clipboard polling +2. **Inline rendering strategy resolved** — Pre-process markdown with special link URI (`highlight://`) as v1; fork-level `==highlight==` syntax as v2 +3. **Floating toolbar pattern confirmed** — `Popup` + custom `PopupPositionProvider`, matches existing `ChatPane.kt` pattern +4. **NIP-84 gap found** — `HighlightEvent.create()` only takes `msg`+`signer`, needs tag assembly wrapper for full highlight creation + +### Resolved Questions +- **`` support?** No — richtext library ignores `HtmlInline` nodes. Use pre-processing or fork changes. +- **Clipboard polling reliability?** Moot — use `LocalTextContextMenu` instead (right-click UX, official API) +- **NIP-09 deletion?** Yes — send kind 5 event with `["e", highlightEventId]` + `["k", "9802"]` + +## Overview + +Text selection-based highlight and annotation system for the Desktop article reader. Users select text in NIP-23 articles, a context menu option or floating toolbar appears, and they create highlights (with optional notes). Highlights render inline as colored markers. Supports private (local) and public (NIP-84) modes. Includes a "My Highlights" aggregation screen. + +## Problem Statement + +Desktop article reader has no way to mark up, annotate, or take notes on long-form content. Users reading NIP-23 articles can't highlight passages, add personal notes, or publish highlights to their Nostr social graph. This limits the reading experience compared to tools like Kindle, Medium, or Hypothesis. + +## Proposed Solution + +Three-phase implementation: +1. **Storage + data model** — DesktopHighlightStore (Preferences-based) + highlight data classes +2. **Selection UX + inline rendering** — Text selection interception via context menu, floating toolbar, yellow highlight markers in markdown +3. **My Highlights screen + NIP-84 publishing** — Aggregation view, public/private toggle, relay broadcast + +## Technical Approach + +### Architecture + +``` +desktopApp/ +├── service/highlights/ +│ └── DesktopHighlightStore.kt # Preferences-based storage (like DraftStore) +├── ui/ +│ ├── ArticleReaderScreen.kt # Modified: selection + inline highlights +│ ├── highlights/ +│ │ ├── FloatingHighlightToolbar.kt # Popup on text selection +│ │ ├── HighlightAnnotationDialog.kt # Note entry dialog +│ │ ├── HighlightPublishAction.kt # NIP-84 tag assembly + publish +│ │ └── MyHighlightsScreen.kt # Aggregation screen +│ └── deck/ +│ ├── DeckColumnType.kt # Add MyHighlights +│ ├── DeckColumnContainer.kt # Route MyHighlights +│ └── SinglePaneLayout.kt # Add nav item + +commons/ +├── compose/markdown/ +│ └── RenderMarkdown.kt # Modified: accept highlight ranges, render yellow bg +├── model/highlights/ +│ └── HighlightData.kt # Shared data class +``` + +### Implementation Phases + +#### Phase 1: Storage & Data Model + +**Goal:** DesktopHighlightStore + highlight data classes, no UI yet. + +**Files:** +- `desktopApp/service/highlights/DesktopHighlightStore.kt` — follows DesktopDraftStore pattern (Jackson + Preferences) +- `commons/model/highlights/HighlightData.kt` — shared data class + +**Data model:** +```kotlin +data class HighlightData( + val id: String, // UUID + val text: String, // selected/highlighted text + val note: String?, // optional annotation + val articleAddressTag: String, // "30023:pubkey:d-tag" + val articleTitle: String?, // cached for My Highlights display + val createdAt: Long, // epoch seconds + val published: Boolean, // false = private, true = NIP-84 published + val eventId: String?, // NIP-84 event ID if published +) +``` + +**DesktopHighlightStore API:** +```kotlin +class DesktopHighlightStore(scope: CoroutineScope) { + private val mapper = jacksonObjectMapper() + val highlights: StateFlow>> // keyed by articleAddressTag + + suspend fun addHighlight(articleAddressTag: String, text: String, note: String?, articleTitle: String?) + suspend fun updateNote(highlightId: String, note: String) + suspend fun removeHighlight(highlightId: String) + suspend fun markPublished(highlightId: String, eventId: String) + fun getHighlightsForArticle(addressTag: String): List + fun getAllHighlights(): Map> +} +``` + +**Tests:** Unit tests for store CRUD, serialization round-trip. + +**Success criteria:** +- [ ] HighlightData serializes/deserializes via Jackson +- [ ] Store persists across app restarts via Preferences +- [ ] CRUD operations work correctly +- [ ] StateFlow emits on changes + +### Research Insights — Phase 1 + +**Storage pattern:** Follow `DesktopDraftStore.kt` exactly: +- `jacksonObjectMapper()` for serialization (line 62) +- Atomic writes with temp files + `Files.move()` (lines 237-254) +- POSIX file permissions for security (lines 261-288) +- Preferences key: `"highlights:${articleAddressTag}"` with JSON array value + +**Edge case — Preferences size limit:** `java.util.prefs.Preferences` has a per-value limit of 8192 bytes on some platforms. For articles with many highlights, the JSON array could exceed this. Mitigation: if value exceeds 6KB, spill to file-based storage (same pattern as DraftStore's file storage). + +--- + +#### Phase 2: Selection UX + Inline Rendering + +**Goal:** Select text in article → create highlight → see yellow marker. + +##### Text Selection Strategy (REVISED) + +**Primary: `LocalTextContextMenu` override** — the only official Compose Desktop API that exposes `selectedText`: + +```kotlin +@Composable +fun HighlightableContent( + onHighlight: (String) -> Unit, + onAnnotate: (String) -> Unit, + content: @Composable () -> Unit, +) { + val defaultMenu = LocalTextContextMenu.current + + CompositionLocalProvider( + LocalTextContextMenu provides object : TextContextMenu { + @Composable + override fun Area( + textManager: TextContextMenu.TextManager, + state: ContextMenuState, + content: @Composable () -> Unit, + ) { + ContextMenuDataProvider({ + val selected = textManager.selectedText + if (selected.text.isNotEmpty()) { + listOf( + ContextMenuItem("Highlight") { onHighlight(selected.text) }, + ContextMenuItem("Highlight with Note") { onAnnotate(selected.text) }, + ) + } else { + emptyList() + } + }) { + defaultMenu.Area(textManager, state, content = content) + } + } + }, + content = content, + ) +} +``` + +**UX:** Select text → right-click → "Highlight" / "Highlight with Note" in context menu. Natural desktop UX. No clipboard polling needed. + +**Secondary: Keyboard shortcut (Cmd+H)** — reads clipboard after user copies: + +```kotlin +Modifier.onPreviewKeyEvent { event -> + if (event.isMetaPressed && event.key == Key.H && event.type == KeyEventType.KeyDown) { + val clipText = clipboard.getText()?.text + if (!clipText.isNullOrBlank()) onHighlight(clipText) + true + } else false +} +``` + +##### Inline Rendering Strategy (REVISED) + +**Research finding:** richtext library ignores `HtmlInline` (``) and has no `Highlight` format. Three options ranked: + +| Approach | Effort | Quality | Recommended | +|----------|--------|---------|-------------| +| Pre-process: wrap in special link `[text](highlight://)` | Low | Hacky but works | v1 | +| Fork: add `==text==` DelimiterProcessor + `Format.Highlight` | Medium | Clean, semantic | v2 | +| Overlay: position colored Box composables | High | Fragile | No | + +**v1 approach (ship fast):** Pre-process markdown before parsing: +```kotlin +fun applyHighlights(content: String, highlights: List): String { + var result = content + // Sort by length descending to avoid nested replacement issues + highlights.sortedByDescending { it.text.length }.forEach { h -> + val idx = result.indexOf(h.text) + if (idx >= 0) { + // Wrap in bold + italic to visually distinguish + result = result.replaceFirst(h.text, "***${h.text}***") + } + } + return result +} +``` + +**v2 approach (proper):** Add highlight support to Vitor's richtext fork: +1. Add `AstHighlight` inline node type +2. Add `HighlightDelimiterProcessor` for `==text==` syntax +3. Add `Format.Highlight` with `SpanStyle(background = Color(0xFFFFEB3B))` +4. Pre-process: wrap highlights with `==text==` before parsing + +##### Floating Toolbar (for future enhancement beyond context menu) + +**Pattern:** `Popup` + custom `PopupPositionProvider` (matches existing `ChatPane.kt:584`): + +```kotlin +class MousePositionProvider(private val offset: IntOffset) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, windowSize: IntSize, + layoutDirection: LayoutDirection, popupContentSize: IntSize, + ): IntOffset { + val x = (offset.x - popupContentSize.width / 2) + .coerceIn(0, windowSize.width - popupContentSize.width) + val y = (offset.y - popupContentSize.height - 8) + .coerceIn(0, windowSize.height - popupContentSize.height) + return IntOffset(x, y) + } +} +``` + +Track mouse via `Modifier.pointerInput` + `awaitPointerEventScope` (pattern from `VideoControls.kt:89`). Dismiss on scroll via `LaunchedEffect(scrollState.isScrollInProgress)`. + +**Files:** +- `desktopApp/ui/highlights/FloatingHighlightToolbar.kt` — Popup with Highlight/Annotate buttons +- `desktopApp/ui/highlights/HighlightAnnotationDialog.kt` — AlertDialog for note text +- `desktopApp/ui/ArticleReaderScreen.kt` — Add context menu override, highlight state, inline rendering +- `commons/compose/markdown/RenderMarkdown.kt` — Add `highlights: List` param + +**UX flow (revised):** +1. User reads article, selects text by click-dragging +2. Right-click → context menu shows "Highlight" / "Highlight with Note" (via `LocalTextContextMenu`) +3. "Highlight" → saves immediately via DesktopHighlightStore, re-renders with bold/italic marker (v1) or yellow bg (v2) +4. "Highlight with Note" → opens HighlightAnnotationDialog → saves with note +5. Click existing highlight → popup with note / delete / publish toggle + +**Success criteria:** +- [ ] Right-click context menu shows "Highlight" option when text is selected +- [ ] Highlight saves and renders as visual marker in article +- [ ] Annotation dialog captures and saves notes +- [ ] Highlights persist across navigation (leave article and return) +- [ ] Best-effort match: highlights survive minor article edits (see brainstorm) +- [ ] Cmd+H keyboard shortcut works as alternative (select, copy, Cmd+H) + +--- + +#### Phase 3: My Highlights Screen + NIP-84 Publishing + +**Goal:** Aggregation screen showing all highlights grouped by article. Public/private toggle per highlight. + +**Files:** +- `desktopApp/ui/highlights/MyHighlightsScreen.kt` +- `desktopApp/ui/highlights/HighlightPublishAction.kt` — tag assembly + publish +- `desktopApp/ui/deck/DeckColumnType.kt` — Add `object MyHighlights` +- `desktopApp/ui/deck/DeckColumnContainer.kt` — Route MyHighlights +- `desktopApp/ui/deck/SinglePaneLayout.kt` — Add nav item +- `desktopApp/ui/deck/AddColumnDialog.kt` — Add to column options +- `desktopApp/ui/deck/ColumnHeader.kt` — Add icon + +##### NIP-84 Publishing (REVISED) + +**Gap found:** `HighlightEvent.create()` only takes `msg` + `signer` — doesn't accept source/context/comment tags. Need a wrapper: + +```kotlin +object HighlightPublishAction { + suspend fun publish( + highlightText: String, + articleEvent: LongTextNoteEvent, + note: String?, + signer: NostrSigner, + ): HighlightEvent { + val tags = TagArrayBuilder().apply { + // Article reference (addressable event) + add(ATag.assemble(30023, articleEvent.pubKey, articleEvent.dTag())) + // Article author attribution + add(PTag.assemble(articleEvent.pubKey, role = "author")) + // Optional annotation + note?.let { add(CommentTag.assemble(it)) } + // Surrounding paragraph as context + extractContext(articleEvent.content, highlightText)?.let { + add(ContextTag.assemble(it)) + } + // Alt text for non-NIP-84 clients + add(AltTag.assemble("Highlight: $highlightText")) + }.build() + + return HighlightEvent.create( + msg = highlightText, + tags = tags, + signer = signer, + ) + } + + /** Extract the paragraph containing the highlighted text */ + fun extractContext(content: String, highlightText: String): String? { + val paragraphs = content.split("\n\n") + return paragraphs.find { it.contains(highlightText) } + } +} +``` + +##### NIP-09 Deletion for Published Highlights + +```kotlin +suspend fun deleteHighlight(eventId: String, signer: NostrSigner): DeletionEvent { + return DeletionEvent.create( + deleteEvents = listOf(eventId), + deleteKinds = listOf(9802), + reason = "User deleted highlight", + signer = signer, + ) +} +``` + +**My Highlights screen layout:** +``` +┌─────────────────────────────────┐ +│ My Highlights │ +├─────────────────────────────────┤ +│ ▼ "Article Title One" │ +│ "highlighted text..." 🔒 │ +│ Note: my annotation ╳ │ +│ Mar 24, 2026 │ +│ │ +│ "another highlight..." 🌐 │ +│ Mar 24, 2026 │ +│ │ +│ ▼ "Article Title Two" │ +│ "highlighted passage..." 🔒 │ +│ Note: thoughts here ╳ │ +└─────────────────────────────────┘ +🔒 = private 🌐 = published +Click article title → navigates to article +Click 🔒 → publish to Nostr (NIP-84) +Click ╳ → delete (+ NIP-09 if published) +``` + +**Success criteria:** +- [ ] My Highlights accessible from sidebar nav +- [ ] Highlights grouped by article with collapsible sections +- [ ] Click article title navigates to article (onNavigateToArticle) +- [ ] Public/private toggle publishes NIP-84 event to relays +- [ ] Delete removes from local store + sends NIP-09 deletion for published +- [ ] Empty state when no highlights exist + +## Acceptance Criteria + +- [ ] Right-click selected text in article → "Highlight" in context menu +- [ ] Click "Highlight" → text marked visually, saved locally +- [ ] Click "Highlight with Note" → note dialog, then saved with annotation +- [ ] Cmd+H keyboard shortcut creates highlight from clipboard +- [ ] Highlights persist across app restarts +- [ ] Highlights survive article content updates (best-effort string match) +- [ ] "My Highlights" screen shows all highlights grouped by article +- [ ] Can toggle highlight public/private (publishes NIP-84 event) +- [ ] Can delete highlights (+ NIP-09 for published) +- [ ] Zoom (Cmd+/Cmd-) doesn't break highlight rendering +- [ ] Works in both single-pane and deck layout modes + +## Dependencies & Risks + +| Risk | Impact | Mitigation | Status | +|------|--------|------------|--------| +| `SelectionContainer` doesn't expose selection state | High | **Resolved:** Use `LocalTextContextMenu` override — official API, accesses `selectedText` directly | Mitigated | +| richtext library doesn't support `` or highlight formatting | Medium | **Resolved:** v1 uses bold/italic pre-processing; v2 adds `Format.Highlight` to fork | Mitigated | +| `LocalTextContextMenu.TextManager.selectedText` doesn't work across multiple `Text()` children | Medium | Test during Phase 2; fallback to clipboard-based Cmd+H shortcut | Open | +| `HighlightEvent.create()` doesn't accept custom tags | Low | **Resolved:** Create `HighlightPublishAction` wrapper with `TagArrayBuilder` | Mitigated | +| Preferences 8KB per-value limit | Low | Monitor; spill to file storage if needed | Open | + +## Sources & References + +### Origin + +- **Brainstorm:** [docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md](docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md) + - Key decisions: private+public scope, select+popup UX, Preferences storage, own highlights only, best-effort persistence, My Highlights screen + +### Internal References + +- HighlightEvent protocol: `quartz/nip84Highlights/HighlightEvent.kt:137-141` +- DraftStore pattern: `desktopApp/service/drafts/DesktopDraftStore.kt` +- Event publishing: `desktopApp/ui/ArticleEditorScreen.kt:161-187` +- SelectionContainer: `desktopApp/ui/ArticleEditorScreen.kt:304` +- Context menu override: `LocalTextContextMenu` (Compose Desktop API) +- Popup pattern: `desktopApp/ui/chats/ChatPane.kt:584` +- Mouse tracking: `desktopApp/ui/media/VideoControls.kt:89` +- Android highlight rendering: `amethyst/ui/note/types/Highlight.kt:179-198` + +### External References + +- [Compose Desktop context menus](https://kotlinlang.org/docs/multiplatform/compose-desktop-context-menus.html) +- [NIP-84 spec (Highlights)](https://github.com/nostr-protocol/nips/blob/master/84.md) +- [NIP-09 spec (Event Deletion)](https://github.com/nostr-protocol/nips/blob/master/09.md) +- [commonmark-java DelimiterProcessor](https://github.com/commonmark/commonmark-java) 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/experimental/attestations/attestation/AttestationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt index 9e707b75b..820298930 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent @@ -32,17 +31,14 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle 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.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.serialization.json.JsonNull.content @Immutable class AttestationEvent( @@ -54,8 +50,7 @@ class AttestationEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider, - PubKeyHintProvider { + AddressHintProvider { override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) @@ -64,12 +59,6 @@ class AttestationEvent( override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - - override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) - - fun validity() = tags.validity() - fun status() = tags.status() fun validFrom() = tags.validFrom() @@ -94,10 +83,6 @@ class AttestationEvent( fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) - fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) - - fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) - companion object { const val KIND = 31871 const val ALT_DESCRIPTION = "Attestation" @@ -106,7 +91,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -117,7 +101,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) about(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } @@ -129,7 +112,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -140,7 +122,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) aboutReplaceable(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } @@ -152,7 +133,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -163,7 +143,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) aboutAddressable(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt index b64090b6f..3ef5d1a5b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt @@ -25,8 +25,6 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Reque import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent @@ -36,8 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -fun TagArrayBuilder.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity)) - fun TagArrayBuilder.status(status: AttestationStatus) = addUnique(StatusTag.assemble(status)) fun TagArrayBuilder.validFrom(timestamp: Long) = addUnique(ValidFromTag.assemble(timestamp)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt index 2b545818e..8e71b066b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt @@ -24,11 +24,8 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Reque import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag import com.vitorpamplona.quartz.nip01Core.core.TagArray -fun TagArray.validity() = firstNotNullOfOrNull(ValidityTag::parse) - fun TagArray.status() = firstNotNullOfOrNull(StatusTag::parse) fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt index 424c698db..853f84c7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt @@ -26,10 +26,9 @@ import com.vitorpamplona.quartz.utils.ensure enum class AttestationStatus( val code: String, ) { - ACCEPTED("accepted"), - REJECTED("rejected"), VERIFYING("verifying"), - VERIFIED("verified"), + VALID("valid"), + INVALID("invalid"), REVOKED("revoked"), } @@ -45,10 +44,9 @@ class StatusTag { ensure(tag[1].isNotEmpty()) { return null } return when (tag[1]) { - AttestationStatus.ACCEPTED.code -> AttestationStatus.ACCEPTED - AttestationStatus.REJECTED.code -> AttestationStatus.REJECTED AttestationStatus.VERIFYING.code -> AttestationStatus.VERIFYING - AttestationStatus.VERIFIED.code -> AttestationStatus.VERIFIED + AttestationStatus.VALID.code -> AttestationStatus.VALID + AttestationStatus.INVALID.code -> AttestationStatus.INVALID AttestationStatus.REVOKED.code -> AttestationStatus.REVOKED else -> null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt deleted file mode 100644 index 72d7a23c5..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt +++ /dev/null @@ -1,53 +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.experimental.attestations.attestation.tags - -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure - -enum class Validity( - val code: String, -) { - VALID("valid"), - INVALID("invalid"), -} - -class ValidityTag { - companion object { - const val TAG_NAME = "v" - - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() - - fun parse(tag: Array): Validity? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - - return when (tag[1]) { - Validity.VALID.code -> Validity.VALID - Validity.INVALID.code -> Validity.INVALID - else -> null - } - } - - fun assemble(validity: Validity) = arrayOf(TAG_NAME, validity.code) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt index 49bd5109f..879bcf3c7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt @@ -40,7 +40,7 @@ class AttestorProficiencyEvent( ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun kinds() = tags.kinds() - fun description() = tags.description() + fun description() = content.ifBlank { null } companion object { const val KIND = 11871 @@ -51,10 +51,9 @@ class AttestorProficiencyEvent( description: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + ) = eventTemplate(KIND, description ?: "", createdAt) { alt(ALT_DESCRIPTION) kinds(kinds) - description?.let { desc(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt index fc617bb51..652450801 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt @@ -20,11 +20,8 @@ */ package com.vitorpamplona.quartz.experimental.attestations.proficiency -import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) - -fun TagArrayBuilder.desc(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt index a6dfab465..e9e9fe1b3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt @@ -20,10 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.attestations.proficiency -import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag import com.vitorpamplona.quartz.nip01Core.core.TagArray fun TagArray.kinds() = mapNotNull(KindTag::parse) - -fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt index 704d85680..2a3a4bbf5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt @@ -41,7 +41,7 @@ class AttestorRecommendationEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun kinds() = tags.kinds() - fun description() = tags.description() + fun description() = content.ifBlank { null } companion object { const val KIND = 31873 @@ -53,11 +53,10 @@ class AttestorRecommendationEvent( description: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + ) = eventTemplate(KIND, description ?: "", createdAt) { alt(ALT_DESCRIPTION) dTag(attestorPubKey) kinds(kinds) - description?.let { desc(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt index 23dfbc7e7..e1dfcca6b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt @@ -20,11 +20,8 @@ */ package com.vitorpamplona.quartz.experimental.attestations.recommendation -import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) - -fun TagArrayBuilder.desc(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt index d784f3d9e..d04e04bd8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt @@ -20,10 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.attestations.recommendation -import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag import com.vitorpamplona.quartz.nip01Core.core.TagArray fun TagArray.kinds() = mapNotNull(KindTag::parse) - -fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse)