merge
This commit is contained in:
@@ -406,21 +406,21 @@ 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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+120
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -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
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+363
@@ -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) + "" + 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) + "" + 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -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") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-20
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+79
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -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,
|
||||||
|
|||||||
+289
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
+5
-1
@@ -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,
|
||||||
|
|||||||
+337
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+736
@@ -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 ->
|
||||||
|
Column {
|
||||||
LongFormCard(
|
LongFormCard(
|
||||||
event = event,
|
event = event,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
onAuthorClick = onNavigateToProfile,
|
onAuthorClick = onNavigateToProfile,
|
||||||
onClick = { onNavigateToArticle(event.id) },
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+191
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+4
@@ -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
|
||||||
|
|||||||
+90
-1
@@ -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",
|
||||||
|
|||||||
+20
@@ -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,
|
||||||
|
|||||||
+12
@@ -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() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+208
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+75
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -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 |
|
||||||
|
|--|-------|----------|-------|
|
||||||
|
| | | | |
|
||||||
|
| | | | |
|
||||||
|
| | | | |
|
||||||
+1
-22
@@ -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) }
|
||||||
|
|||||||
-4
@@ -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))
|
||||||
|
|||||||
-3
@@ -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)
|
||||||
|
|||||||
+4
-6
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
-53
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-3
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -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))
|
|
||||||
|
|||||||
-3
@@ -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)
|
|
||||||
|
|||||||
+2
-3
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -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))
|
|
||||||
|
|||||||
-3
@@ -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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user