This commit is contained in:
Vitor Pamplona
2026-03-25 08:37:18 -04:00
45 changed files with 4618 additions and 201 deletions
+18 -18
View File
@@ -405,24 +405,24 @@ to `onPause` methods.
### Feature Parity Table ### Feature Parity Table
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes | | Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
| :--- | :--- | :---: | :---: | :--- | |:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------|
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. | | **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. | | | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | |
| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. | | | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | |
| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. | | | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | |
| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. | | | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. | | **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. |
| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. | | | GZip Compression | ✅ Full | ✅ Full | |
| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. | | | BitSet | ✅ Full | ✅ Full | |
| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. | | | LargeCache | ✅ Full | ✅ Full | |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. | | **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | |
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. | | | 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`. | | | 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. | | **Utilities** | URL Encoding / Decoding | ✅ Full | ✅ Full | |
| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. | | | Unicode Normalization | ✅ Full | ✅ Full | |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. | | | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. | | | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
## Contributing ## Contributing
@@ -34,12 +34,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.CheckCircle
import androidx.compose.material.icons.filled.Close 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.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.HourglassTop import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.Recommend import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.RemoveDone 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.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent 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.AttestationStatus
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
@@ -106,8 +102,7 @@ fun RenderAttestationPreview() {
arrayOf( arrayOf(
arrayOf("d", "af5aa898:fe108febb997:1773941524"), arrayOf("d", "af5aa898:fe108febb997:1773941524"),
arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"), arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"),
arrayOf("s", "verified"), arrayOf("s", "valid"),
arrayOf("v", "valid"),
arrayOf("client", "attestr.xyz"), arrayOf("client", "attestr.xyz"),
), ),
) )
@@ -161,19 +156,17 @@ fun RenderAttestation(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val validity = remember(noteEvent) { noteEvent.validity() }
val status = remember(noteEvent) { noteEvent.status() } val status = remember(noteEvent) { noteEvent.status() }
val validFrom = remember(noteEvent) { noteEvent.validFrom() } val validFrom = remember(noteEvent) { noteEvent.validFrom() }
val validTo = remember(noteEvent) { noteEvent.validTo() } val validTo = remember(noteEvent) { noteEvent.validTo() }
val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
val statusColor = remember(status, validity) { attestationColor(status, validity) } val statusColor = remember(status) { attestationColor(status) }
val statusIcon = remember(status, validity) { attestationIcon(status, validity) } val statusIcon = remember(status) { attestationIcon(status) }
val statusLabel = attestationStatusLabel(status, validity) val statusLabel = attestationStatusLabel(status)
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
Column( Column(
modifier = 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( private fun attestationColor(status: AttestationStatus?): Color =
status: AttestationStatus?,
validity: Validity?,
): Color =
when { when {
validity == Validity.INVALID -> Color(0xFFB71C1C) status == AttestationStatus.INVALID -> Color(0xFFB71C1C)
validity == Validity.VALID -> Color(0xFF2E7D32) status == AttestationStatus.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.REVOKED -> Color(0xFFB21CB7) status == AttestationStatus.REVOKED -> Color(0xFFB21CB7)
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFF173CF5) status == AttestationStatus.VERIFYING -> Color(0xFF173CF5)
status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0)
else -> Color(0xFF757575) else -> Color(0xFF757575)
} }
private fun attestationIcon( private fun attestationIcon(status: AttestationStatus?): ImageVector =
status: AttestationStatus?,
validity: Validity?,
): ImageVector =
when { when {
validity == Validity.INVALID -> Icons.Default.Close status == AttestationStatus.INVALID -> Icons.Default.Close
validity == Validity.VALID -> Icons.Default.CheckCircle status == AttestationStatus.VALID -> Icons.Default.CheckCircle
status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone 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.VERIFYING -> Icons.Default.HourglassTop
status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty
else -> Icons.Default.ErrorOutline else -> Icons.Default.ErrorOutline
} }
@Composable @Composable
private fun attestationStatusLabel( private fun attestationStatusLabel(status: AttestationStatus?): String =
status: AttestationStatus?,
validity: Validity?,
): String =
when { when {
validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) status == AttestationStatus.INVALID -> stringRes(R.string.attestation_invalid)
validity == Validity.VALID -> stringRes(R.string.attestation_valid) status == AttestationStatus.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked) 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.VERIFYING -> stringRes(R.string.attestation_status_verifying)
status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted)
else -> stringRes(R.string.attestation) else -> stringRes(R.string.attestation)
} }
+5
View File
@@ -72,6 +72,11 @@ kotlin {
// Compose Multiplatform Resources // Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources) implementation(libs.jetbrains.compose.components.resources)
// Markdown rendering (richtext-commonmark)
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
} }
} }
@@ -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<String>()
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))
}
}
@@ -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<TocEntry> {
val entries = mutableListOf<TocEntry>()
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<TocEntry>,
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
},
),
)
}
}
}
@@ -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
}
}
@@ -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,
)
}
}
@@ -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<String>,
onTagsChange: (List<String>) -> 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") },
)
}
}
@@ -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<String> = 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)
}
}
}
@@ -18,24 +18,15 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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 data class HighlightData(
import com.vitorpamplona.quartz.utils.ensure val id: String,
val text: String,
class DescriptionTag { val note: String? = null,
companion object { val articleAddressTag: String,
const val TAG_NAME = "desc" val articleTitle: String? = null,
val createdAt: Long,
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() val published: Boolean = false,
val eventId: String? = null,
fun parse(tag: Array<String>): 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)
}
}
@@ -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<String>,
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)
}
}
@@ -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())
}
}
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager 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.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -143,6 +144,16 @@ sealed class DesktopScreen {
val noteId: String, val noteId: String,
) : DesktopScreen() ) : DesktopScreen()
data class Article(
val addressTag: String,
) : DesktopScreen()
data class Editor(
val draftSlug: String? = null,
) : DesktopScreen()
data object Drafts : DesktopScreen()
data object Settings : DesktopScreen() data object Settings : DesktopScreen()
} }
@@ -369,6 +380,8 @@ fun main() {
Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) })
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) 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("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
@@ -597,6 +610,13 @@ fun MainContent(
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) 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 // Subscribe to incoming DMs and process into chatroomList
LaunchedEffect(account) { LaunchedEffect(account) {
relayManager.connectedRelays.first { it.isNotEmpty() } relayManager.connectedRelays.first { it.isNotEmpty() }
@@ -716,6 +736,8 @@ fun MainContent(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
@@ -753,6 +775,8 @@ fun MainContent(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
@@ -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<String> = 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<String, DraftMetadata>? = null
private val _drafts = MutableStateFlow<List<DraftEntry>>(emptyList())
val drafts: StateFlow<List<DraftEntry>> = _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<String, DraftMetadata> {
cachedIndex?.let { return it }
val loaded: MutableMap<String, DraftMetadata> =
if (!indexFile.exists()) {
mutableMapOf()
} else {
try {
mapper.readValue<MutableMap<String, DraftMetadata>>(indexFile)
} catch (e: Exception) {
System.err.println("Failed to read drafts index: ${e.message}")
mutableMapOf()
}
}
cachedIndex = loaded
return loaded
}
private suspend fun loadIndex() {
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<String, DraftMetadata>) {
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
}
}
}
@@ -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<Map<String, List<HighlightData>>>(emptyMap())
val highlights: StateFlow<Map<String, List<HighlightData>>> = _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<HighlightData> = _highlights.value[addressTag] ?: emptyList()
fun getAllHighlights(): Map<String, List<HighlightData>> = _highlights.value
private suspend fun loadIndex() {
mutex.withLock {
if (indexFile.exists()) {
try {
val data: Map<String, List<HighlightData>> = mapper.readValue(indexFile)
_highlights.value = data
} catch (e: Exception) {
// Corrupted file — start fresh
_highlights.value = emptyMap()
}
}
}
}
private fun saveIndex(data: Map<String, List<HighlightData>>) {
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
}
}
}
@@ -576,6 +576,24 @@ object FilterBuilders {
until = until, until = until,
) )
/**
* Creates a filter for a specific long-form article by author pubkey and d-tag.
*
* @param pubkey Author public key (hex-encoded, 64 chars)
* @param dTag The d-tag (slug) identifier for the addressable event
* @return Filter for a specific long-form article
*/
fun longFormByAddress(
pubkey: String,
dTag: String,
): Filter =
Filter(
kinds = listOf(30023), // LongTextNoteEvent.KIND
authors = listOf(pubkey),
tags = mapOf("d" to listOf(dTag)),
limit = 1,
)
/** /**
* Creates a filter for long-form content (kind 30023) from specific authors. * Creates a filter for long-form content (kind 30023) from specific authors.
* *
@@ -104,7 +104,11 @@ fun createUserPostsSubscription(
): SubscriptionConfig = ): SubscriptionConfig =
SubscriptionConfig( SubscriptionConfig(
subId = generateSubId("posts-${pubKeyHex.take(8)}"), 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, relays = relays,
onEvent = onEvent, onEvent = onEvent,
onEose = onEose, onEose = onEose,
@@ -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<List<String>>(emptyList()) }
var slug by remember { mutableStateOf(draftSlug ?: "") }
val editorState = remember { MarkdownEditorState() }
var publishing by remember { mutableStateOf(false) }
var saveMessage by remember { mutableStateOf<String?>(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,
)
}
}
}
}
}
}
}
@@ -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<Int, String, String>? {
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<LongTextNoteEvent?>(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<String?>(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<Int?>(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<List<ZapReceipt>>(emptyList()) }
var reactionCount by remember { mutableStateOf(0) }
var replyCount by remember { mutableStateOf(0) }
var repostCount by remember { mutableStateOf(0) }
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(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<String>()
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<String>()
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<String>()
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<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.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<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.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<ContextMenuItem>,
) {
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)
}
}
@@ -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<DraftEntry?>(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,
)
}
}
}
}
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import java.text.SimpleDateFormat import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import java.util.Date import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000)) private fun formatDate(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.format(dateFormat)
/** /**
* Card displaying long-form content (NIP-23) with title, summary, and image. * Card displaying long-form content (NIP-23) with title, summary, and image.
@@ -171,8 +179,11 @@ fun ReadsScreen(
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null, account: AccountState.LoggedIn? = null,
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -357,12 +368,27 @@ fun ReadsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(events, key = { it.id }) { event -> items(events, key = { it.id }) { event ->
LongFormCard( Column {
event = event, LongFormCard(
localCache = localCache, event = event,
onAuthorClick = onNavigateToProfile, localCache = localCache,
onClick = { onNavigateToArticle(event.id) }, 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)
} }
} }
} }
@@ -96,7 +96,9 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -118,6 +120,7 @@ fun UserProfileScreen(
onBack: () -> Unit, onBack: () -> Unit,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
@@ -157,6 +160,8 @@ fun UserProfileScreen(
var selectedTab by remember { mutableStateOf(0) } var selectedTab by remember { mutableStateOf(0) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) } var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
val pictureEvents = remember { mutableStateListOf<PictureEvent>() } val pictureEvents = remember { mutableStateListOf<PictureEvent>() }
val articleEvents = remember { mutableStateListOf<LongTextNoteEvent>() }
val highlightEvents = remember { mutableStateListOf<HighlightEvent>() }
// Follow state // Follow state
val followState = 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 // Scroll state for detecting scroll direction
val listState = rememberLazyListState() val listState = rememberLazyListState()
var showFloatingHeader by remember { mutableStateOf(false) } var showFloatingHeader by remember { mutableStateOf(false) }
@@ -629,8 +688,20 @@ fun UserProfileScreen(
Text("Notes", modifier = Modifier.padding(12.dp)) Text("Notes", modifier = Modifier.padding(12.dp))
} }
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { 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)) 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 -> { 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") { item(key = "gallery") {
GalleryTab( GalleryTab(
pictureEvents = pictureEvents, 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")) 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,
)
}
}
}
}
@@ -53,6 +53,8 @@ private val COLUMN_OPTIONS =
DeckColumnType.Messages, DeckColumnType.Messages,
DeckColumnType.Search, DeckColumnType.Search,
DeckColumnType.Reads, DeckColumnType.Reads,
DeckColumnType.Drafts,
DeckColumnType.MyHighlights,
DeckColumnType.Bookmarks, DeckColumnType.Bookmarks,
DeckColumnType.GlobalFeed, DeckColumnType.GlobalFeed,
DeckColumnType.MyProfile, DeckColumnType.MyProfile,
@@ -132,6 +132,10 @@ fun DeckColumnType.icon(): ImageVector =
DeckColumnType.MyProfile -> Icons.Default.Person DeckColumnType.MyProfile -> Icons.Default.Person
DeckColumnType.Chess -> Icons.Default.Extension DeckColumnType.Chess -> Icons.Default.Extension
DeckColumnType.Settings -> Icons.Default.Settings DeckColumnType.Settings -> Icons.Default.Settings
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
DeckColumnType.MyHighlights -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Profile -> Icons.Default.Person is DeckColumnType.Profile -> Icons.Default.Person
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Hashtag -> Icons.Default.Tag is DeckColumnType.Hashtag -> Icons.Default.Tag
@@ -44,9 +44,14 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen
@@ -92,6 +97,8 @@ fun DeckColumnContainer(
iAccount: DesktopIAccount, iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: DesktopDraftStore,
appScope: CoroutineScope, appScope: CoroutineScope,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -132,6 +139,8 @@ fun DeckColumnContainer(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
compactMode = true, compactMode = true,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
@@ -139,6 +148,8 @@ fun DeckColumnContainer(
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
) )
if (currentOverlay != null) { if (currentOverlay != null) {
Surface( Surface(
@@ -152,11 +163,14 @@ fun DeckColumnContainer(
account = account, account = account,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() }, onBack = { navState.pop() },
) )
} }
@@ -175,6 +189,8 @@ internal fun RootContent(
iAccount: DesktopIAccount, iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
appScope: CoroutineScope, appScope: CoroutineScope,
compactMode: Boolean = false, compactMode: Boolean = false,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
@@ -182,6 +198,8 @@ internal fun RootContent(
onZapFeedback: (ZapFeedback) -> Unit, onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit, onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit, onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -231,8 +249,11 @@ internal fun RootContent(
relayManager = relayManager, relayManager = relayManager,
localCache = localCache, localCache = localCache,
account = account, account = account,
nwcConnection = nwcConnection,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToThread, onNavigateToArticle = onNavigateToArticle,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
) )
} }
@@ -275,6 +296,7 @@ internal fun RootContent(
onBack = {}, onBack = {},
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback, 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 -> { is DeckColumnType.Hashtag -> {
SearchScreen( SearchScreen(
localCache = localCache, localCache = localCache,
@@ -344,11 +404,14 @@ internal fun OverlayContent(
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit, onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit, onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit, onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
when (screen) { when (screen) {
@@ -363,6 +426,7 @@ internal fun OverlayContent(
onBack = onBack, onBack = onBack,
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback, 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 -> { else -> {
androidx.compose.material3.Text( androidx.compose.material3.Text(
"Unsupported screen type", "Unsupported screen type",
@@ -51,6 +51,18 @@ sealed class DeckColumnType {
val noteId: String, val noteId: String,
) : DeckColumnType() ) : DeckColumnType()
data class Article(
val addressTag: String,
) : DeckColumnType()
data class Editor(
val draftSlug: String? = null,
) : DeckColumnType()
object Drafts : DeckColumnType()
object MyHighlights : DeckColumnType()
data class Hashtag( data class Hashtag(
val tag: String, val tag: String,
) : DeckColumnType() ) : DeckColumnType()
@@ -67,6 +79,10 @@ sealed class DeckColumnType {
MyProfile -> "Profile" MyProfile -> "Profile"
Chess -> "Chess" Chess -> "Chess"
Settings -> "Settings" Settings -> "Settings"
is Article -> "Article"
is Editor -> "New Article"
Drafts -> "Drafts"
MyHighlights -> "Highlights"
is Profile -> "Profile" is Profile -> "Profile"
is Thread -> "Thread" is Thread -> "Thread"
is Hashtag -> "#$tag" is Hashtag -> "#$tag"
@@ -84,6 +100,10 @@ sealed class DeckColumnType {
MyProfile -> "my_profile" MyProfile -> "my_profile"
Chess -> "chess" Chess -> "chess"
Settings -> "settings" Settings -> "settings"
is Article -> "article"
is Editor -> "editor"
Drafts -> "drafts"
MyHighlights -> "highlights"
is Profile -> "profile" is Profile -> "profile"
is Thread -> "thread" is Thread -> "thread"
is Hashtag -> "hashtag" is Hashtag -> "hashtag"
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
@@ -61,6 +62,8 @@ fun DeckLayout(
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope, appScope: CoroutineScope,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -113,6 +116,8 @@ fun DeckLayout(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
@@ -79,6 +80,8 @@ private val navItems =
listOf( listOf(
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), 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.Search, Icons.Default.Search, "Search"),
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
@@ -97,6 +100,8 @@ fun SinglePaneLayout(
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope, appScope: CoroutineScope,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -171,12 +176,16 @@ fun SinglePaneLayout(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
) )
if (currentOverlay != null) { if (currentOverlay != null) {
Surface( Surface(
@@ -190,11 +199,14 @@ fun SinglePaneLayout(
account = account, account = account,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() }, onBack = { navState.pop() },
) )
} }
@@ -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<HighlightData>,
highlightStore: DesktopHighlightStore,
articleContent: String,
signer: NostrSigner?,
relayManager: DesktopRelayConnectionManager?,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
var editTarget by remember { mutableStateOf<HighlightData?>(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,
)
}
}
}
}
}
@@ -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")
}
},
)
}
@@ -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<Array<String>>()
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) }
}
}
@@ -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<HighlightData?>(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"))
@@ -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
- **`<mark>` 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<Map<String, List<HighlightData>>> // 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<HighlightData>
fun getAllHighlights(): Map<String, List<HighlightData>>
}
```
**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` (`<mark>`) 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<HighlightData>): 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<HighlightData>` 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 `<mark>` 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)
@@ -0,0 +1,156 @@
---
title: "Long-Form Reads — Manual Testing Sheet"
date: 2026-03-24
branch: features/long-form-content
---
# Long-Form Reads — Manual Testing Sheet
**Run:** `cd AmethystMultiplatform-long-form && ./gradlew :desktopApp:run`
## Pre-Test Setup
- [x] App launches without crash
- [x] Login with existing account (needs relay connections)
- [x] Navigate to Reads tab in sidebar
---
## 1. ReadsScreen Feed
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 1.1 | Feed loads articles | Click Reads in sidebar | Long-form article cards appear | |
| 1.2 | Reading time shown | Check article cards | "X min read" displayed on each card | |
| 1.3 | Global/Following toggle | Click Global/Following chips | Feed switches between modes | |
| 1.4 | Article click navigates | Click any article card | ArticleReaderScreen opens (not ThreadScreen) | |
---
## 2. ArticleReaderScreen
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 2.1 | Article loads | Click article from Reads feed | Content renders with markdown formatting | |
| 2.2 | Title + metadata | Check header area | Title, author name, reading time, date displayed | |
| 2.3 | Banner image | Open article with banner | Hero image renders at top (if article has `image` tag) | |
| 2.4 | Markdown headings | Scroll through article | H1-H3 render with different sizes | |
| 2.5 | Bold/italic/code | Check formatting | **Bold**, *italic*, `inline code` render correctly | |
| 2.6 | Code blocks | Find code block | Monospace font, distinct background | |
| 2.7 | Links clickable | Click a URL link | Opens in system browser | |
| 2.8 | nostr: links | Click a nostr: link | Does NOT open OS error dialog (scheme filtered) | |
| 2.9 | Images in content | Find article with images | Images render via Coil | |
| 2.10 | Back button | Click ← Back | Returns to ReadsScreen | |
| 2.11 | Content width | Check article body | Max ~680dp centered column | |
| 2.12 | Loading state | Open article (watch transition) | "Loading article..." shown briefly | |
| 2.13 | Error state | Open invalid address tag (if testable) | "Article not found" message | |
---
## 3. Table of Contents
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 3.1 | ToC visible | Open article on wide window (>1100dp) | ToC sidebar appears on left with heading list | |
| 3.2 | ToC hidden | Resize window to <900dp | ToC sidebar disappears | |
| 3.3 | Heading hierarchy | Check ToC entries | H2 indented less than H3 | |
| 3.4 | Click heading | Click a ToC entry | Active entry highlights (scroll-to is TODO) | |
| 3.5 | No headings | Open article with no markdown headings | ToC sidebar not shown or empty | |
---
## 4. Article Editor
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 4.1 | Navigate to editor | Drafts tab → New Draft (or via menu) | Editor screen opens with split pane | |
| 4.2 | Split pane | Check layout | Source left, preview right | |
| 4.3 | Live preview | Type markdown in source pane | Preview updates after ~300ms | |
| 4.4 | Toolbar: Bold | Click B button | `**text**` inserted at cursor | |
| 4.5 | Toolbar: Italic | Click I button | `*text*` inserted | |
| 4.6 | Toolbar: Heading | Click H button | `## ` inserted | |
| 4.7 | Toolbar: Link | Click link button | `[text](url)` inserted | |
| 4.8 | Toolbar: Code | Click code button | Backticks inserted | |
| 4.9 | Toolbar: Quote | Click quote button | `> ` inserted | |
| 4.10 | Metadata: Title | Enter title | Title field accepts input, max 256 chars | |
| 4.11 | Metadata: Summary | Enter summary | Summary field accepts input, max 1024 chars | |
| 4.12 | Metadata: Tags | Type tag + Enter | Tag chip added | |
| 4.13 | Metadata: Slug | Enter slug | Auto-sanitized (no special chars) | |
| 4.14 | Ctrl+S save | Press Ctrl+S (or Cmd+S) | Draft saved to disk | |
| 4.15 | Back button | Click ← Back | Returns to previous screen | |
| 4.16 | Preview link safety | Add `[click](javascript:alert(1))` in source | Link NOT clickable in preview (scheme blocked) | |
---
## 5. Draft Storage
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 5.1 | Draft saved | Create draft, save, check filesystem | `~/.amethyst/drafts/<slug>.md` exists | |
| 5.2 | Index file | Check filesystem | `~/.amethyst/drafts/index.json` exists with metadata | |
| 5.3 | Drafts screen | Navigate to Drafts | Lists saved drafts with title, date | |
| 5.4 | Resume editing | Click a draft in list | Editor opens with content restored | |
| 5.5 | Delete draft | Click delete on a draft | Confirmation dialog → draft removed | |
| 5.6 | Slug sanitization | Try slug with `../` or special chars | Slug sanitized to safe characters | |
| 5.7 | Directory permissions | `ls -la ~/.amethyst/drafts/` | Dir permissions 700 (Unix) | |
---
## 6. Publish
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 6.1 | Publish button | Fill title + content, click Publish | Event signed and sent to relays | |
| 6.2 | Publish feedback | After publish | Success snackbar / confirmation | |
| 6.3 | Published in feed | After publish, check Reads feed | Your article appears in Global feed | |
| 6.4 | Re-publish (replace) | Edit same draft, publish again | Article updated (same d-tag) | |
| 6.5 | Size limit | Try publishing >100KB content | Error message about content too large | |
---
## 7. Typography (Visual)
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 7.1 | Body text | Read article body | Georgia-style serif, ~18sp, generous line height | |
| 7.2 | Content centered | Check horizontal layout | Content centered with max ~680dp width | |
| 7.3 | Dark mode | Check dark theme | Text ~#E0E0E0 on dark background, comfortable contrast | |
| 7.4 | Blockquotes | Find blockquote | Left border/indent, slightly larger text | |
| 7.5 | Tables | Find table | Renders with columns and rows | |
---
## 8. Security Checks
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 8.1 | XSS in markdown | Article with `<script>alert(1)</script>` | Rendered as literal text, no execution | |
| 8.2 | URI scheme: javascript | Link `[x](javascript:alert(1))` in article | Link not clickable / filtered | |
| 8.3 | URI scheme: file | Link `[x](file:///etc/passwd)` in article | Link not clickable / filtered | |
| 8.4 | Image URL validation | Article with `file:///etc/passwd` as banner | Image not loaded | |
| 8.5 | Slug traversal | Set slug to `../../.ssh/keys` | Sanitized to safe string | |
---
## 9. Edge Cases
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 9.1 | Empty article | Article with no content | Reader shows empty body, no crash | |
| 9.2 | No relays connected | Disconnect all relays → open article | "Connecting to relays..." loading state | |
| 9.3 | Very long article | Article with 10k+ words | Renders without freeze (may be slow) | |
| 9.4 | No banner image | Article without `image` tag | Header renders without banner, no crash | |
| 9.5 | No author metadata | Article from unknown pubkey | Shows pubkey hex, no profile pic | |
| 9.6 | Window resize | Resize during article reading | Layout adapts, ToC shows/hides | |
---
## Notes
_Record any bugs, unexpected behavior, or UX issues here:_
| | Issue | Severity | Notes |
|--|-------|----------|-------|
| | | | |
| | | | |
| | | | |
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus 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.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent 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.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider 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.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag 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.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content
@Immutable @Immutable
class AttestationEvent( class AttestationEvent(
@@ -54,8 +50,7 @@ class AttestationEvent(
sig: HexKey, sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider, EventHintProvider,
AddressHintProvider, AddressHintProvider {
PubKeyHintProvider {
override fun eventHints(): List<EventIdHint> = tags.mapNotNull(ETag::parseAsHint) override fun eventHints(): List<EventIdHint> = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds(): List<HexKey> = tags.mapNotNull(ETag::parseId) override fun linkedEventIds(): List<HexKey> = tags.mapNotNull(ETag::parseId)
@@ -64,12 +59,6 @@ class AttestationEvent(
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId) override fun linkedAddressIds(): List<String> = 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 status() = tags.status()
fun validFrom() = tags.validFrom() fun validFrom() = tags.validFrom()
@@ -94,10 +83,6 @@ class AttestationEvent(
fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse)
fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey)
fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse)
companion object { companion object {
const val KIND = 31871 const val KIND = 31871
const val ALT_DESCRIPTION = "Attestation" const val ALT_DESCRIPTION = "Attestation"
@@ -106,7 +91,6 @@ class AttestationEvent(
dTagId: String, dTagId: String,
about: EventHintBundle<Event>, about: EventHintBundle<Event>,
content: String = "", content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null, status: AttestationStatus? = null,
validFrom: Long? = null, validFrom: Long? = null,
validTo: Long? = null, validTo: Long? = null,
@@ -117,7 +101,6 @@ class AttestationEvent(
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
dTag(dTagId) dTag(dTagId)
about(about) about(about)
validity?.let { validity(it) }
status?.let { status(it) } status?.let { status(it) }
validFrom?.let { validFrom(it) } validFrom?.let { validFrom(it) }
validTo?.let { validTo(it) } validTo?.let { validTo(it) }
@@ -129,7 +112,6 @@ class AttestationEvent(
dTagId: String, dTagId: String,
about: EventHintBundle<BaseReplaceableEvent>, about: EventHintBundle<BaseReplaceableEvent>,
content: String = "", content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null, status: AttestationStatus? = null,
validFrom: Long? = null, validFrom: Long? = null,
validTo: Long? = null, validTo: Long? = null,
@@ -140,7 +122,6 @@ class AttestationEvent(
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
dTag(dTagId) dTag(dTagId)
aboutReplaceable(about) aboutReplaceable(about)
validity?.let { validity(it) }
status?.let { status(it) } status?.let { status(it) }
validFrom?.let { validFrom(it) } validFrom?.let { validFrom(it) }
validTo?.let { validTo(it) } validTo?.let { validTo(it) }
@@ -152,7 +133,6 @@ class AttestationEvent(
dTagId: String, dTagId: String,
about: EventHintBundle<BaseAddressableEvent>, about: EventHintBundle<BaseAddressableEvent>,
content: String = "", content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null, status: AttestationStatus? = null,
validFrom: Long? = null, validFrom: Long? = null,
validTo: Long? = null, validTo: Long? = null,
@@ -163,7 +143,6 @@ class AttestationEvent(
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
dTag(dTagId) dTag(dTagId)
aboutAddressable(about) aboutAddressable(about)
validity?.let { validity(it) }
status?.let { status(it) } status?.let { status(it) }
validFrom?.let { validFrom(it) } validFrom?.let { validFrom(it) }
validTo?.let { validTo(it) } validTo?.let { validTo(it) }
@@ -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.StatusTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag 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.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.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent 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.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
fun TagArrayBuilder<AttestationEvent>.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity))
fun TagArrayBuilder<AttestationEvent>.status(status: AttestationStatus) = addUnique(StatusTag.assemble(status)) fun TagArrayBuilder<AttestationEvent>.status(status: AttestationStatus) = addUnique(StatusTag.assemble(status))
fun TagArrayBuilder<AttestationEvent>.validFrom(timestamp: Long) = addUnique(ValidFromTag.assemble(timestamp)) fun TagArrayBuilder<AttestationEvent>.validFrom(timestamp: Long) = addUnique(ValidFromTag.assemble(timestamp))
@@ -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.StatusTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag 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.ValidToTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.validity() = firstNotNullOfOrNull(ValidityTag::parse)
fun TagArray.status() = firstNotNullOfOrNull(StatusTag::parse) fun TagArray.status() = firstNotNullOfOrNull(StatusTag::parse)
fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse) fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse)
@@ -26,10 +26,9 @@ import com.vitorpamplona.quartz.utils.ensure
enum class AttestationStatus( enum class AttestationStatus(
val code: String, val code: String,
) { ) {
ACCEPTED("accepted"),
REJECTED("rejected"),
VERIFYING("verifying"), VERIFYING("verifying"),
VERIFIED("verified"), VALID("valid"),
INVALID("invalid"),
REVOKED("revoked"), REVOKED("revoked"),
} }
@@ -45,10 +44,9 @@ class StatusTag {
ensure(tag[1].isNotEmpty()) { return null } ensure(tag[1].isNotEmpty()) { return null }
return when (tag[1]) { return when (tag[1]) {
AttestationStatus.ACCEPTED.code -> AttestationStatus.ACCEPTED
AttestationStatus.REJECTED.code -> AttestationStatus.REJECTED
AttestationStatus.VERIFYING.code -> AttestationStatus.VERIFYING 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 AttestationStatus.REVOKED.code -> AttestationStatus.REVOKED
else -> null else -> 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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -40,7 +40,7 @@ class AttestorProficiencyEvent(
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun kinds() = tags.kinds() fun kinds() = tags.kinds()
fun description() = tags.description() fun description() = content.ifBlank { null }
companion object { companion object {
const val KIND = 11871 const val KIND = 11871
@@ -51,10 +51,9 @@ class AttestorProficiencyEvent(
description: String? = null, description: String? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestorProficiencyEvent>.() -> Unit = {}, initializer: TagArrayBuilder<AttestorProficiencyEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) { ) = eventTemplate(KIND, description ?: "", createdAt) {
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
kinds(kinds) kinds(kinds)
description?.let { desc(it) }
initializer() initializer()
} }
} }
@@ -20,11 +20,8 @@
*/ */
package com.vitorpamplona.quartz.experimental.attestations.proficiency 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.experimental.attestations.recommendation.tags.KindTag
import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<AttestorProficiencyEvent>.kinds(kinds: List<Kind>) = addAll(KindTag.assemble(kinds)) fun TagArrayBuilder<AttestorProficiencyEvent>.kinds(kinds: List<Kind>) = addAll(KindTag.assemble(kinds))
fun TagArrayBuilder<AttestorProficiencyEvent>.desc(description: String) = addUnique(DescriptionTag.assemble(description))
@@ -20,10 +20,7 @@
*/ */
package com.vitorpamplona.quartz.experimental.attestations.proficiency 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.experimental.attestations.recommendation.tags.KindTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.kinds() = mapNotNull(KindTag::parse) fun TagArray.kinds() = mapNotNull(KindTag::parse)
fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse)
@@ -41,7 +41,7 @@ class AttestorRecommendationEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun kinds() = tags.kinds() fun kinds() = tags.kinds()
fun description() = tags.description() fun description() = content.ifBlank { null }
companion object { companion object {
const val KIND = 31873 const val KIND = 31873
@@ -53,11 +53,10 @@ class AttestorRecommendationEvent(
description: String? = null, description: String? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestorRecommendationEvent>.() -> Unit = {}, initializer: TagArrayBuilder<AttestorRecommendationEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) { ) = eventTemplate(KIND, description ?: "", createdAt) {
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
dTag(attestorPubKey) dTag(attestorPubKey)
kinds(kinds) kinds(kinds)
description?.let { desc(it) }
initializer() initializer()
} }
} }
@@ -20,11 +20,8 @@
*/ */
package com.vitorpamplona.quartz.experimental.attestations.recommendation 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.experimental.attestations.recommendation.tags.KindTag
import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<AttestorRecommendationEvent>.kinds(kinds: List<Kind>) = addAll(KindTag.assemble(kinds)) fun TagArrayBuilder<AttestorRecommendationEvent>.kinds(kinds: List<Kind>) = addAll(KindTag.assemble(kinds))
fun TagArrayBuilder<AttestorRecommendationEvent>.desc(description: String) = addUnique(DescriptionTag.assemble(description))
@@ -20,10 +20,7 @@
*/ */
package com.vitorpamplona.quartz.experimental.attestations.recommendation 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.experimental.attestations.recommendation.tags.KindTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.kinds() = mapNotNull(KindTag::parse) fun TagArray.kinds() = mapNotNull(KindTag::parse)
fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse)