Merge branch 'main' into claude/review-cache-cleanup-Yr2P6

This commit is contained in:
Vitor Pamplona
2026-03-25 13:20:30 -04:00
committed by GitHub
72 changed files with 5231 additions and 808 deletions
+18 -18
View File
@@ -405,24 +405,24 @@ to `onPause` methods.
### Feature Parity Table
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
| :--- | :--- | :---: | :---: | :--- |
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. |
| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. |
| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. |
| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. |
| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. |
| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. |
| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. |
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. |
| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
|:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------|
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | |
| | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | |
| | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | |
| | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. |
| | GZip Compression | ✅ Full | ✅ Full | |
| | BitSet | ✅ Full | ✅ Full | |
| | LargeCache | ✅ Full | ✅ Full | |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | |
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
| **Utilities** | URL Encoding / Decoding | ✅ Full | ✅ Full | |
| | Unicode Normalization | ✅ Full | ✅ Full | |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
## Contributing
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg2
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -72,6 +73,27 @@ fun InformationDialog(
InformationDialog(title = stringRes(obj.titleResId), textContent = str, moreInfo = stack, buttonColors, onDismiss)
}
@Composable
fun InformationDialog(
obj: ThrowableToastMsg2,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onDismiss: () -> Unit,
) {
val str = stringRes(obj.description)
val stack =
remember(obj) {
val writer = StringWriter()
writer.append("\n")
obj.throwable.printStackTrace(PrintWriter(writer))
writer.toString()
}
InformationDialog(title = stringRes(obj.titleResId), textContent = str, moreInfo = stack, buttonColors, onDismiss)
}
@Composable
fun InformationDialog(
title: String,
@@ -82,6 +82,12 @@ fun DisplayErrorMessages(
}
}
is ThrowableToastMsg2 -> {
InformationDialog(obj) {
toastManager.clearToasts()
}
}
is MultiErrorToastMsg -> {
MultiUserErrorMessageDialog(obj, accountViewModel, nav)
}
@@ -28,3 +28,10 @@ class ThrowableToastMsg(
val msg: String? = null,
val throwable: Throwable,
) : ToastMsg()
@Immutable
class ThrowableToastMsg2(
val titleResId: Int,
val description: Int,
val throwable: Throwable,
) : ToastMsg()
@@ -62,6 +62,14 @@ class ToastManager {
toasts.tryEmit(ThrowableToastMsg(titleResId, message, throwable))
}
fun toast(
titleResId: Int,
description: Int,
throwable: Throwable,
) {
toasts.tryEmit(ThrowableToastMsg2(titleResId, description, throwable))
}
fun toast(
titleResId: Int,
resourceId: Int,
@@ -34,12 +34,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Approval
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.RemoveDone
@@ -84,7 +81,6 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
@@ -106,8 +102,7 @@ fun RenderAttestationPreview() {
arrayOf(
arrayOf("d", "af5aa898:fe108febb997:1773941524"),
arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"),
arrayOf("s", "verified"),
arrayOf("v", "valid"),
arrayOf("s", "valid"),
arrayOf("client", "attestr.xyz"),
),
)
@@ -161,19 +156,17 @@ fun RenderAttestation(
accountViewModel: AccountViewModel,
nav: INav,
) {
val validity = remember(noteEvent) { noteEvent.validity() }
val status = remember(noteEvent) { noteEvent.status() }
val validFrom = remember(noteEvent) { noteEvent.validFrom() }
val validTo = remember(noteEvent) { noteEvent.validTo() }
val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
val statusColor = remember(status, validity) { attestationColor(status, validity) }
val statusIcon = remember(status, validity) { attestationIcon(status, validity) }
val statusLabel = attestationStatusLabel(status, validity)
val statusColor = remember(status) { attestationColor(status) }
val statusIcon = remember(status) { attestationIcon(status) }
val statusLabel = attestationStatusLabel(status)
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
Column(
modifier =
@@ -257,13 +250,6 @@ fun RenderAttestation(
)
}
}
} else if (aboutPubkey != null) {
LoadUser(aboutPubkey, accountViewModel) {
if (it != null) {
Spacer(modifier = DoubleVertSpacer)
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
@@ -590,49 +576,31 @@ fun RenderAttestorProficiency(
}
}
private fun attestationColor(
status: AttestationStatus?,
validity: Validity?,
): Color =
private fun attestationColor(status: AttestationStatus?): Color =
when {
validity == Validity.INVALID -> Color(0xFFB71C1C)
validity == Validity.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.INVALID -> Color(0xFFB71C1C)
status == AttestationStatus.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.REVOKED -> Color(0xFFB21CB7)
status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFF173CF5)
status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0)
else -> Color(0xFF757575)
}
private fun attestationIcon(
status: AttestationStatus?,
validity: Validity?,
): ImageVector =
private fun attestationIcon(status: AttestationStatus?): ImageVector =
when {
validity == Validity.INVALID -> Icons.Default.Close
validity == Validity.VALID -> Icons.Default.CheckCircle
status == AttestationStatus.INVALID -> Icons.Default.Close
status == AttestationStatus.VALID -> Icons.Default.CheckCircle
status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone
status == AttestationStatus.REJECTED -> Icons.Default.Delete
status == AttestationStatus.VERIFIED -> Icons.Default.Approval
status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop
status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty
else -> Icons.Default.ErrorOutline
}
@Composable
private fun attestationStatusLabel(
status: AttestationStatus?,
validity: Validity?,
): String =
private fun attestationStatusLabel(status: AttestationStatus?): String =
when {
validity == Validity.INVALID -> stringRes(R.string.attestation_invalid)
validity == Validity.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.INVALID -> stringRes(R.string.attestation_invalid)
status == AttestationStatus.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked)
status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected)
status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified)
status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying)
status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted)
else -> stringRes(R.string.attestation)
}
@@ -962,6 +962,12 @@ class AccountViewModel(
Log.w("AccountViewModel", "AutomaticallyUnauthorizedException", e)
} catch (e: SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException) {
Log.w("AccountViewModel", "TimedOutRunningOnBackgroundWithoutAutomaticPermissionExceptionException", e)
} catch (e: IllegalStateException) {
toastManager.toast(
R.string.signer_not_found_exception,
R.string.signer_illegal_state_exception_description,
e,
)
}
}
}
@@ -71,7 +71,7 @@
<string name="following">" অনুসরণ"</string>
<string name="followers">" অনুসারী"</string>
<string name="number_following">"%1$s অনুসরণ"</string>
<string name="number_followers">""</string>
<string name="number_followers">"%1$s অনুসারী"</string>
<string name="profile">প্রোফাইল</string>
<string name="security_filters">নিরাপত্তা-ফিল্টার</string>
<string name="log_out">লগ আউট</string>
@@ -448,6 +448,8 @@
<string name="poll_zap_value_max">打闪最高金额</string>
<string name="poll_consensus_threshold">共识</string>
<string name="poll_consensus_threshold_percent">(0–100)%</string>
<string name="poll_single_choice">单选</string>
<string name="poll_multiple_choice">多选</string>
<string name="poll_closing_date_time">投票结束日期 &amp; 时间</string>
<string name="poll_closing_in">投票结束于 %1$s</string>
<string name="poll_closing_time">后关闭</string>
@@ -487,6 +489,9 @@
<string name="zap_type_anonymous_explainer">接收方和公众不知道谁发送了付款</string>
<string name="zap_type_nonzap">非打闪</string>
<string name="zap_type_nonzap_explainer">Nostr 上没有痕迹,仅在闪电上</string>
<string name="post_anonymously">匿名</string>
<string name="post_anonymously_explainer">使用新的一次性身份发布。您的帐户将不会被链接到这个回复。</string>
<string name="anonymous_reply_warning">此回复将从新的匿名身份发布</string>
<string name="file_server">文件服务器</string>
<string name="file_server_description">选择上传文件时使用的服务器</string>
<string name="zap_forward_lnAddress">闪电地址或 @User</string>
@@ -786,7 +791,7 @@
<string name="preferences">偏好设置</string>
<string name="user_preferences">用户首选项</string>
<string name="translations">翻译</string>
<string name="reactions">反应</string>
<string name="reactions">回应</string>
<string name="settings">设置</string>
<string name="account_settings">账户设置</string>
<string name="app_settings">应用程序设置</string>
@@ -1119,8 +1124,8 @@
<string name="like_description">点赞</string>
<string name="zap_description">打闪</string>
<string name="change_reaction">修改快速回应</string>
<string name="reactions_settings">反应设置</string>
<string name="reactions_settings_description">配置显示的反应按钮、它们的顺序以及是否显示计数器。</string>
<string name="reactions_settings">回应设置</string>
<string name="reactions_settings_description">配置显示的回应按钮、按钮顺序及是否显示回应计数。</string>
<string name="reactions_settings_enabled">已启用</string>
<string name="reactions_settings_show_counter">显示计数</string>
<string name="reactions_settings_reorder">调整顺序</string>
@@ -1129,7 +1134,7 @@
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_boost_description">转发或引用此笔记</string>
<string name="reactions_settings_like">点赞</string>
<string name="reactions_settings_like_description">用表情符号对此笔记进行反应</string>
<string name="reactions_settings_like_description">使用表情符号回应笔记</string>
<string name="reactions_settings_zap">打闪</string>
<string name="reactions_settings_zap_description">给作者发送 Lightning 网络付款</string>
<string name="reactions_settings_share">分享</string>
@@ -1541,7 +1546,7 @@
<string name="kind_private_relays">私密中继</string>
<string name="kind_proxy_relays">代理中继</string>
<string name="kind_public_message">公开消息</string>
<string name="kind_reactions">反应</string>
<string name="kind_reactions">回应</string>
<string name="kind_contact_card">名片</string>
<string name="kind_relay_auth">中继认证</string>
<string name="kind_relay_discovery">中继发现</string>
+2 -1
View File
@@ -68,7 +68,8 @@
<string name="signer_not_found_exception">Signer not found</string>
<string name="signer_not_found_exception_description">Was the Signer app uninstalled? Check if the signer is installed and has this account. Log off and Log in again of the signer app has changed.</string>
<string name="signer_illegal_state_exception">Signer misbehaved</string>
<string name="signer_illegal_state_exception_description">External signer returned a payload that is strange for the request. There might be a bug on either Amethyst or the Signer.</string>
<string name="zaps">Zaps</string>
<string name="view_count">View count</string>
@@ -27,9 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.mac.FixedKey
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
@RunWith(AndroidJUnit4::class)
class ChaCha20Benchmark {
@@ -58,15 +61,12 @@ class ChaCha20Benchmark {
}
}
/*
Removed in the conversion to KMP
@Test
fun encryptNative() {
benchmarkRule.measureRepeated {
chaCha.encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
*/
@Test
fun decryptLibSodium() {
@@ -75,13 +75,30 @@ class ChaCha20Benchmark {
}
}
/*
Removed in the conversion to KMP
@Test
fun decryptNative() {
benchmarkRule.measureRepeated {
chaCha.decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey)
}
}
*/
fun encryptNative(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = Cipher.getInstance("ChaCha20")
cipher.init(Cipher.ENCRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(message)
}
fun decryptNative(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = Cipher.getInstance("ChaCha20")
cipher.init(Cipher.DECRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(message)
}
}
+5
View File
@@ -72,6 +72,11 @@ kotlin {
// Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources)
// Markdown rendering (richtext-commonmark)
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
}
}
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.article
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
@Composable
fun ArticleHeader(
title: String,
authorName: String?,
authorPicture: String?,
publishedAt: String?,
readingTimeMinutes: Int?,
bannerUrl: String?,
modifier: Modifier = Modifier,
onAuthorClick: (() -> Unit)? = null,
) {
Column(modifier = modifier.fillMaxWidth()) {
// Banner image
if (!bannerUrl.isNullOrBlank() &&
(bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://"))
) {
AsyncImage(
model = bannerUrl,
contentDescription = "Article banner",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(300.dp),
)
Spacer(Modifier.height(24.dp))
}
// Title
Text(
text = title,
style =
MaterialTheme.typography.headlineLarge.copy(
fontSize = 34.sp,
fontWeight = FontWeight.Bold,
lineHeight = 40.sp,
letterSpacing = (-0.5).sp,
),
)
Spacer(Modifier.height(16.dp))
// Author + metadata row
Row(verticalAlignment = Alignment.CenterVertically) {
if (!authorPicture.isNullOrBlank() &&
(authorPicture.startsWith("https://") || authorPicture.startsWith("http://"))
) {
AsyncImage(
model = authorPicture,
contentDescription = "Author",
modifier = Modifier.size(40.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
Spacer(Modifier.width(12.dp))
}
Column {
if (!authorName.isNullOrBlank()) {
Text(
text = authorName,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
val metaParts = mutableListOf<String>()
readingTimeMinutes?.let { metaParts.add("$it min read") }
publishedAt?.let { metaParts.add(it) }
if (metaParts.isNotEmpty()) {
Text(
text = metaParts.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(24.dp))
}
}
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.article
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)")
private val TRAILING_HASHES_REGEX = Regex("#+$")
data class TocEntry(
val level: Int,
val text: String,
val index: Int,
)
/**
* Extracts table of contents entries from markdown content.
* Parses ATX headings (# H1, ## H2, etc.), skipping code blocks.
*/
fun extractTableOfContents(markdown: String): List<TocEntry> {
val entries = mutableListOf<TocEntry>()
var inCodeBlock = false
var headingIndex = 0
markdown.lines().forEach { line ->
val trimmed = line.trim()
if (trimmed.startsWith("```")) {
inCodeBlock = !inCodeBlock
return@forEach
}
if (inCodeBlock) return@forEach
val match = HEADING_REGEX.find(trimmed)
if (match != null) {
val level = match.groupValues[1].length
val text =
match.groupValues[2]
.trim()
.replace(TRAILING_HASHES_REGEX, "")
.trim()
if (text.isNotEmpty() && level <= 3) {
entries.add(TocEntry(level = level, text = text, index = headingIndex))
}
headingIndex++
}
}
return entries
}
@Composable
fun TableOfContents(
entries: List<TocEntry>,
activeEntryIndex: Int?,
onEntryClick: (TocEntry) -> Unit,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
Column(
modifier =
modifier
.width(240.dp)
.verticalScroll(scrollState)
.padding(vertical = 16.dp),
) {
entries.forEach { entry ->
val isActive = entry.index == activeEntryIndex
val accentColor = MaterialTheme.colorScheme.primary
Text(
text = entry.text,
style =
MaterialTheme.typography.bodySmall.copy(
fontSize = 13.sp,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clickable { onEntryClick(entry) }
.padding(
start = ((entry.level - 1) * 16).dp,
top = 4.dp,
bottom = 4.dp,
end = 8.dp,
).then(
if (isActive) {
Modifier.drawBehind {
drawLine(
color = accentColor,
start = Offset(0f, 0f),
end = Offset(0f, size.height),
strokeWidth = 3.dp.toPx(),
)
}
} else {
Modifier
},
),
)
}
}
}
@@ -0,0 +1,363 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.editor
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
/**
* State holder for a markdown editor with selection-aware formatting.
*
* Solves the focus/selection bug: toolbar buttons steal focus from TextField,
* collapsing the selection. We cache the last known selection on every value
* change, and toolbar operations use the cached selection.
*/
class MarkdownEditorState(
initial: String = "",
) {
var value by mutableStateOf(TextFieldValue(initial))
private set
/** Cached selection — updated on every onValueChange, survives focus loss. */
var lastSelection: TextRange = TextRange.Zero
private set
fun onValueChange(newValue: TextFieldValue) {
value = newValue
// Only cache non-zero selections (focus loss sends collapsed range)
if (newValue.selection.length > 0 || lastSelection == TextRange.Zero) {
lastSelection = newValue.selection
}
// Also cache cursor position when no selection
if (newValue.selection.collapsed) {
lastSelection = newValue.selection
}
}
fun loadContent(content: String) {
value = TextFieldValue(content)
lastSelection = TextRange.Zero
}
val text: String get() = value.text
// --- Active state detection (uses current value.selection for display) ---
val isBold: Boolean
get() = isWrapped("**", "**")
val isItalic: Boolean
get() = isWrappedItalic()
val isStrikethrough: Boolean
get() = isWrapped("~~", "~~")
val isInlineCode: Boolean
get() = isWrapped("`", "`")
val isBlockquote: Boolean
get() = isLinePrefix("> ")
val isUnorderedList: Boolean
get() = isLinePrefix("- ")
val isOrderedList: Boolean
get() {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
val line = text.substring(lineStart)
return line.matches(Regex("^\\d+\\.\\s.*"))
}
val isTaskList: Boolean
get() = isLinePrefix("- [ ] ") || isLinePrefix("- [x] ")
val headingLevel: Int?
get() {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
val line = text.substring(lineStart)
return when {
line.startsWith("### ") -> 3
line.startsWith("## ") -> 2
line.startsWith("# ") -> 1
else -> null
}
}
// --- Formatting operations (use lastSelection to survive focus loss) ---
fun toggleBold() {
applyToggleWrap("**", "**")
}
fun toggleItalic() {
applyToggleWrapItalic()
}
fun toggleStrikethrough() {
applyToggleWrap("~~", "~~")
}
fun toggleInlineCode() {
applyToggleWrap("`", "`")
}
fun setHeading(level: Int?) {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
val line = text.substring(lineStart)
// Remove existing heading prefix
val stripped =
when {
line.startsWith("### ") -> line.removePrefix("### ")
line.startsWith("## ") -> line.removePrefix("## ")
line.startsWith("# ") -> line.removePrefix("# ")
else -> line
}
val oldPrefixLen =
when {
line.startsWith("### ") -> 4
line.startsWith("## ") -> 3
line.startsWith("# ") -> 2
else -> 0
}
val newPrefix =
when (level) {
1 -> "# "
2 -> "## "
3 -> "### "
else -> ""
}
val lineEnd = text.indexOf('\n', lineStart).let { if (it == -1) text.length else it }
val newText = text.substring(0, lineStart) + newPrefix + stripped + text.substring(lineEnd)
val shift = newPrefix.length - oldPrefixLen
value =
TextFieldValue(
text = newText,
selection = TextRange((sel.min + shift).coerceAtLeast(lineStart), (sel.max + shift).coerceAtLeast(lineStart)),
)
lastSelection = value.selection
}
fun toggleBlockquote() {
applyToggleLinePrefix("> ")
}
fun toggleUnorderedList() {
applyToggleLinePrefix("- ")
}
fun toggleOrderedList() {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
val line = text.substring(lineStart)
if (line.matches(Regex("^\\d+\\.\\s.*"))) {
// Remove ordered list prefix
val prefixEnd = line.indexOf(". ") + 2
val newText = text.substring(0, lineStart) + line.substring(prefixEnd) + text.substring(lineStart + line.indexOf('\n').let { if (it == -1) line.length else it })
value =
TextFieldValue(
text = text.substring(0, lineStart) + line.substring(prefixEnd),
selection = TextRange((sel.min - prefixEnd).coerceAtLeast(lineStart)),
)
} else {
applyToggleLinePrefix("1. ")
}
lastSelection = value.selection
}
fun toggleTaskList() {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
when {
text.startsWith("- [ ] ", lineStart) -> {
// Remove task list prefix
val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
val shift = 6
value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
lastSelection = value.selection
}
text.startsWith("- [x] ", lineStart) -> {
val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
val shift = 6
value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
lastSelection = value.selection
}
else -> {
applyToggleLinePrefix("- [ ] ")
}
}
}
fun toggleCodeBlock() {
applyToggleWrap("```\n", "\n```")
}
fun insertHorizontalRule() {
val sel = lastSelection
val insert = "\n---\n"
val newText = text.substring(0, sel.min) + insert + text.substring(sel.max)
value = TextFieldValue(text = newText, selection = TextRange(sel.min + insert.length))
lastSelection = value.selection
}
fun insertLink() {
val sel = lastSelection
val selected = text.substring(sel.min, sel.max)
if (selected.isNotEmpty()) {
val newText = text.substring(0, sel.min) + "[$selected](url)" + text.substring(sel.max)
val urlStart = sel.min + selected.length + 3
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
} else {
val newText = text.substring(0, sel.min) + "[](url)" + text.substring(sel.min)
value = TextFieldValue(text = newText, selection = TextRange(sel.min + 1))
}
lastSelection = value.selection
}
fun insertImage() {
val sel = lastSelection
val selected = text.substring(sel.min, sel.max)
if (selected.isNotEmpty()) {
val newText = text.substring(0, sel.min) + "![$selected](url)" + text.substring(sel.max)
val urlStart = sel.min + selected.length + 4
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
} else {
val newText = text.substring(0, sel.min) + "![alt](url)" + text.substring(sel.min)
val urlStart = sel.min + 7
value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
}
lastSelection = value.selection
}
// --- Private helpers ---
private fun isWrapped(
prefix: String,
suffix: String,
): Boolean {
val sel = value.selection
val start = sel.min
val end = sel.max
return start >= prefix.length &&
end + suffix.length <= text.length &&
text.substring(start - prefix.length, start) == prefix &&
text.substring(end, end + suffix.length) == suffix
}
private fun isWrappedItalic(): Boolean {
val sel = value.selection
val start = sel.min
val end = sel.max
if (start < 1 || end + 1 > text.length) return false
if (text[start - 1] != '*' || text[end] != '*') return false
val hasBoldBefore = start >= 2 && text[start - 2] == '*'
val hasBoldAfter = end + 1 < text.length && text[end + 1] == '*'
return !hasBoldBefore && !hasBoldAfter
}
private fun isLinePrefix(prefix: String): Boolean {
val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
return text.startsWith(prefix, lineStart)
}
private fun applyToggleWrap(
prefix: String,
suffix: String,
) {
val sel = lastSelection
val start = sel.min
val end = sel.max
val wrapped =
start >= prefix.length &&
end + suffix.length <= text.length &&
text.substring(start - prefix.length, start) == prefix &&
text.substring(end, end + suffix.length) == suffix
value =
if (wrapped) {
val newText =
text.substring(0, start - prefix.length) +
text.substring(start, end) +
text.substring(end + suffix.length)
TextFieldValue(newText, TextRange(start - prefix.length, end - prefix.length))
} else if (start == end) {
val newText = text.substring(0, start) + prefix + suffix + text.substring(start)
TextFieldValue(newText, TextRange(start + prefix.length))
} else {
val newText = text.substring(0, start) + prefix + text.substring(start, end) + suffix + text.substring(end)
TextFieldValue(newText, TextRange(start + prefix.length, end + prefix.length))
}
lastSelection = value.selection
}
private fun applyToggleWrapItalic() {
val sel = lastSelection
val start = sel.min
val end = sel.max
val isItalic =
start >= 1 &&
end + 1 <= text.length &&
text[start - 1] == '*' &&
text[end] == '*' &&
!(start >= 2 && text[start - 2] == '*') &&
!(end + 1 < text.length && text[end + 1] == '*')
if (isItalic) {
val newText = text.substring(0, start - 1) + text.substring(start, end) + text.substring(end + 1)
value = TextFieldValue(newText, TextRange(start - 1, end - 1))
} else {
applyToggleWrap("*", "*")
return
}
lastSelection = value.selection
}
private fun applyToggleLinePrefix(prefix: String) {
val sel = lastSelection
val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
value =
if (text.startsWith(prefix, lineStart)) {
val newText = text.substring(0, lineStart) + text.substring(lineStart + prefix.length)
val shift = prefix.length
TextFieldValue(newText, TextRange((sel.min - shift).coerceAtLeast(lineStart), (sel.max - shift).coerceAtLeast(lineStart)))
} else {
val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart)
TextFieldValue(newText, TextRange(sel.min + prefix.length, sel.max + prefix.length))
}
lastSelection = value.selection
}
}
@@ -0,0 +1,184 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.editor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.FormatBold
import androidx.compose.material.icons.filled.FormatItalic
import androidx.compose.material.icons.filled.FormatListBulleted
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.FormatStrikethrough
import androidx.compose.material.icons.filled.HorizontalRule
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Markdown toolbar with Material icons, grouped formatting buttons, and active state.
* Uses [MarkdownEditorState] for selection-aware toggle behavior.
*
* Buttons use `focusProperties { canFocus = false }` to prevent stealing focus
* from the editor TextField, preserving the user's text selection.
*/
@Composable
fun MarkdownToolbar(
state: MarkdownEditorState,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// --- Headings ---
ToolbarButton(label = "H1", active = state.headingLevel == 1) { state.setHeading(if (state.headingLevel == 1) null else 1) }
ToolbarButton(label = "H2", active = state.headingLevel == 2) { state.setHeading(if (state.headingLevel == 2) null else 2) }
ToolbarButton(label = "H3", active = state.headingLevel == 3) { state.setHeading(if (state.headingLevel == 3) null else 3) }
Separator()
// --- Inline formatting ---
ToolbarIconButton(Icons.Default.FormatBold, "Bold", state.isBold) { state.toggleBold() }
ToolbarIconButton(Icons.Default.FormatItalic, "Italic", state.isItalic) { state.toggleItalic() }
ToolbarIconButton(Icons.Default.FormatStrikethrough, "Strikethrough", state.isStrikethrough) { state.toggleStrikethrough() }
ToolbarIconButton(Icons.Default.Code, "Inline code", state.isInlineCode) { state.toggleInlineCode() }
Separator()
// --- Lists ---
ToolbarIconButton(Icons.Default.FormatListBulleted, "Bullet list", state.isUnorderedList) { state.toggleUnorderedList() }
ToolbarIconButton(Icons.Default.FormatListNumbered, "Numbered list", state.isOrderedList) { state.toggleOrderedList() }
ToolbarIconButton(Icons.Default.Checklist, "Task list", state.isTaskList) { state.toggleTaskList() }
Separator()
// --- Block elements ---
ToolbarIconButton(Icons.Default.FormatQuote, "Blockquote", state.isBlockquote) { state.toggleBlockquote() }
ToolbarButton(label = "```", active = false) { state.toggleCodeBlock() }
ToolbarIconButton(Icons.Default.HorizontalRule, "Horizontal rule", false) { state.insertHorizontalRule() }
Separator()
// --- Insert ---
ToolbarIconButton(Icons.Default.Link, "Link", false) { state.insertLink() }
ToolbarIconButton(Icons.Default.Image, "Image", false) { state.insertImage() }
}
}
@Composable
private fun Separator() {
VerticalDivider(
modifier = Modifier.height(24.dp).padding(horizontal = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
@Composable
private fun ToolbarIconButton(
icon: ImageVector,
contentDescription: String,
active: Boolean,
onClick: () -> Unit,
) {
val containerColor =
if (active) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor =
if (active) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
SmallFloatingActionButton(
onClick = onClick,
containerColor = containerColor,
contentColor = contentColor,
modifier =
Modifier
.size(32.dp)
.focusProperties { canFocus = false },
) {
Icon(
icon,
contentDescription = contentDescription,
modifier = Modifier.size(18.dp),
)
}
}
@Composable
private fun ToolbarButton(
label: String,
active: Boolean,
onClick: () -> Unit,
) {
val containerColor =
if (active) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor =
if (active) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
SmallFloatingActionButton(
onClick = onClick,
containerColor = containerColor,
contentColor = contentColor,
modifier =
Modifier
.size(32.dp)
.focusProperties { canFocus = false },
) {
Text(
text = label,
fontSize = 11.sp,
color = contentColor,
)
}
}
@@ -0,0 +1,160 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.editor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp
/**
* Form fields for article metadata: title, summary, banner, tags, slug.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MetadataPanel(
title: String,
onTitleChange: (String) -> Unit,
summary: String,
onSummaryChange: (String) -> Unit,
bannerUrl: String,
onBannerUrlChange: (String) -> Unit,
tags: List<String>,
onTagsChange: (List<String>) -> Unit,
slug: String,
onSlugChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var tagInput by remember { mutableStateOf("") }
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = title,
onValueChange = { if (it.length <= 256) onTitleChange(it) },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("${title.length}/256") },
)
OutlinedTextField(
value = summary,
onValueChange = { if (it.length <= 1024) onSummaryChange(it) },
label = { Text("Summary") },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("${summary.length}/1024") },
)
OutlinedTextField(
value = bannerUrl,
onValueChange = onBannerUrlChange,
label = { Text("Banner Image URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Tags chip input
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = tagInput,
onValueChange = { tagInput = it },
label = { Text("Add tag (Enter to add)") },
singleLine = true,
modifier =
Modifier.weight(1f).onKeyEvent { event ->
if (event.key == Key.Enter && tagInput.isNotBlank()) {
val newTag = tagInput.trim().lowercase()
if (newTag !in tags) {
onTagsChange(tags + newTag)
}
tagInput = ""
true
} else {
false
}
},
)
}
if (tags.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
tags.forEach { tag ->
AssistChip(
onClick = { onTagsChange(tags - tag) },
label = { Text(tag) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = "Remove $tag",
modifier = Modifier.size(16.dp),
)
},
)
}
}
}
}
OutlinedTextField(
value = slug,
onValueChange = onSlugChange,
label = { Text("Slug (d-tag)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Used as the unique identifier for this article") },
)
}
}
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.markdown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.unit.Density
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning", "highlight")
/**
* Escapes markdown special characters inside highlighted text so it doesn't
* break the markdown parser when wrapped in a link.
*/
private fun escapeMarkdownInLink(text: String): String =
text
.replace("[", "\\[")
.replace("]", "\\]")
.replace("(", "\\(")
.replace(")", "\\)")
@Composable
fun RenderMarkdown(
content: String,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
fontScale: Float = 1.0f,
highlightedTexts: List<String> = emptyList(),
) {
val processedContent =
remember(content, highlightedTexts) {
if (highlightedTexts.isEmpty()) {
content
} else {
var result = content
highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text ->
val idx = result.indexOf(text)
if (idx >= 0) {
val escaped = escapeMarkdownInLink(text)
result = result.replaceFirst(text, "[$escaped](highlight://$index)")
}
}
result
}
}
val astNode =
remember(processedContent) {
CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent)
}
val uriHandler =
remember(onLinkClick) {
object : UriHandler {
override fun openUri(uri: String) {
val scheme = uri.substringBefore(":").lowercase()
if (scheme in ALLOWED_SCHEMES) {
onLinkClick(uri)
}
}
}
}
val currentDensity = LocalDensity.current
val scaledDensity =
remember(fontScale, currentDensity) {
if (fontScale == 1.0f) {
currentDensity
} else {
Density(
density = currentDensity.density * fontScale,
fontScale = currentDensity.fontScale,
)
}
}
CompositionLocalProvider(
LocalUriHandler provides uriHandler,
LocalDensity provides scaledDensity,
) {
RichText(
modifier = modifier,
style = RichTextStyle(),
) {
BasicMarkdown(astNode)
}
}
}
@@ -18,24 +18,15 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.attestations.recommendation.tags
package com.vitorpamplona.amethyst.commons.model.highlights
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class DescriptionTag {
companion object {
const val TAG_NAME = "desc"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
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)
}
}
data class HighlightData(
val id: String,
val text: String,
val note: String? = null,
val articleAddressTag: String,
val articleTitle: String? = null,
val createdAt: Long,
val published: Boolean = false,
val eventId: String? = null,
)
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.nip23LongContent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared action for publishing long-form content (NIP-23 kind 30023).
* Handles title, summary, image, tags, and d-tag for addressable events.
*/
object LongFormPublishAction {
private const val MAX_CONTENT_BYTES = 100_000
/**
* Publishes a long-form text note (NIP-23 kind 30023).
*
* @param title The article title
* @param content The markdown body content
* @param summary Optional article summary
* @param image Optional banner image URL
* @param tags List of hashtag topics
* @param dTag Unique identifier for this addressable event (slug)
* @param signer The NostrSigner to sign the event
* @return Signed LongTextNoteEvent ready to broadcast
* @throws IllegalStateException if signer is not writeable
*/
suspend fun publish(
title: String,
content: String,
summary: String?,
image: String?,
tags: List<String>,
dTag: String,
signer: NostrSigner,
): LongTextNoteEvent {
if (!signer.isWriteable()) {
throw IllegalStateException("Cannot publish: signer is not writeable")
}
if (content.toByteArray().size > MAX_CONTENT_BYTES) {
throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
}
val template =
LongTextNoteEvent.build(
description = content,
title = title,
summary = summary,
image = image,
publishedAt = TimeUtils.now(),
dTag = dTag,
) {
tags.forEach { hashtag(it) }
}
return signer.sign(template)
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.nip23LongContent
import kotlin.math.ceil
import kotlin.math.max
/**
* Calculates estimated reading time for markdown content.
* Uses 238 WPM for prose (Brysbaert 2019), 80 WPM for code blocks,
* and Medium's image decay formula (12 sec first, -1 each, min 3).
*/
object ReadingTimeCalculator {
private const val PROSE_WPM = 238.0
private const val CODE_WPM = 80.0
private val WHITESPACE_REGEX = "\\s+".toRegex()
private val IMAGE_REGEX = Regex("!\\[.*?]\\(.*?\\)")
private val IMAGE_STRIP_REGEX = Regex("!\\[.*?]\\(.*?\\)")
private val LINK_REGEX = Regex("\\[([^]]*)]\\([^)]*\\)")
private val FORMATTING_REGEX = Regex("[*_~`#>]")
private val LIST_MARKER_REGEX = Regex("^-\\s+|^\\d+\\.\\s+")
private val HORIZONTAL_RULE_REGEX = Regex("^---+$|^\\*\\*\\*+$")
fun calculate(markdownContent: String): Int {
var proseWords = 0
var codeWords = 0
var imageCount = 0
var inCodeBlock = false
markdownContent.lines().forEach { line ->
val trimmed = line.trim()
if (trimmed.startsWith("```")) {
inCodeBlock = !inCodeBlock
return@forEach
}
if (inCodeBlock) {
codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() }
return@forEach
}
// Count images
val imageMatches = IMAGE_REGEX.findAll(trimmed)
imageCount += imageMatches.count()
// Strip markdown syntax for word counting
val stripped =
trimmed
.replace(IMAGE_STRIP_REGEX, "") // images
.replace(LINK_REGEX, "$1") // links -> text only
.replace(FORMATTING_REGEX, "") // formatting
.replace(LIST_MARKER_REGEX, "") // list markers
.replace(HORIZONTAL_RULE_REGEX, "") // horizontal rules
proseWords += stripped.split(WHITESPACE_REGEX).count { it.isNotBlank() }
}
// Medium's image time decay: 12 sec first, -1 each, min 3
val imageSeconds = (0 until imageCount).sumOf { max(12 - it, 3) }
val totalMinutes = (proseWords / PROSE_WPM) + (codeWords / CODE_WPM) + (imageSeconds / 60.0)
return max(1, ceil(totalMinutes).toInt())
}
}
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -143,6 +144,16 @@ sealed class DesktopScreen {
val noteId: String,
) : DesktopScreen()
data class Article(
val addressTag: String,
) : DesktopScreen()
data class Editor(
val draftSlug: String? = null,
) : DesktopScreen()
data object Drafts : DesktopScreen()
data object Settings : DesktopScreen()
}
@@ -369,6 +380,8 @@ fun main() {
Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) })
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) })
Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) })
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
@@ -597,6 +610,13 @@ fun MainContent(
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
}
val highlightStore = remember { DesktopHighlightStore(appScope) }
val draftStore =
remember {
com.vitorpamplona.amethyst.desktop.service.drafts
.DesktopDraftStore(appScope)
}
// Subscribe to incoming DMs and process into chatroomList
LaunchedEffect(account) {
relayManager.connectedRelays.first { it.isNotEmpty() }
@@ -716,6 +736,8 @@ fun MainContent(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -753,6 +775,8 @@ fun MainContent(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -0,0 +1,289 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.drafts
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermission
import java.time.Instant
data class DraftMetadata(
val title: String = "",
val summary: String? = null,
val image: String? = null,
val tags: List<String> = emptyList(),
val createdAt: String = Instant.now().toString(),
val updatedAt: String = Instant.now().toString(),
val published: Boolean = false,
)
data class DraftEntry(
val slug: String,
val metadata: DraftMetadata,
)
/**
* Local draft storage for long-form articles.
* Stores markdown content as .md files and metadata in index.json.
* Uses atomic writes and restrictive file permissions.
*/
class DesktopDraftStore(
private val scope: CoroutineScope,
) {
private val mapper = jacksonObjectMapper()
private val mutex = Mutex()
private var cachedIndex: MutableMap<String, DraftMetadata>? = null
private val _drafts = MutableStateFlow<List<DraftEntry>>(emptyList())
val drafts: StateFlow<List<DraftEntry>> = _drafts.asStateFlow()
private val draftsDir: File by lazy {
val dir = File(System.getProperty("user.home"), ".amethyst/drafts")
if (!dir.exists()) {
dir.mkdirs()
setDirPermissions(dir)
}
dir
}
private val indexFile: File get() = File(draftsDir, "index.json")
init {
scope.launch(Dispatchers.IO) {
cachedIndex = null
loadIndex()
}
}
/**
* Sanitizes a slug to prevent path traversal and ensure safe filenames.
*/
private fun sanitizeSlug(slug: String): String {
val sanitized =
slug
.replace("/", "")
.replace("\\", "")
.replace("\u0000", "")
.trim()
.lowercase()
.replace(Regex("[^a-z0-9_-]"), "-")
.replace(Regex("-+"), "-")
.trimStart('-')
.trimEnd('-')
.take(128)
require(sanitized.isNotEmpty()) { "Slug cannot be empty after sanitization" }
// Validate canonical path stays within drafts dir
val resolved = File(draftsDir, "$sanitized.md").canonicalPath
require(resolved.startsWith(draftsDir.canonicalPath)) {
"Slug resolves outside drafts directory"
}
return sanitized
}
/**
* Generates a slug from a title. Falls back to timestamp if title is blank.
*/
fun slugFromTitle(title: String): String {
if (title.isBlank()) return "untitled-${Instant.now().epochSecond}"
return sanitizeSlug(title)
}
/**
* Saves or updates a draft. Creates content file and updates index atomically.
*/
suspend fun saveDraft(
slug: String,
content: String,
metadata: DraftMetadata,
) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
// Write content file atomically
val contentFile = File(draftsDir, "$safeSlug.md")
atomicWrite(contentFile, content)
// Update index
val index = loadIndexMap()
index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString())
atomicWriteIndex(index)
cachedIndex = index
// Refresh state
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
/**
* Loads a draft's content by slug.
*/
suspend fun loadContent(slug: String): String? {
val safeSlug = sanitizeSlug(slug)
val file = File(draftsDir, "$safeSlug.md")
return if (file.exists()) file.readText() else null
}
/**
* Loads a draft's metadata by slug.
*/
suspend fun loadMetadata(slug: String): DraftMetadata? {
val safeSlug = sanitizeSlug(slug)
return mutex.withLock {
loadIndexMap()[safeSlug]
}
}
/**
* Deletes a draft by slug.
*/
suspend fun deleteDraft(slug: String) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
File(draftsDir, "$safeSlug.md").delete()
val index = loadIndexMap()
index.remove(safeSlug)
atomicWriteIndex(index)
cachedIndex = index
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
/**
* Marks a draft as published.
*/
suspend fun markPublished(slug: String) {
val safeSlug = sanitizeSlug(slug)
mutex.withLock {
val index = loadIndexMap()
val existing = index[safeSlug] ?: return
index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString())
atomicWriteIndex(index)
cachedIndex = index
_drafts.value =
index.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
private fun loadIndexMap(): MutableMap<String, DraftMetadata> {
cachedIndex?.let { return it }
val loaded: MutableMap<String, DraftMetadata> =
if (!indexFile.exists()) {
mutableMapOf()
} else {
try {
mapper.readValue<MutableMap<String, DraftMetadata>>(indexFile)
} catch (e: Exception) {
System.err.println("Failed to read drafts index: ${e.message}")
mutableMapOf()
}
}
cachedIndex = loaded
return loaded
}
private suspend fun loadIndex() {
mutex.withLock {
_drafts.value =
loadIndexMap()
.entries
.map { DraftEntry(it.key, it.value) }
.sortedByDescending { it.metadata.updatedAt }
}
}
private fun atomicWrite(
file: File,
content: String,
) {
val tempFile = File(file.parentFile, "${file.name}.tmp")
try {
tempFile.writeText(content)
setFilePermissions(tempFile)
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} finally {
if (tempFile.exists()) tempFile.delete()
}
}
private fun atomicWriteIndex(index: Map<String, DraftMetadata>) {
val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(index)
atomicWrite(indexFile, json)
}
private fun setDirPermissions(dir: File) {
try {
Files.setPosixFilePermissions(
dir.toPath(),
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
),
)
} catch (_: UnsupportedOperationException) {
// Windows
}
}
private fun setFilePermissions(file: File) {
try {
Files.setPosixFilePermissions(
file.toPath(),
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
),
)
} catch (_: UnsupportedOperationException) {
// Windows
}
}
}
@@ -0,0 +1,193 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.highlights
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.util.UUID
/**
* Local highlight storage for article annotations.
* Stores highlights as JSON in ~/.amethyst/highlights/index.json.
* Uses atomic writes following the same pattern as DesktopDraftStore.
*/
class DesktopHighlightStore(
private val scope: CoroutineScope,
) {
private val mapper = jacksonObjectMapper()
private val mutex = Mutex()
private val _highlights = MutableStateFlow<Map<String, List<HighlightData>>>(emptyMap())
val highlights: StateFlow<Map<String, List<HighlightData>>> = _highlights.asStateFlow()
private val highlightsDir: File by lazy {
val dir = File(System.getProperty("user.home"), ".amethyst/highlights")
if (!dir.exists()) {
dir.mkdirs()
}
dir
}
private val indexFile: File get() = File(highlightsDir, "index.json")
init {
scope.launch(Dispatchers.IO) {
loadIndex()
}
}
suspend fun addHighlight(
articleAddressTag: String,
text: String,
note: String?,
articleTitle: String?,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
val articleHighlights = current.getOrDefault(articleAddressTag, emptyList()).toMutableList()
// Avoid duplicate highlights of the same text
if (articleHighlights.any { it.text == text }) return
articleHighlights.add(
HighlightData(
id = UUID.randomUUID().toString(),
text = text,
note = note,
articleAddressTag = articleAddressTag,
articleTitle = articleTitle,
createdAt = Instant.now().epochSecond,
),
)
current[articleAddressTag] = articleHighlights
_highlights.value = current
saveIndex(current)
}
}
suspend fun updateNote(
highlightId: String,
note: String,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val idx = list.indexOfFirst { it.id == highlightId }
if (idx >= 0) {
current[key] =
list.toMutableList().apply {
set(idx, get(idx).copy(note = note))
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
suspend fun removeHighlight(highlightId: String) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val filtered = list.filter { it.id != highlightId }
if (filtered.size != list.size) {
if (filtered.isEmpty()) {
current.remove(key)
} else {
current[key] = filtered
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
suspend fun markPublished(
highlightId: String,
eventId: String,
) {
mutex.withLock {
val current = _highlights.value.toMutableMap()
for ((key, list) in current) {
val idx = list.indexOfFirst { it.id == highlightId }
if (idx >= 0) {
current[key] =
list.toMutableList().apply {
set(idx, get(idx).copy(published = true, eventId = eventId))
}
_highlights.value = current
saveIndex(current)
return
}
}
}
}
fun getHighlightsForArticle(addressTag: String): List<HighlightData> = _highlights.value[addressTag] ?: emptyList()
fun getAllHighlights(): Map<String, List<HighlightData>> = _highlights.value
private suspend fun loadIndex() {
mutex.withLock {
if (indexFile.exists()) {
try {
val data: Map<String, List<HighlightData>> = mapper.readValue(indexFile)
_highlights.value = data
} catch (e: Exception) {
// Corrupted file — start fresh
_highlights.value = emptyMap()
}
}
}
}
private fun saveIndex(data: Map<String, List<HighlightData>>) {
try {
val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(data)
val tempFile = File(highlightsDir, "index.json.tmp")
tempFile.writeText(json)
Files.move(
tempFile.toPath(),
indexFile.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
} catch (_: Exception) {
// Best effort — don't crash on write failure
}
}
}
@@ -576,6 +576,24 @@ object FilterBuilders {
until = until,
)
/**
* 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.
*
@@ -104,7 +104,11 @@ fun createUserPostsSubscription(
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("posts-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit)),
filters =
listOf(
FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit),
FilterBuilders.longFormFromAuthors(listOf(pubKeyHex), limit = 50),
),
relays = relays,
onEvent = onEvent,
onEose = onEose,
@@ -0,0 +1,337 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownEditorState
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftMetadata
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.awt.Desktop
import java.net.URI
private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning")
@Composable
fun ArticleEditorScreen(
draftSlug: String?,
draftStore: DesktopDraftStore,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
onBack: () -> Unit,
onPublished: () -> Unit,
) {
val scope = rememberCoroutineScope()
var title by remember { mutableStateOf("") }
var summary by remember { mutableStateOf("") }
var bannerUrl by remember { mutableStateOf("") }
var tags by remember { mutableStateOf<List<String>>(emptyList()) }
var slug by remember { mutableStateOf(draftSlug ?: "") }
val editorState = remember { MarkdownEditorState() }
var publishing by remember { mutableStateOf(false) }
var saveMessage by remember { mutableStateOf<String?>(null) }
var debouncedContent by remember { mutableStateOf("") }
LaunchedEffect(editorState.text) {
delay(300)
debouncedContent = editorState.text
}
// Load existing draft
LaunchedEffect(draftSlug) {
if (draftSlug != null) {
val meta = draftStore.loadMetadata(draftSlug)
val body = draftStore.loadContent(draftSlug)
if (meta != null) {
title = meta.title
summary = meta.summary ?: ""
bannerUrl = meta.image ?: ""
tags = meta.tags
slug = draftSlug
}
if (body != null) {
editorState.loadContent(body)
}
}
}
// Auto-generate slug from title if creating a new draft
LaunchedEffect(title) {
if (draftSlug == null && title.isNotBlank()) {
slug = draftStore.slugFromTitle(title)
}
}
val onLinkClick: (String) -> Unit =
remember {
{ url: String ->
val scheme = url.substringBefore(":").lowercase()
if (scheme in ALLOWED_SCHEMES) {
try {
Desktop.getDesktop().browse(URI(url))
} catch (_: Exception) {
// Ignore unsupported or malformed URLs
}
}
}
}
fun saveDraft() {
if (slug.isBlank()) return
scope.launch {
draftStore.saveDraft(
slug = slug,
content = editorState.text,
metadata =
DraftMetadata(
title = title,
summary = summary.ifBlank { null },
image = bannerUrl.ifBlank { null },
tags = tags,
),
)
saveMessage = "Saved"
}
}
fun publishArticle() {
if (publishing) return
publishing = true
scope.launch {
try {
val event =
LongFormPublishAction.publish(
title = title,
content = editorState.text,
summary = summary.ifBlank { null },
image = bannerUrl.ifBlank { null },
tags = tags,
dTag = slug.ifBlank { draftStore.slugFromTitle(title) },
signer = account.signer,
)
// TODO: send() is fire-and-forget; markPublished runs before relay ack.
// Consider waiting for relay OK response before marking as published.
relayManager.send(event)
draftStore.markPublished(slug)
onPublished()
} catch (e: Exception) {
saveMessage = "Publish failed: ${e.message}"
} finally {
publishing = false
}
}
}
Column(
modifier =
Modifier
.fillMaxSize()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.isMetaPressed) {
when (event.key) {
Key.S -> {
saveDraft()
true
}
Key.B -> {
editorState.toggleBold()
true
}
Key.I -> {
editorState.toggleItalic()
true
}
Key.E -> {
editorState.toggleInlineCode()
true
}
Key.K -> {
editorState.insertLink()
true
}
else -> {
false
}
}
} else {
false
}
},
) {
// Top bar
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) {
Text("Back")
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
saveMessage?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
OutlinedButton(onClick = { saveDraft() }) {
Text("Save")
}
Button(
onClick = { publishArticle() },
enabled = !publishing && title.isNotBlank() && editorState.text.isNotBlank(),
) {
Text(if (publishing) "Publishing..." else "Publish")
}
}
}
// Metadata panel (collapsible)
MetadataPanel(
title = title,
onTitleChange = { title = it },
summary = summary,
onSummaryChange = { summary = it },
bannerUrl = bannerUrl,
onBannerUrlChange = { bannerUrl = it },
tags = tags,
onTagsChange = { tags = it },
slug = slug,
onSlugChange = { slug = it },
)
Spacer(Modifier.height(8.dp))
// Markdown toolbar — selection-aware toggle behavior
MarkdownToolbar(state = editorState)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Split pane: source left, preview right
Row(
modifier = Modifier.fillMaxSize().weight(1f),
) {
// Source editor
TextField(
value = editorState.value,
onValueChange = {
editorState.onValueChange(it)
saveMessage = null
},
modifier =
Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = 4.dp),
textStyle =
TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface,
),
placeholder = { Text("Write your article in markdown...") },
)
VerticalDivider()
// Preview
SelectionContainer {
Column(
modifier =
Modifier
.weight(1f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(modifier = Modifier.widthIn(max = 680.dp)) {
if (debouncedContent.isNotBlank()) {
RenderMarkdown(
content = debouncedContent,
onLinkClick = onLinkClick,
)
} else {
Text(
"Preview will appear here...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
@@ -0,0 +1,736 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.ContextMenuRepresentation
import androidx.compose.foundation.ContextMenuState
import androidx.compose.foundation.LocalContextMenuRepresentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader
import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents
import com.vitorpamplona.amethyst.commons.compose.article.extractTableOfContents
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.ReadingTimeCalculator
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.highlights.ArticleHighlightsPanel
import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private val articleDateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
/**
* Parses a NIP-23 address tag in the format "30023:pubkey:d-tag".
* Returns a Triple of (kind, pubkey, dTag) or null if invalid.
*/
private fun parseAddressTag(addressTag: String): Triple<Int, String, String>? {
val parts = addressTag.split(":", limit = 3)
if (parts.size < 3) return null
val kind = parts[0].toIntOrNull() ?: return null
return Triple(kind, parts[1], parts[2])
}
/**
* Desktop Article Reader Screen - renders long-form NIP-23 content with
* a Medium-style layout: optional ToC sidebar, centered content column,
* article header with hero image, markdown body, and reaction row.
*/
@Composable
fun ArticleReaderScreen(
addressTag: String,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
highlightStore: DesktopHighlightStore? = null,
onBack: () -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scrollState = rememberScrollState()
// Parse address tag
val parsed = remember(addressTag) { parseAddressTag(addressTag) }
val pubkey = parsed?.second
val dTag = parsed?.third
// Article state
var article by remember(addressTag) { mutableStateOf<LongTextNoteEvent?>(null) }
var eoseReceived by remember(addressTag) { mutableStateOf(false) }
// Zoom level for article text
var zoomLevel by remember { mutableStateOf(1.0f) }
// Coroutine scope for highlight operations
val scope = rememberCoroutineScope()
// Highlight state — collect outside let to ensure proper Compose subscription
val allHighlights by (highlightStore?.highlights ?: kotlinx.coroutines.flow.MutableStateFlow(emptyMap()))
.collectAsState()
val articleHighlights = allHighlights[addressTag] ?: emptyList()
var showAnnotationDialog by remember { mutableStateOf<String?>(null) }
var showHighlightsPanel by remember { mutableStateOf(false) }
val focusRequester =
remember {
androidx.compose.ui.focus
.FocusRequester()
}
// Active ToC entry tracking (placeholder — no scroll-position-based tracking yet)
var activeTocIndex by remember { mutableStateOf<Int?>(null) }
// Link click handler for markdown
val onLinkClick: (String) -> Unit =
remember(articleHighlights) {
{ url: String ->
when {
url.startsWith("highlight://") -> {
showHighlightsPanel = true
}
url.startsWith("nostr:") -> {
// TODO: Parse nostr: URI and navigate
}
else -> {
try {
java.awt.Desktop
.getDesktop()
.browse(java.net.URI(url))
} catch (_: Exception) {
}
}
}
}
}
// Load author metadata via coordinator
LaunchedEffect(article, subscriptionsCoordinator) {
val art = article ?: return@LaunchedEffect
subscriptionsCoordinator?.loadMetadataForPubkeys(listOf(art.pubKey))
}
// Subscribe to the article by address components
rememberSubscription(relayStatuses, addressTag, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || pubkey == null || dTag == null) {
return@rememberSubscription null
}
SubscriptionConfig(
subId = "article-${addressTag.hashCode()}",
filters = listOf(FilterBuilders.longFormByAddress(pubkey, dTag)),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) {
// Keep the most recent version
val current = article
if (current == null || event.createdAt > current.createdAt) {
article = event
}
}
},
onEose = { _, _ ->
eoseReceived = true
},
)
}
// Interaction state
val articleEventId = article?.id
val eventIds = listOfNotNull(articleEventId)
var zapReceipts by remember { mutableStateOf<List<ZapReceipt>>(emptyList()) }
var reactionCount by remember { mutableStateOf(0) }
var replyCount by remember { mutableStateOf(0) }
var repostCount by remember { mutableStateOf(0) }
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Subscribe to zaps
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null
createZapsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
if (zapReceipts.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
zapReceipts = zapReceipts + receipt
}
}
},
)
}
// Subscribe to reactions
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null
val reactionIds = mutableSetOf<String>()
createReactionsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent && reactionIds.add(event.id)) {
reactionCount = reactionIds.size
}
},
)
}
// Subscribe to replies
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null
val replyIds = mutableSetOf<String>()
createRepliesSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (replyIds.add(event.id)) {
replyCount = replyIds.size
}
},
)
}
// Subscribe to reposts
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null
val repostIds = mutableSetOf<String>()
createRepostsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent && repostIds.add(event.id)) {
repostCount = repostIds.size
}
},
)
}
// Subscribe to bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = "article-bookmarks-${account.pubKeyHex.take(8)}",
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(account.pubKeyHex),
kinds = listOf(BookmarkListEvent.KIND),
limit = 1,
),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
bookmarkedEventIds =
event
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Derived data from article
val title = article?.title() ?: "Untitled"
val content = article?.content ?: ""
val tocEntries = remember(content) { extractTableOfContents(content) }
val readingTime =
remember(content) {
if (content.isNotBlank()) ReadingTimeCalculator.calculate(content) else null
}
val bannerUrl = article?.image()
val publishedAt =
article?.let { art ->
val ts = art.publishedAt() ?: art.createdAt
Instant
.ofEpochSecond(ts)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.format(articleDateFormat)
}
// Author info from local cache
val authorUser = article?.let { localCache.getOrCreateUser(it.pubKey) }
val authorName = authorUser?.toBestDisplayName()
val authorPicture = authorUser?.profilePicture()
val clipboardManager = LocalClipboardManager.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.isMetaPressed) {
when (event.key) {
Key.Equals -> {
zoomLevel = (zoomLevel + 0.1f).coerceAtMost(2.0f)
true
}
Key.Minus -> {
zoomLevel = (zoomLevel - 0.1f).coerceAtLeast(0.5f)
true
}
Key.Zero -> {
zoomLevel = 1.0f
true
}
else -> {
false
}
}
} else {
false
}
},
) {
// Top bar: back + bookmark placeholder
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(24.dp),
)
}
Spacer(Modifier.width(8.dp))
Text(
"Article",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
if (zoomLevel != 1.0f) {
Spacer(Modifier.width(8.dp))
Text(
"${(zoomLevel * 100).toInt()}%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Loading / error / content states
when {
parsed == null -> {
EmptyState(
title = "Invalid article address",
description = "Could not parse address: $addressTag",
onRefresh = onBack,
refreshLabel = "Go back",
)
}
connectedRelays.isEmpty() -> {
LoadingState("Connecting to relays...")
}
article == null && !eoseReceived -> {
LoadingState("Loading article...")
}
article == null && eoseReceived -> {
EmptyState(
title = "Article not found",
description = "This article may have been deleted or is not available from connected relays",
onRefresh = onBack,
refreshLabel = "Go back",
)
}
else -> {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val showToc = maxWidth > 1100.dp && tocEntries.isNotEmpty()
Row(modifier = Modifier.fillMaxSize()) {
// ToC sidebar
if (showToc) {
TableOfContents(
entries = tocEntries,
activeEntryIndex = activeTocIndex,
onEntryClick = { entry ->
activeTocIndex = entry.index
// TODO: scroll to heading position
},
modifier = Modifier.padding(top = 16.dp, start = 8.dp),
)
VerticalDivider()
}
// Main content column
Column(
modifier =
Modifier
.weight(1f)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp),
) {
Column(
modifier =
Modifier
.widthIn(max = 680.dp)
.align(Alignment.CenterHorizontally),
) {
Spacer(Modifier.height(16.dp))
ArticleHeader(
title = title,
authorName = authorName,
authorPicture = authorPicture,
publishedAt = publishedAt,
readingTimeMinutes = readingTime,
bannerUrl = bannerUrl,
onAuthorClick =
article?.let {
{ onNavigateToProfile(it.pubKey) }
},
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = 1.dp,
)
// Collapsible highlights section
if (articleHighlights.isNotEmpty()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
showHighlightsPanel = !showHighlightsPanel
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (showHighlightsPanel) {
Icons.Default.KeyboardArrowDown
} else {
Icons.AutoMirrored.Filled.KeyboardArrowRight
},
contentDescription = "Toggle highlights",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(4.dp))
Text(
text = "${articleHighlights.size} highlight${if (articleHighlights.size != 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
if (showHighlightsPanel && highlightStore != null) {
ArticleHighlightsPanel(
highlights = articleHighlights,
highlightStore = highlightStore,
articleContent = content,
signer = account?.signer,
relayManager = relayManager,
modifier = Modifier.padding(bottom = 16.dp),
)
HorizontalDivider(
modifier = Modifier.padding(bottom = 16.dp),
thickness = 1.dp,
)
}
}
// Markdown body with right-click highlight via context menu
val defaultRepresentation = LocalContextMenuRepresentation.current
val highlightRepresentation =
remember(
defaultRepresentation,
highlightStore,
addressTag,
title,
) {
HighlightContextMenuRepresentation(
delegate = defaultRepresentation,
clipboardManager = clipboardManager,
onHighlight = { text ->
scope.launch {
highlightStore?.addHighlight(
articleAddressTag = addressTag,
text = text,
note = null,
articleTitle = title,
)
}
},
onHighlightWithNote = { text ->
showAnnotationDialog = text
},
)
}
CompositionLocalProvider(
LocalContextMenuRepresentation provides highlightRepresentation,
) {
SelectionContainer {
RenderMarkdown(
content = content,
onLinkClick = onLinkClick,
fontScale = zoomLevel,
highlightedTexts = articleHighlights.map { it.text },
)
}
}
Spacer(Modifier.height(32.dp))
// Topics / hashtags
val topics = article?.topics() ?: emptyList()
if (topics.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 16.dp),
) {
topics.forEach { topic ->
Text(
text = "#$topic",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}
HorizontalDivider(thickness = 1.dp)
// Reaction actions
val art = article
if (art != null && account != null) {
Spacer(Modifier.height(16.dp))
NoteActionsRow(
event = art,
relayManager = relayManager,
localCache = localCache,
account = account,
onReplyClick = { onNavigateToThread(art.id) },
onZapFeedback = onZapFeedback,
zapCount = zapReceipts.size,
zapAmountSats = zapReceipts.sumOf { it.amountSats },
zapReceipts = zapReceipts,
reactionCount = reactionCount,
replyCount = replyCount,
repostCount = repostCount,
nwcConnection = nwcConnection,
isBookmarked = articleEventId in bookmarkedEventIds,
bookmarkList = bookmarkList,
onBookmarkChanged = { newList ->
bookmarkList = newList
bookmarkedEventIds =
newList
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
},
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(48.dp))
}
}
}
}
}
}
}
showAnnotationDialog?.let { selectedText ->
HighlightAnnotationDialog(
selectedText = selectedText,
onConfirm = { note ->
scope.launch {
highlightStore?.addHighlight(
articleAddressTag = addressTag,
text = selectedText,
note = note,
articleTitle = title,
)
}
showAnnotationDialog = null
},
onDismiss = { showAnnotationDialog = null },
)
}
}
/**
* Custom context menu representation that adds "Highlight" and "Highlight with Note"
* items to the right-click menu inside a SelectionContainer.
*
* How it works: The SelectionContainer provides a "Copy" item that has access to the
* selected text. Our items piggyback on Copy's onClick — calling it first to put the
* selected text on the clipboard, then reading the clipboard to get the text.
*/
private class HighlightContextMenuRepresentation(
private val delegate: ContextMenuRepresentation,
private val clipboardManager: androidx.compose.ui.platform.ClipboardManager,
private val onHighlight: (String) -> Unit,
private val onHighlightWithNote: (String) -> Unit,
) : ContextMenuRepresentation {
@Composable
override fun Representation(
state: ContextMenuState,
items: () -> List<ContextMenuItem>,
) {
val extendedItems = {
val original = items()
val copyItem = original.find { it.label == "Copy" }
if (copyItem != null) {
original +
listOf(
ContextMenuItem("Highlight") {
copyItem.onClick()
val text = clipboardManager.getText()?.text
if (!text.isNullOrBlank()) {
onHighlight(text)
}
},
ContextMenuItem("Highlight with Note") {
copyItem.onClick()
val text = clipboardManager.getText()?.text
if (!text.isNullOrBlank()) {
onHighlightWithNote(text)
}
},
)
} else {
original
}
}
delegate.Representation(state, extendedItems)
}
}
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry
import kotlinx.coroutines.launch
@Composable
fun DraftsScreen(
draftStore: DesktopDraftStore,
onOpenEditor: (slug: String?) -> Unit,
) {
val drafts by draftStore.drafts.collectAsState()
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Drafts",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Button(onClick = { onOpenEditor(null) }) {
Icon(Icons.Default.Add, contentDescription = null)
Text("New Draft", modifier = Modifier.padding(start = 4.dp))
}
}
Spacer(Modifier.height(16.dp))
if (drafts.isEmpty()) {
Text(
"No drafts yet. Click \"New Draft\" to start writing.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(drafts, key = { it.slug }) { entry ->
DraftCard(
entry = entry,
onClick = { onOpenEditor(entry.slug) },
onDelete = { deleteTarget = entry },
)
}
}
}
}
// Delete confirmation dialog
deleteTarget?.let { entry ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text("Delete Draft") },
text = {
Text(
"Delete \"${entry.metadata.title.ifBlank { entry.slug }}\"? This cannot be undone.",
)
},
confirmButton = {
TextButton(onClick = {
scope.launch { draftStore.deleteDraft(entry.slug) }
deleteTarget = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text("Cancel")
}
},
)
}
}
@Composable
private fun DraftCard(
entry: DraftEntry,
onClick: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.metadata.title.ifBlank { "Untitled" },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = entry.metadata.updatedAt.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (entry.metadata.published) {
Text(
text = "Published",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete draft",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -68,13 +69,20 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscr
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import java.text.SimpleDateFormat
import java.util.Date
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000))
private fun formatDate(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.format(dateFormat)
/**
* Card displaying long-form content (NIP-23) with title, summary, and image.
@@ -171,8 +179,11 @@ fun ReadsScreen(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null,
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val scope = rememberCoroutineScope()
@@ -357,12 +368,27 @@ fun ReadsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(events, key = { it.id }) { event ->
LongFormCard(
event = event,
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onClick = { onNavigateToArticle(event.id) },
)
Column {
LongFormCard(
event = event,
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onClick = { onNavigateToArticle(event.addressTag()) },
)
if (account != null) {
NoteActionsRow(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = { onNavigateToThread(event.id) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
}
HorizontalDivider(thickness = 1.dp)
}
}
}
@@ -96,7 +96,9 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -118,6 +120,7 @@ fun UserProfileScreen(
onBack: () -> Unit,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
@@ -157,6 +160,8 @@ fun UserProfileScreen(
var selectedTab by remember { mutableStateOf(0) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
val pictureEvents = remember { mutableStateListOf<PictureEvent>() }
val articleEvents = remember { mutableStateListOf<LongTextNoteEvent>() }
val highlightEvents = remember { mutableStateListOf<HighlightEvent>() }
// Follow state
val followState =
@@ -344,6 +349,60 @@ fun UserProfileScreen(
}
}
// Subscribe to long-form articles (kind 30023) for reads tab
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
articleEvents.clear()
SubscriptionConfig(
subId = generateSubId("articles-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(LongTextNoteEvent.KIND),
limit = 50,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent && articleEvents.none { it.id == event.id }) {
articleEvents.add(event)
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to highlight events (kind 9802) for highlights tab
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
highlightEvents.clear()
SubscriptionConfig(
subId = generateSubId("hl-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(HighlightEvent.KIND),
limit = 100,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is HighlightEvent && highlightEvents.none { it.id == event.id }) {
highlightEvents.add(event)
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Scroll state for detecting scroll direction
val listState = rememberLazyListState()
var showFloatingHeader by remember { mutableStateOf(false) }
@@ -629,8 +688,20 @@ fun UserProfileScreen(
Text("Notes", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
Text(
"Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) {
Text("Gallery", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) {
Text(
"Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
}
}
@@ -726,6 +797,38 @@ fun UserProfileScreen(
}
1 -> {
if (articleEvents.isEmpty()) {
item(key = "no-articles") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No long-form articles",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
items(
articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt },
key = { "art-${it.id}" },
) { article ->
LongFormCard(
event = article,
localCache = localCache,
onAuthorClick = { onNavigateToProfile(article.pubKey) },
onClick = {
val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}"
onNavigateToArticle(addressTag)
},
)
}
}
}
2 -> {
item(key = "gallery") {
GalleryTab(
pictureEvents = pictureEvents,
@@ -734,6 +837,33 @@ fun UserProfileScreen(
)
}
}
3 -> {
if (highlightEvents.isEmpty()) {
item(key = "no-highlights") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No published highlights",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
items(
highlightEvents.sortedByDescending { it.createdAt },
key = { "hl-${it.id}" },
) { highlight ->
PublishedHighlightCard(
highlight = highlight,
localCache = localCache,
)
}
}
}
}
}
}
@@ -940,3 +1070,64 @@ private suspend fun updateProfileDisplayName(
onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error"))
}
}
@Composable
private fun PublishedHighlightCard(
highlight: HighlightEvent,
localCache: DesktopLocalCache,
) {
val articleAddress = highlight.inPostAddress()
val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source"
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Quoted highlight text
Text(
text = "\u201C${highlight.quote()}\u201D",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
)
// Note/comment
val comment = highlight.comment()
if (!comment.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
Text(
text = comment,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Context (surrounding paragraph)
val context = highlight.context()
if (!context.isNullOrBlank() && context != highlight.quote()) {
Spacer(Modifier.height(8.dp))
Text(
text = context.take(200) + if (context.length > 200) "\u2026" else "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
Spacer(Modifier.height(8.dp))
// Source article reference
if (articleAddress != null) {
Text(
text = "from ${articleAddress.dTag.ifBlank { "article" }}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -53,6 +53,8 @@ private val COLUMN_OPTIONS =
DeckColumnType.Messages,
DeckColumnType.Search,
DeckColumnType.Reads,
DeckColumnType.Drafts,
DeckColumnType.MyHighlights,
DeckColumnType.Bookmarks,
DeckColumnType.GlobalFeed,
DeckColumnType.MyProfile,
@@ -132,6 +132,10 @@ fun DeckColumnType.icon(): ImageVector =
DeckColumnType.MyProfile -> Icons.Default.Person
DeckColumnType.Chess -> Icons.Default.Extension
DeckColumnType.Settings -> Icons.Default.Settings
is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article
DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article
DeckColumnType.MyHighlights -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Profile -> Icons.Default.Person
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Hashtag -> Icons.Default.Tag
@@ -44,9 +44,14 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen
@@ -92,6 +97,8 @@ fun DeckColumnContainer(
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: DesktopDraftStore,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -132,6 +139,8 @@ fun DeckColumnContainer(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
compactMode = true,
onShowComposeDialog = onShowComposeDialog,
@@ -139,6 +148,8 @@ fun DeckColumnContainer(
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
)
if (currentOverlay != null) {
Surface(
@@ -152,11 +163,14 @@ fun DeckColumnContainer(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() },
)
}
@@ -175,6 +189,8 @@ internal fun RootContent(
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
appScope: CoroutineScope,
compactMode: Boolean = false,
onShowComposeDialog: () -> Unit,
@@ -182,6 +198,8 @@ internal fun RootContent(
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {},
) {
val scope = rememberCoroutineScope()
@@ -231,8 +249,11 @@ internal fun RootContent(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToThread,
onNavigateToArticle = onNavigateToArticle,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
@@ -275,6 +296,7 @@ internal fun RootContent(
onBack = {},
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback,
)
}
@@ -323,6 +345,44 @@ internal fun RootContent(
)
}
is DeckColumnType.Article -> {
ArticleReaderScreen(
addressTag = columnType.addressTag,
relayManager = relayManager,
localCache = localCache,
account = account,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = {},
onNavigateToProfile = onNavigateToProfile,
)
}
is DeckColumnType.Editor -> {
ArticleEditorScreen(
draftSlug = columnType.draftSlug,
draftStore = draftStore ?: remember { DesktopDraftStore(scope) },
account = account,
relayManager = relayManager,
onBack = {},
onPublished = {},
)
}
DeckColumnType.Drafts -> {
DraftsScreen(
draftStore = draftStore ?: remember { DesktopDraftStore(scope) },
onOpenEditor = { slug -> onNavigateToEditor(slug) },
)
}
DeckColumnType.MyHighlights -> {
com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen(
highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) },
onNavigateToArticle = onNavigateToArticle,
)
}
is DeckColumnType.Hashtag -> {
SearchScreen(
localCache = localCache,
@@ -344,11 +404,14 @@ internal fun OverlayContent(
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onBack: () -> Unit,
) {
when (screen) {
@@ -363,6 +426,7 @@ internal fun OverlayContent(
onBack = onBack,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback,
)
}
@@ -383,6 +447,31 @@ internal fun OverlayContent(
)
}
is DesktopScreen.Article -> {
ArticleReaderScreen(
addressTag = screen.addressTag,
relayManager = relayManager,
localCache = localCache,
account = account,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = onBack,
onNavigateToProfile = onNavigateToProfile,
)
}
is DesktopScreen.Editor -> {
val overlayScope = androidx.compose.runtime.rememberCoroutineScope()
ArticleEditorScreen(
draftSlug = screen.draftSlug,
draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) },
account = account,
relayManager = relayManager,
onBack = onBack,
onPublished = onBack,
)
}
else -> {
androidx.compose.material3.Text(
"Unsupported screen type",
@@ -51,6 +51,18 @@ sealed class DeckColumnType {
val noteId: String,
) : DeckColumnType()
data class Article(
val addressTag: String,
) : DeckColumnType()
data class Editor(
val draftSlug: String? = null,
) : DeckColumnType()
object Drafts : DeckColumnType()
object MyHighlights : DeckColumnType()
data class Hashtag(
val tag: String,
) : DeckColumnType()
@@ -67,6 +79,10 @@ sealed class DeckColumnType {
MyProfile -> "Profile"
Chess -> "Chess"
Settings -> "Settings"
is Article -> "Article"
is Editor -> "New Article"
Drafts -> "Drafts"
MyHighlights -> "Highlights"
is Profile -> "Profile"
is Thread -> "Thread"
is Hashtag -> "#$tag"
@@ -84,6 +100,10 @@ sealed class DeckColumnType {
MyProfile -> "my_profile"
Chess -> "chess"
Settings -> "settings"
is Article -> "article"
is Editor -> "editor"
Drafts -> "drafts"
MyHighlights -> "highlights"
is Profile -> "profile"
is Thread -> "thread"
is Hashtag -> "hashtag"
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
@@ -61,6 +62,8 @@ fun DeckLayout(
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -113,6 +116,8 @@ fun DeckLayout(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
@@ -79,6 +80,8 @@ private val navItems =
listOf(
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"),
NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"),
NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"),
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
@@ -97,6 +100,8 @@ fun SinglePaneLayout(
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -171,12 +176,16 @@ fun SinglePaneLayout(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
)
if (currentOverlay != null) {
Surface(
@@ -190,11 +199,14 @@ fun SinglePaneLayout(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() },
)
}
@@ -0,0 +1,208 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.launch
@Composable
fun ArticleHighlightsPanel(
highlights: List<HighlightData>,
highlightStore: DesktopHighlightStore,
articleContent: String,
signer: NostrSigner?,
relayManager: DesktopRelayConnectionManager?,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
var editTarget by remember { mutableStateOf<HighlightData?>(null) }
Column(modifier = modifier.fillMaxWidth().padding(top = 8.dp)) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
highlights.forEach { highlight ->
HighlightPanelCard(
highlight = highlight,
onDelete = {
scope.launch { highlightStore.removeHighlight(highlight.id) }
},
onEditNote = { editTarget = highlight },
onPublish =
if (!highlight.published && signer != null && relayManager != null) {
{
scope.launch {
val context =
HighlightPublishAction.extractContext(
articleContent,
highlight.text,
)
val event =
HighlightPublishAction.publish(
highlightText = highlight.text,
articleAddressTag = highlight.articleAddressTag,
note = highlight.note,
context = context,
signer = signer,
)
relayManager.broadcastToAll(event)
highlightStore.markPublished(highlight.id, event.id)
}
}
} else {
null
},
)
}
}
}
editTarget?.let { highlight ->
HighlightAnnotationDialog(
selectedText = highlight.text,
onConfirm = { note ->
scope.launch { highlightStore.updateNote(highlight.id, note) }
editTarget = null
},
onDismiss = { editTarget = null },
)
}
}
@Composable
private fun HighlightPanelCard(
highlight: HighlightData,
onDelete: () -> Unit,
onEditNote: () -> Unit,
onPublish: (() -> Unit)?,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "\u201C${highlight.text}\u201D",
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
)
val noteText = highlight.note
if (!noteText.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = noteText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Published status
Icon(
imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock,
contentDescription = if (highlight.published) "Published" else "Private",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (highlight.published) "Published" else "Private",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
Spacer(Modifier.weight(1f))
// Publish button
if (onPublish != null) {
IconButton(onClick = onPublish, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Public,
contentDescription = "Publish to relays",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
IconButton(onClick = onEditNote, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit note",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun HighlightAnnotationDialog(
selectedText: String,
onConfirm: (note: String) -> Unit,
onDismiss: () -> Unit,
) {
var note by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Highlight Note") },
text = {
Column {
Text(
text = "\"$selectedText\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = note,
onValueChange = { note = it },
label = { Text("Note (optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 5,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(note) }) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag
import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag
import com.vitorpamplona.quartz.utils.TimeUtils
object HighlightPublishAction {
suspend fun publish(
highlightText: String,
articleAddressTag: String,
note: String?,
context: String?,
signer: NostrSigner,
): HighlightEvent {
val tags = mutableListOf<Array<String>>()
tags.add(AltTag.assemble(HighlightEvent.ALT))
tags.add(ATag.assemble(articleAddressTag, null))
// Tag the article author
val parts = articleAddressTag.split(":", limit = 3)
val pubkey = parts.getOrNull(1)
if (!pubkey.isNullOrBlank()) {
tags.add(PTag.assemble(pubkey, null))
}
if (!note.isNullOrBlank()) {
tags.add(CommentTag.assemble(note))
}
if (!context.isNullOrBlank()) {
tags.add(ContextTag.assemble(context))
}
return signer.sign(
createdAt = TimeUtils.now(),
kind = HighlightEvent.KIND,
tags = tags.toTypedArray(),
content = highlightText,
)
}
fun extractContext(
content: String,
highlightText: String,
): String? {
val paragraphs = content.split("\n\n")
return paragraphs.find { it.contains(highlightText) }
}
}
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun MyHighlightsScreen(
highlightStore: DesktopHighlightStore,
onNavigateToArticle: (addressTag: String) -> Unit,
) {
val allHighlights by highlightStore.highlights.collectAsState()
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
Text(
"Highlights",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
if (allHighlights.isEmpty()) {
EmptyState(
title = "No highlights yet",
description = "Select text in an article and choose \"Highlight\" to save passages.",
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
allHighlights.forEach { (addressTag, highlights) ->
val articleTitle = highlights.firstOrNull()?.articleTitle ?: addressTag
stickyHeader(key = addressTag) {
ArticleGroupHeader(
title = articleTitle,
onClick = { onNavigateToArticle(addressTag) },
)
}
items(highlights, key = { it.id }) { highlight ->
HighlightCard(
highlight = highlight,
onDelete = { deleteTarget = highlight },
)
}
}
}
}
}
deleteTarget?.let { highlight ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text("Delete Highlight") },
text = {
Text(
"Delete this highlight? This cannot be undone.",
)
},
confirmButton = {
TextButton(onClick = {
scope.launch { highlightStore.removeHighlight(highlight.id) }
deleteTarget = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text("Cancel")
}
},
)
}
}
@Composable
private fun ArticleGroupHeader(
title: String,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun HighlightCard(
highlight: HighlightData,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "\u201C${highlight.text}\u201D",
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
)
val noteText = highlight.note
if (!noteText.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = noteText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatTimestamp(highlight.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock,
contentDescription = if (highlight.published) "Published" else "Private",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete highlight",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
private fun formatTimestamp(epochSeconds: Long): String =
Instant
.ofEpochSecond(epochSeconds)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
@@ -0,0 +1,410 @@
---
title: "feat: Article Highlights & Note-Taking"
type: feat
status: active
date: 2026-03-24
deepened: 2026-03-24
origin: docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md
---
# feat: Article Highlights & Note-Taking
## Enhancement Summary
**Deepened on:** 2026-03-24
**Research agents used:** text-selection, richtext-rendering, floating-popup, nip84-highlights
### Key Improvements
1. **Text selection strategy resolved** — Use `LocalTextContextMenu` override (only official API exposing selected text), not clipboard polling
2. **Inline rendering strategy resolved** — Pre-process markdown with special link URI (`highlight://`) as v1; fork-level `==highlight==` syntax as v2
3. **Floating toolbar pattern confirmed** — `Popup` + custom `PopupPositionProvider`, matches existing `ChatPane.kt` pattern
4. **NIP-84 gap found** — `HighlightEvent.create()` only takes `msg`+`signer`, needs tag assembly wrapper for full highlight creation
### Resolved Questions
- **`<mark>` support?** No — richtext library ignores `HtmlInline` nodes. Use pre-processing or fork changes.
- **Clipboard polling reliability?** Moot — use `LocalTextContextMenu` instead (right-click UX, official API)
- **NIP-09 deletion?** Yes — send kind 5 event with `["e", highlightEventId]` + `["k", "9802"]`
## Overview
Text selection-based highlight and annotation system for the Desktop article reader. Users select text in NIP-23 articles, a context menu option or floating toolbar appears, and they create highlights (with optional notes). Highlights render inline as colored markers. Supports private (local) and public (NIP-84) modes. Includes a "My Highlights" aggregation screen.
## Problem Statement
Desktop article reader has no way to mark up, annotate, or take notes on long-form content. Users reading NIP-23 articles can't highlight passages, add personal notes, or publish highlights to their Nostr social graph. This limits the reading experience compared to tools like Kindle, Medium, or Hypothesis.
## Proposed Solution
Three-phase implementation:
1. **Storage + data model** — DesktopHighlightStore (Preferences-based) + highlight data classes
2. **Selection UX + inline rendering** — Text selection interception via context menu, floating toolbar, yellow highlight markers in markdown
3. **My Highlights screen + NIP-84 publishing** — Aggregation view, public/private toggle, relay broadcast
## Technical Approach
### Architecture
```
desktopApp/
├── service/highlights/
│ └── DesktopHighlightStore.kt # Preferences-based storage (like DraftStore)
├── ui/
│ ├── ArticleReaderScreen.kt # Modified: selection + inline highlights
│ ├── highlights/
│ │ ├── FloatingHighlightToolbar.kt # Popup on text selection
│ │ ├── HighlightAnnotationDialog.kt # Note entry dialog
│ │ ├── HighlightPublishAction.kt # NIP-84 tag assembly + publish
│ │ └── MyHighlightsScreen.kt # Aggregation screen
│ └── deck/
│ ├── DeckColumnType.kt # Add MyHighlights
│ ├── DeckColumnContainer.kt # Route MyHighlights
│ └── SinglePaneLayout.kt # Add nav item
commons/
├── compose/markdown/
│ └── RenderMarkdown.kt # Modified: accept highlight ranges, render yellow bg
├── model/highlights/
│ └── HighlightData.kt # Shared data class
```
### Implementation Phases
#### Phase 1: Storage & Data Model
**Goal:** DesktopHighlightStore + highlight data classes, no UI yet.
**Files:**
- `desktopApp/service/highlights/DesktopHighlightStore.kt` — follows DesktopDraftStore pattern (Jackson + Preferences)
- `commons/model/highlights/HighlightData.kt` — shared data class
**Data model:**
```kotlin
data class HighlightData(
val id: String, // UUID
val text: String, // selected/highlighted text
val note: String?, // optional annotation
val articleAddressTag: String, // "30023:pubkey:d-tag"
val articleTitle: String?, // cached for My Highlights display
val createdAt: Long, // epoch seconds
val published: Boolean, // false = private, true = NIP-84 published
val eventId: String?, // NIP-84 event ID if published
)
```
**DesktopHighlightStore API:**
```kotlin
class DesktopHighlightStore(scope: CoroutineScope) {
private val mapper = jacksonObjectMapper()
val highlights: StateFlow<Map<String, List<HighlightData>>> // keyed by articleAddressTag
suspend fun addHighlight(articleAddressTag: String, text: String, note: String?, articleTitle: String?)
suspend fun updateNote(highlightId: String, note: String)
suspend fun removeHighlight(highlightId: String)
suspend fun markPublished(highlightId: String, eventId: String)
fun getHighlightsForArticle(addressTag: String): List<HighlightData>
fun getAllHighlights(): Map<String, List<HighlightData>>
}
```
**Tests:** Unit tests for store CRUD, serialization round-trip.
**Success criteria:**
- [ ] HighlightData serializes/deserializes via Jackson
- [ ] Store persists across app restarts via Preferences
- [ ] CRUD operations work correctly
- [ ] StateFlow emits on changes
### Research Insights — Phase 1
**Storage pattern:** Follow `DesktopDraftStore.kt` exactly:
- `jacksonObjectMapper()` for serialization (line 62)
- Atomic writes with temp files + `Files.move()` (lines 237-254)
- POSIX file permissions for security (lines 261-288)
- Preferences key: `"highlights:${articleAddressTag}"` with JSON array value
**Edge case — Preferences size limit:** `java.util.prefs.Preferences` has a per-value limit of 8192 bytes on some platforms. For articles with many highlights, the JSON array could exceed this. Mitigation: if value exceeds 6KB, spill to file-based storage (same pattern as DraftStore's file storage).
---
#### Phase 2: Selection UX + Inline Rendering
**Goal:** Select text in article → create highlight → see yellow marker.
##### Text Selection Strategy (REVISED)
**Primary: `LocalTextContextMenu` override** — the only official Compose Desktop API that exposes `selectedText`:
```kotlin
@Composable
fun HighlightableContent(
onHighlight: (String) -> Unit,
onAnnotate: (String) -> Unit,
content: @Composable () -> Unit,
) {
val defaultMenu = LocalTextContextMenu.current
CompositionLocalProvider(
LocalTextContextMenu provides object : TextContextMenu {
@Composable
override fun Area(
textManager: TextContextMenu.TextManager,
state: ContextMenuState,
content: @Composable () -> Unit,
) {
ContextMenuDataProvider({
val selected = textManager.selectedText
if (selected.text.isNotEmpty()) {
listOf(
ContextMenuItem("Highlight") { onHighlight(selected.text) },
ContextMenuItem("Highlight with Note") { onAnnotate(selected.text) },
)
} else {
emptyList()
}
}) {
defaultMenu.Area(textManager, state, content = content)
}
}
},
content = content,
)
}
```
**UX:** Select text → right-click → "Highlight" / "Highlight with Note" in context menu. Natural desktop UX. No clipboard polling needed.
**Secondary: Keyboard shortcut (Cmd+H)** — reads clipboard after user copies:
```kotlin
Modifier.onPreviewKeyEvent { event ->
if (event.isMetaPressed && event.key == Key.H && event.type == KeyEventType.KeyDown) {
val clipText = clipboard.getText()?.text
if (!clipText.isNullOrBlank()) onHighlight(clipText)
true
} else false
}
```
##### Inline Rendering Strategy (REVISED)
**Research finding:** richtext library ignores `HtmlInline` (`<mark>`) and has no `Highlight` format. Three options ranked:
| Approach | Effort | Quality | Recommended |
|----------|--------|---------|-------------|
| Pre-process: wrap in special link `[text](highlight://)` | Low | Hacky but works | v1 |
| Fork: add `==text==` DelimiterProcessor + `Format.Highlight` | Medium | Clean, semantic | v2 |
| Overlay: position colored Box composables | High | Fragile | No |
**v1 approach (ship fast):** Pre-process markdown before parsing:
```kotlin
fun applyHighlights(content: String, highlights: List<HighlightData>): String {
var result = content
// Sort by length descending to avoid nested replacement issues
highlights.sortedByDescending { it.text.length }.forEach { h ->
val idx = result.indexOf(h.text)
if (idx >= 0) {
// Wrap in bold + italic to visually distinguish
result = result.replaceFirst(h.text, "***${h.text}***")
}
}
return result
}
```
**v2 approach (proper):** Add highlight support to Vitor's richtext fork:
1. Add `AstHighlight` inline node type
2. Add `HighlightDelimiterProcessor` for `==text==` syntax
3. Add `Format.Highlight` with `SpanStyle(background = Color(0xFFFFEB3B))`
4. Pre-process: wrap highlights with `==text==` before parsing
##### Floating Toolbar (for future enhancement beyond context menu)
**Pattern:** `Popup` + custom `PopupPositionProvider` (matches existing `ChatPane.kt:584`):
```kotlin
class MousePositionProvider(private val offset: IntOffset) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect, windowSize: IntSize,
layoutDirection: LayoutDirection, popupContentSize: IntSize,
): IntOffset {
val x = (offset.x - popupContentSize.width / 2)
.coerceIn(0, windowSize.width - popupContentSize.width)
val y = (offset.y - popupContentSize.height - 8)
.coerceIn(0, windowSize.height - popupContentSize.height)
return IntOffset(x, y)
}
}
```
Track mouse via `Modifier.pointerInput` + `awaitPointerEventScope` (pattern from `VideoControls.kt:89`). Dismiss on scroll via `LaunchedEffect(scrollState.isScrollInProgress)`.
**Files:**
- `desktopApp/ui/highlights/FloatingHighlightToolbar.kt` — Popup with Highlight/Annotate buttons
- `desktopApp/ui/highlights/HighlightAnnotationDialog.kt` — AlertDialog for note text
- `desktopApp/ui/ArticleReaderScreen.kt` — Add context menu override, highlight state, inline rendering
- `commons/compose/markdown/RenderMarkdown.kt` — Add `highlights: List<HighlightData>` param
**UX flow (revised):**
1. User reads article, selects text by click-dragging
2. Right-click → context menu shows "Highlight" / "Highlight with Note" (via `LocalTextContextMenu`)
3. "Highlight" → saves immediately via DesktopHighlightStore, re-renders with bold/italic marker (v1) or yellow bg (v2)
4. "Highlight with Note" → opens HighlightAnnotationDialog → saves with note
5. Click existing highlight → popup with note / delete / publish toggle
**Success criteria:**
- [ ] Right-click context menu shows "Highlight" option when text is selected
- [ ] Highlight saves and renders as visual marker in article
- [ ] Annotation dialog captures and saves notes
- [ ] Highlights persist across navigation (leave article and return)
- [ ] Best-effort match: highlights survive minor article edits (see brainstorm)
- [ ] Cmd+H keyboard shortcut works as alternative (select, copy, Cmd+H)
---
#### Phase 3: My Highlights Screen + NIP-84 Publishing
**Goal:** Aggregation screen showing all highlights grouped by article. Public/private toggle per highlight.
**Files:**
- `desktopApp/ui/highlights/MyHighlightsScreen.kt`
- `desktopApp/ui/highlights/HighlightPublishAction.kt` — tag assembly + publish
- `desktopApp/ui/deck/DeckColumnType.kt` — Add `object MyHighlights`
- `desktopApp/ui/deck/DeckColumnContainer.kt` — Route MyHighlights
- `desktopApp/ui/deck/SinglePaneLayout.kt` — Add nav item
- `desktopApp/ui/deck/AddColumnDialog.kt` — Add to column options
- `desktopApp/ui/deck/ColumnHeader.kt` — Add icon
##### NIP-84 Publishing (REVISED)
**Gap found:** `HighlightEvent.create()` only takes `msg` + `signer` — doesn't accept source/context/comment tags. Need a wrapper:
```kotlin
object HighlightPublishAction {
suspend fun publish(
highlightText: String,
articleEvent: LongTextNoteEvent,
note: String?,
signer: NostrSigner,
): HighlightEvent {
val tags = TagArrayBuilder().apply {
// Article reference (addressable event)
add(ATag.assemble(30023, articleEvent.pubKey, articleEvent.dTag()))
// Article author attribution
add(PTag.assemble(articleEvent.pubKey, role = "author"))
// Optional annotation
note?.let { add(CommentTag.assemble(it)) }
// Surrounding paragraph as context
extractContext(articleEvent.content, highlightText)?.let {
add(ContextTag.assemble(it))
}
// Alt text for non-NIP-84 clients
add(AltTag.assemble("Highlight: $highlightText"))
}.build()
return HighlightEvent.create(
msg = highlightText,
tags = tags,
signer = signer,
)
}
/** Extract the paragraph containing the highlighted text */
fun extractContext(content: String, highlightText: String): String? {
val paragraphs = content.split("\n\n")
return paragraphs.find { it.contains(highlightText) }
}
}
```
##### NIP-09 Deletion for Published Highlights
```kotlin
suspend fun deleteHighlight(eventId: String, signer: NostrSigner): DeletionEvent {
return DeletionEvent.create(
deleteEvents = listOf(eventId),
deleteKinds = listOf(9802),
reason = "User deleted highlight",
signer = signer,
)
}
```
**My Highlights screen layout:**
```
┌─────────────────────────────────┐
│ My Highlights │
├─────────────────────────────────┤
│ ▼ "Article Title One" │
│ "highlighted text..." 🔒 │
│ Note: my annotation ╳ │
│ Mar 24, 2026 │
│ │
│ "another highlight..." 🌐 │
│ Mar 24, 2026 │
│ │
│ ▼ "Article Title Two" │
│ "highlighted passage..." 🔒 │
│ Note: thoughts here ╳ │
└─────────────────────────────────┘
🔒 = private 🌐 = published
Click article title → navigates to article
Click 🔒 → publish to Nostr (NIP-84)
Click ╳ → delete (+ NIP-09 if published)
```
**Success criteria:**
- [ ] My Highlights accessible from sidebar nav
- [ ] Highlights grouped by article with collapsible sections
- [ ] Click article title navigates to article (onNavigateToArticle)
- [ ] Public/private toggle publishes NIP-84 event to relays
- [ ] Delete removes from local store + sends NIP-09 deletion for published
- [ ] Empty state when no highlights exist
## Acceptance Criteria
- [ ] Right-click selected text in article → "Highlight" in context menu
- [ ] Click "Highlight" → text marked visually, saved locally
- [ ] Click "Highlight with Note" → note dialog, then saved with annotation
- [ ] Cmd+H keyboard shortcut creates highlight from clipboard
- [ ] Highlights persist across app restarts
- [ ] Highlights survive article content updates (best-effort string match)
- [ ] "My Highlights" screen shows all highlights grouped by article
- [ ] Can toggle highlight public/private (publishes NIP-84 event)
- [ ] Can delete highlights (+ NIP-09 for published)
- [ ] Zoom (Cmd+/Cmd-) doesn't break highlight rendering
- [ ] Works in both single-pane and deck layout modes
## Dependencies & Risks
| Risk | Impact | Mitigation | Status |
|------|--------|------------|--------|
| `SelectionContainer` doesn't expose selection state | High | **Resolved:** Use `LocalTextContextMenu` override — official API, accesses `selectedText` directly | Mitigated |
| richtext library doesn't support `<mark>` or highlight formatting | Medium | **Resolved:** v1 uses bold/italic pre-processing; v2 adds `Format.Highlight` to fork | Mitigated |
| `LocalTextContextMenu.TextManager.selectedText` doesn't work across multiple `Text()` children | Medium | Test during Phase 2; fallback to clipboard-based Cmd+H shortcut | Open |
| `HighlightEvent.create()` doesn't accept custom tags | Low | **Resolved:** Create `HighlightPublishAction` wrapper with `TagArrayBuilder` | Mitigated |
| Preferences 8KB per-value limit | Low | Monitor; spill to file storage if needed | Open |
## Sources & References
### Origin
- **Brainstorm:** [docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md](docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md)
- Key decisions: private+public scope, select+popup UX, Preferences storage, own highlights only, best-effort persistence, My Highlights screen
### Internal References
- HighlightEvent protocol: `quartz/nip84Highlights/HighlightEvent.kt:137-141`
- DraftStore pattern: `desktopApp/service/drafts/DesktopDraftStore.kt`
- Event publishing: `desktopApp/ui/ArticleEditorScreen.kt:161-187`
- SelectionContainer: `desktopApp/ui/ArticleEditorScreen.kt:304`
- Context menu override: `LocalTextContextMenu` (Compose Desktop API)
- Popup pattern: `desktopApp/ui/chats/ChatPane.kt:584`
- Mouse tracking: `desktopApp/ui/media/VideoControls.kt:89`
- Android highlight rendering: `amethyst/ui/note/types/Highlight.kt:179-198`
### External References
- [Compose Desktop context menus](https://kotlinlang.org/docs/multiplatform/compose-desktop-context-menus.html)
- [NIP-84 spec (Highlights)](https://github.com/nostr-protocol/nips/blob/master/84.md)
- [NIP-09 spec (Event Deletion)](https://github.com/nostr-protocol/nips/blob/master/09.md)
- [commonmark-java DelimiterProcessor](https://github.com/commonmark/commonmark-java)
@@ -0,0 +1,156 @@
---
title: "Long-Form Reads — Manual Testing Sheet"
date: 2026-03-24
branch: features/long-form-content
---
# Long-Form Reads — Manual Testing Sheet
**Run:** `cd AmethystMultiplatform-long-form && ./gradlew :desktopApp:run`
## Pre-Test Setup
- [x] App launches without crash
- [x] Login with existing account (needs relay connections)
- [x] Navigate to Reads tab in sidebar
---
## 1. ReadsScreen Feed
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 1.1 | Feed loads articles | Click Reads in sidebar | Long-form article cards appear | |
| 1.2 | Reading time shown | Check article cards | "X min read" displayed on each card | |
| 1.3 | Global/Following toggle | Click Global/Following chips | Feed switches between modes | |
| 1.4 | Article click navigates | Click any article card | ArticleReaderScreen opens (not ThreadScreen) | |
---
## 2. ArticleReaderScreen
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 2.1 | Article loads | Click article from Reads feed | Content renders with markdown formatting | |
| 2.2 | Title + metadata | Check header area | Title, author name, reading time, date displayed | |
| 2.3 | Banner image | Open article with banner | Hero image renders at top (if article has `image` tag) | |
| 2.4 | Markdown headings | Scroll through article | H1-H3 render with different sizes | |
| 2.5 | Bold/italic/code | Check formatting | **Bold**, *italic*, `inline code` render correctly | |
| 2.6 | Code blocks | Find code block | Monospace font, distinct background | |
| 2.7 | Links clickable | Click a URL link | Opens in system browser | |
| 2.8 | nostr: links | Click a nostr: link | Does NOT open OS error dialog (scheme filtered) | |
| 2.9 | Images in content | Find article with images | Images render via Coil | |
| 2.10 | Back button | Click ← Back | Returns to ReadsScreen | |
| 2.11 | Content width | Check article body | Max ~680dp centered column | |
| 2.12 | Loading state | Open article (watch transition) | "Loading article..." shown briefly | |
| 2.13 | Error state | Open invalid address tag (if testable) | "Article not found" message | |
---
## 3. Table of Contents
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 3.1 | ToC visible | Open article on wide window (>1100dp) | ToC sidebar appears on left with heading list | |
| 3.2 | ToC hidden | Resize window to <900dp | ToC sidebar disappears | |
| 3.3 | Heading hierarchy | Check ToC entries | H2 indented less than H3 | |
| 3.4 | Click heading | Click a ToC entry | Active entry highlights (scroll-to is TODO) | |
| 3.5 | No headings | Open article with no markdown headings | ToC sidebar not shown or empty | |
---
## 4. Article Editor
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 4.1 | Navigate to editor | Drafts tab → New Draft (or via menu) | Editor screen opens with split pane | |
| 4.2 | Split pane | Check layout | Source left, preview right | |
| 4.3 | Live preview | Type markdown in source pane | Preview updates after ~300ms | |
| 4.4 | Toolbar: Bold | Click B button | `**text**` inserted at cursor | |
| 4.5 | Toolbar: Italic | Click I button | `*text*` inserted | |
| 4.6 | Toolbar: Heading | Click H button | `## ` inserted | |
| 4.7 | Toolbar: Link | Click link button | `[text](url)` inserted | |
| 4.8 | Toolbar: Code | Click code button | Backticks inserted | |
| 4.9 | Toolbar: Quote | Click quote button | `> ` inserted | |
| 4.10 | Metadata: Title | Enter title | Title field accepts input, max 256 chars | |
| 4.11 | Metadata: Summary | Enter summary | Summary field accepts input, max 1024 chars | |
| 4.12 | Metadata: Tags | Type tag + Enter | Tag chip added | |
| 4.13 | Metadata: Slug | Enter slug | Auto-sanitized (no special chars) | |
| 4.14 | Ctrl+S save | Press Ctrl+S (or Cmd+S) | Draft saved to disk | |
| 4.15 | Back button | Click ← Back | Returns to previous screen | |
| 4.16 | Preview link safety | Add `[click](javascript:alert(1))` in source | Link NOT clickable in preview (scheme blocked) | |
---
## 5. Draft Storage
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 5.1 | Draft saved | Create draft, save, check filesystem | `~/.amethyst/drafts/<slug>.md` exists | |
| 5.2 | Index file | Check filesystem | `~/.amethyst/drafts/index.json` exists with metadata | |
| 5.3 | Drafts screen | Navigate to Drafts | Lists saved drafts with title, date | |
| 5.4 | Resume editing | Click a draft in list | Editor opens with content restored | |
| 5.5 | Delete draft | Click delete on a draft | Confirmation dialog → draft removed | |
| 5.6 | Slug sanitization | Try slug with `../` or special chars | Slug sanitized to safe characters | |
| 5.7 | Directory permissions | `ls -la ~/.amethyst/drafts/` | Dir permissions 700 (Unix) | |
---
## 6. Publish
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 6.1 | Publish button | Fill title + content, click Publish | Event signed and sent to relays | |
| 6.2 | Publish feedback | After publish | Success snackbar / confirmation | |
| 6.3 | Published in feed | After publish, check Reads feed | Your article appears in Global feed | |
| 6.4 | Re-publish (replace) | Edit same draft, publish again | Article updated (same d-tag) | |
| 6.5 | Size limit | Try publishing >100KB content | Error message about content too large | |
---
## 7. Typography (Visual)
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 7.1 | Body text | Read article body | Georgia-style serif, ~18sp, generous line height | |
| 7.2 | Content centered | Check horizontal layout | Content centered with max ~680dp width | |
| 7.3 | Dark mode | Check dark theme | Text ~#E0E0E0 on dark background, comfortable contrast | |
| 7.4 | Blockquotes | Find blockquote | Left border/indent, slightly larger text | |
| 7.5 | Tables | Find table | Renders with columns and rows | |
---
## 8. Security Checks
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 8.1 | XSS in markdown | Article with `<script>alert(1)</script>` | Rendered as literal text, no execution | |
| 8.2 | URI scheme: javascript | Link `[x](javascript:alert(1))` in article | Link not clickable / filtered | |
| 8.3 | URI scheme: file | Link `[x](file:///etc/passwd)` in article | Link not clickable / filtered | |
| 8.4 | Image URL validation | Article with `file:///etc/passwd` as banner | Image not loaded | |
| 8.5 | Slug traversal | Set slug to `../../.ssh/keys` | Sanitized to safe string | |
---
## 9. Edge Cases
| # | Test | Steps | Expected | Pass? |
|---|------|-------|----------|-------|
| 9.1 | Empty article | Article with no content | Reader shows empty body, no crash | |
| 9.2 | No relays connected | Disconnect all relays → open article | "Connecting to relays..." loading state | |
| 9.3 | Very long article | Article with 10k+ words | Renders without freeze (may be slow) | |
| 9.4 | No banner image | Article without `image` tag | Header renders without banner, no crash | |
| 9.5 | No author metadata | Article from unknown pubkey | Shows pubkey hex, no profile pic | |
| 9.6 | Window resize | Resize during article reading | Layout adapts, ToC shows/hides | |
---
## Notes
_Record any bugs, unexpected behavior, or UX issues here:_
| | Issue | Severity | Notes |
|--|-------|----------|-------|
| | | | |
| | | | |
| | | | |
@@ -1,94 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip44Encryption
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class Nip44v1Test {
private val nip44v1 = Nip44v1()
@Test
fun testSharedSecretCompatibilityWithCoracle() {
val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561"
val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f"
val key =
nip44v1.getSharedSecret(
privateKey = privateKey.hexToByteArray(),
pubKey = publicKey.hexToByteArray(),
)
assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey())
}
@Test
fun testSharedSecret() {
val sender = KeyPair()
val receiver = KeyPair()
val sharedSecret1 = nip44v1.getSharedSecret(sender.privKey!!, receiver.pubKey)
val sharedSecret2 = nip44v1.getSharedSecret(receiver.privKey!!, sender.pubKey)
assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey())
val secretKey1 = KeyPair(privKey = sharedSecret1)
val secretKey2 = KeyPair(privKey = sharedSecret2)
assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey())
assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey())
}
@Test
fun encryptDecrypt() {
val msg = "Hi"
val privateKey = Nip01Crypto.privKeyCreate()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
val encrypted = nip44v1.encrypt(msg, privateKey, publicKey)
val decrypted = nip44v1.decrypt(encrypted, privateKey, publicKey)
assertEquals(msg, decrypted)
}
@Test
fun encryptDecryptSharedSecret() {
val msg = "Hi"
val privateKey = Nip01Crypto.privKeyCreate()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
val sharedSecret = nip44v1.getSharedSecret(privateKey, publicKey)
val encrypted = nip44v1.encrypt(msg, sharedSecret)
val decrypted = nip44v1.decrypt(encrypted, sharedSecret)
assertEquals(msg, decrypted)
}
}
@@ -1,185 +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.nip44Encryption
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertNull
import junit.framework.TestCase.fail
import kotlinx.serialization.json.decodeFromStream
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class Nip44v2Test {
private val vectors: VectorFile =
JsonMapper.jsonInstance.decodeFromStream<VectorFile>(
javaClass.classLoader.getResourceAsStream("nip44.vectors.json"),
)
private val nip44v2 = Nip44v2()
@Test
fun conversationKeyTest() {
for (v in vectors.v2?.valid?.getConversationKey!!) {
val conversationKey =
nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray())
assertEquals(v.conversationKey, conversationKey.toHexKey())
}
}
@Test
fun paddingTest() {
for (v in vectors.v2?.valid?.calcPaddedLen!!) {
val actual = nip44v2.calcPaddedLen(v[0])
assertEquals(v[1], actual)
}
}
@Test
fun testCompressedWith02Keys() {
val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
val privateKeyB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray()
val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA)
val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB)
assertEquals(
nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(),
nip44v2.getConversationKey(privateKeyB, publicKeyA).toHexKey(),
)
}
@Test
fun testCompressedWith03Keys() {
val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
val privateKeyB = "e6159851715b4aa6190c22b899b0c792847de0a4435ac5b678f35738351c43b0".hexToByteArray()
val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA)
val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB)
assertEquals(
nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(),
nip44v2.getConversationKey(privateKeyB, publicKeyA).toHexKey(),
)
}
@Test
fun encryptDecryptTest() {
for (v in vectors.v2?.valid?.encryptDecrypt!!) {
val pub2 = KeyPair(v.sec2!!.hexToByteArray())
val conversationKey1 = nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), pub2.pubKey)
assertEquals(v.conversationKey, conversationKey1.toHexKey())
val ciphertext =
nip44v2
.encryptWithNonce(
v.plaintext!!,
conversationKey1,
v.nonce!!.hexToByteArray(),
).encodePayload()
assertEquals(v.payload, ciphertext)
val pub1 = KeyPair(v.sec1.hexToByteArray())
val conversationKey2 = nip44v2.getConversationKey(v.sec2.hexToByteArray(), pub1.pubKey)
assertEquals(v.conversationKey, conversationKey2.toHexKey())
val decrypted = nip44v2.decrypt(v.payload!!, conversationKey2)
assertEquals(v.plaintext, decrypted)
}
}
@Test
fun encryptDecryptLongTest() {
for (v in vectors.v2?.valid?.encryptDecryptLongMsg!!) {
val conversationKey = v.conversationKey!!.hexToByteArray()
val plaintext = v.pattern!!.repeat(v.repeat!!)
assertEquals(v.plaintextSha256, sha256Hex(plaintext.toByteArray(Charsets.UTF_8)))
val ciphertext =
nip44v2
.encryptWithNonce(
plaintext,
conversationKey,
v.nonce!!.hexToByteArray(),
).encodePayload()
assertEquals(v.payloadSha256, sha256Hex(ciphertext.toByteArray(Charsets.UTF_8)))
val decrypted = nip44v2.decrypt(ciphertext, conversationKey)
assertEquals(plaintext, decrypted)
}
}
@Test
fun extendedMessageLengths() {
for (v in vectors.v2?.invalid?.encryptMsgLengths!!) {
val key = RandomInstance.bytes(32)
try {
val input = "a".repeat(v)
val result = nip44v2.encrypt(input, key)
val decrypted = nip44v2.decrypt(result, key)
assertEquals(input, decrypted)
} catch (e: Exception) {
assertNotNull(e)
}
}
}
@Test
fun invalidDecrypt() {
for (v in vectors.v2?.invalid?.decrypt!!) {
try {
val result = nip44v2.decrypt(v.payload!!, v.conversationKey!!.hexToByteArray())
assertNull(result)
// fail("Should Throw for ${v.note}")
} catch (e: Exception) {
assertNotNull(e)
}
}
}
@Test
fun invalidConversationKey() {
for (v in vectors.v2?.invalid?.getConversationKey!!) {
try {
nip44v2.getConversationKey(v.sec1!!.hexToByteArray(), v.pub2!!.hexToByteArray())
fail("Should Throw for ${v.note}")
} catch (e: Exception) {
assertNotNull(e)
}
}
}
private fun sha256Hex(data: ByteArray) = sha256(data).toHexKey()
}
@@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Ignore
@@ -41,7 +40,7 @@ internal class Nip46Test {
val dummyEvent =
EventTemplate<Event>(
createdAt = TimeUtils.now(),
createdAt = 1753988264,
kind = 1,
tags = emptyArray(),
content = "test",
@@ -49,12 +48,12 @@ internal class Nip46Test {
val dummyEventSigned =
TextNoteEvent(
id = "",
pubKey = "",
createdAt = TimeUtils.now(),
id = "0b6d941c46411a95edb1c93da7ad6ca26370497d8c7b7d621f5cb59f48841bad",
pubKey = "6dd3b72e325da7383b275eef1c66131ba4664326e162bc060527509b4e33ae43",
createdAt = 1753988264,
tags = emptyArray(),
content = "test",
sig = "",
sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356",
)
suspend fun <T : BunkerMessage> encodeDecodeEvent(req: T): T {
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
@@ -32,17 +31,14 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content
@Immutable
class AttestationEvent(
@@ -54,8 +50,7 @@ class AttestationEvent(
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider {
AddressHintProvider {
override fun eventHints(): List<EventIdHint> = tags.mapNotNull(ETag::parseAsHint)
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 pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
fun validity() = tags.validity()
fun status() = tags.status()
fun validFrom() = tags.validFrom()
@@ -94,10 +83,6 @@ class AttestationEvent(
fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse)
fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey)
fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse)
companion object {
const val KIND = 31871
const val ALT_DESCRIPTION = "Attestation"
@@ -106,7 +91,6 @@ class AttestationEvent(
dTagId: String,
about: EventHintBundle<Event>,
content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null,
validFrom: Long? = null,
validTo: Long? = null,
@@ -117,7 +101,6 @@ class AttestationEvent(
alt(ALT_DESCRIPTION)
dTag(dTagId)
about(about)
validity?.let { validity(it) }
status?.let { status(it) }
validFrom?.let { validFrom(it) }
validTo?.let { validTo(it) }
@@ -129,7 +112,6 @@ class AttestationEvent(
dTagId: String,
about: EventHintBundle<BaseReplaceableEvent>,
content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null,
validFrom: Long? = null,
validTo: Long? = null,
@@ -140,7 +122,6 @@ class AttestationEvent(
alt(ALT_DESCRIPTION)
dTag(dTagId)
aboutReplaceable(about)
validity?.let { validity(it) }
status?.let { status(it) }
validFrom?.let { validFrom(it) }
validTo?.let { validTo(it) }
@@ -152,7 +133,6 @@ class AttestationEvent(
dTagId: String,
about: EventHintBundle<BaseAddressableEvent>,
content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null,
validFrom: Long? = null,
validTo: Long? = null,
@@ -163,7 +143,6 @@ class AttestationEvent(
alt(ALT_DESCRIPTION)
dTag(dTagId)
aboutAddressable(about)
validity?.let { validity(it) }
status?.let { status(it) }
validFrom?.let { validFrom(it) }
validTo?.let { validTo(it) }
@@ -25,8 +25,6 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Reque
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
@@ -36,8 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
fun TagArrayBuilder<AttestationEvent>.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity))
fun TagArrayBuilder<AttestationEvent>.status(status: AttestationStatus) = addUnique(StatusTag.assemble(status))
fun TagArrayBuilder<AttestationEvent>.validFrom(timestamp: Long) = addUnique(ValidFromTag.assemble(timestamp))
@@ -24,11 +24,8 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Reque
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.validity() = firstNotNullOfOrNull(ValidityTag::parse)
fun TagArray.status() = firstNotNullOfOrNull(StatusTag::parse)
fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse)
@@ -26,10 +26,9 @@ import com.vitorpamplona.quartz.utils.ensure
enum class AttestationStatus(
val code: String,
) {
ACCEPTED("accepted"),
REJECTED("rejected"),
VERIFYING("verifying"),
VERIFIED("verified"),
VALID("valid"),
INVALID("invalid"),
REVOKED("revoked"),
}
@@ -45,10 +44,9 @@ class StatusTag {
ensure(tag[1].isNotEmpty()) { return null }
return when (tag[1]) {
AttestationStatus.ACCEPTED.code -> AttestationStatus.ACCEPTED
AttestationStatus.REJECTED.code -> AttestationStatus.REJECTED
AttestationStatus.VERIFYING.code -> AttestationStatus.VERIFYING
AttestationStatus.VERIFIED.code -> AttestationStatus.VERIFIED
AttestationStatus.VALID.code -> AttestationStatus.VALID
AttestationStatus.INVALID.code -> AttestationStatus.INVALID
AttestationStatus.REVOKED.code -> AttestationStatus.REVOKED
else -> null
}
@@ -1,53 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.attestations.attestation.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
enum class Validity(
val code: String,
) {
VALID("valid"),
INVALID("invalid"),
}
class ValidityTag {
companion object {
const val TAG_NAME = "v"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Validity? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return when (tag[1]) {
Validity.VALID.code -> Validity.VALID
Validity.INVALID.code -> Validity.INVALID
else -> null
}
}
fun assemble(validity: Validity) = arrayOf(TAG_NAME, validity.code)
}
}
@@ -40,7 +40,7 @@ class AttestorProficiencyEvent(
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun kinds() = tags.kinds()
fun description() = tags.description()
fun description() = content.ifBlank { null }
companion object {
const val KIND = 11871
@@ -51,10 +51,9 @@ class AttestorProficiencyEvent(
description: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestorProficiencyEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
) = eventTemplate(KIND, description ?: "", createdAt) {
alt(ALT_DESCRIPTION)
kinds(kinds)
description?.let { desc(it) }
initializer()
}
}
@@ -20,11 +20,8 @@
*/
package com.vitorpamplona.quartz.experimental.attestations.proficiency
import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag
import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<AttestorProficiencyEvent>.kinds(kinds: List<Kind>) = addAll(KindTag.assemble(kinds))
fun TagArrayBuilder<AttestorProficiencyEvent>.desc(description: String) = addUnique(DescriptionTag.assemble(description))
@@ -20,10 +20,7 @@
*/
package com.vitorpamplona.quartz.experimental.attestations.proficiency
import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag
import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.kinds() = mapNotNull(KindTag::parse)
fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse)
@@ -41,7 +41,7 @@ class AttestorRecommendationEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun kinds() = tags.kinds()
fun description() = tags.description()
fun description() = content.ifBlank { null }
companion object {
const val KIND = 31873
@@ -53,11 +53,10 @@ class AttestorRecommendationEvent(
description: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestorRecommendationEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
) = eventTemplate(KIND, description ?: "", createdAt) {
alt(ALT_DESCRIPTION)
dTag(attestorPubKey)
kinds(kinds)
description?.let { desc(it) }
initializer()
}
}
@@ -20,11 +20,8 @@
*/
package com.vitorpamplona.quartz.experimental.attestations.recommendation
import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag
import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<AttestorRecommendationEvent>.kinds(kinds: List<Kind>) = addAll(KindTag.assemble(kinds))
fun TagArrayBuilder<AttestorRecommendationEvent>.desc(description: String) = addUnique(DescriptionTag.assemble(description))
@@ -20,10 +20,7 @@
*/
package com.vitorpamplona.quartz.experimental.attestations.recommendation
import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag
import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.kinds() = mapNotNull(KindTag::parse)
fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse)
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip01Core.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult
import com.vitorpamplona.quartz.nip45Count.HyperLogLog
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
@@ -57,6 +58,7 @@ object CountResultKSerializer : KSerializer<CountResult> {
put("count", value.count)
// Matches Jackson's CountResultSerializer which writes "pubkey" for approximate
put("pubkey", value.approximate)
value.hll?.let { put("hll", HyperLogLog.encode(it)) }
}
override fun deserialize(decoder: Decoder): CountResult {
@@ -68,5 +70,6 @@ object CountResultKSerializer : KSerializer<CountResult> {
CountResult(
count = jsonObject["count"]!!.jsonPrimitive.int,
approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false,
hll = jsonObject["hll"]?.jsonPrimitive?.content?.let { HyperLogLog.decode(it) },
)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip45Count.HyperLogLog
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.withTimeoutOrNull
@@ -137,3 +138,50 @@ suspend fun INostrClient.queryCountSuspend(
return results
}
/**
* Queries multiple relays for a COUNT and merges the HyperLogLog
* registers from all responses to produce a single merged estimate.
*
* If any relay returns HLL data, the results are merged by taking
* the maximum register value across all relays, and the cardinality
* is re-estimated from the merged registers.
*
* If no relay returns HLL data, falls back to the maximum count
* reported by any relay.
*
* @param relays List of relays to query.
* @param filter The filter to count against.
* @param timeoutMs How long to wait for all responses (default 15 s).
* @return A merged [CountResult], or `null` if no relay responded.
*/
suspend fun INostrClient.queryCountMergedHll(
relays: List<NormalizedRelayUrl>,
filter: Filter,
timeoutMs: Long = 15_000,
): CountResult? {
if (relays.isEmpty()) return null
val results =
queryCountSuspend(
filters = relays.associateWith { listOf(filter) },
timeoutMs = timeoutMs,
)
if (results.isEmpty()) return null
val hlls = results.values.mapNotNull { it.hll }
return if (hlls.isNotEmpty()) {
val merged = HyperLogLog.merge(hlls)
val estimate = HyperLogLog.estimate(merged)
CountResult(
count = estimate.toInt(),
approximate = true,
hll = merged,
)
} else {
// No HLL data - use the maximum count from any relay
results.values.maxByOrNull { it.count }
}
}
@@ -63,7 +63,7 @@ class RelayLogger(
is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}")
is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}")
is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}")
is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate}")
is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate} hll: ${msg.result.hll != null}")
is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}")
}
}
@@ -34,4 +34,5 @@ class CountMessage(
class CountResult(
val count: Int,
val approximate: Boolean = false,
val hll: ByteArray? = null,
)
@@ -38,28 +38,6 @@ class ChaCha20 {
key: ByteArray,
) = decryptLibSodium(message, nonce, key)
/*
fun encryptNative(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = Cipher.getInstance("ChaCha20")
cipher.init(Cipher.ENCRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(message)
}
fun decryptNative(
message: ByteArray,
nonce: ByteArray,
key: ByteArray,
): ByteArray {
val cipher = Cipher.getInstance("ChaCha20")
cipher.init(Cipher.DECRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce))
return cipher.doFinal(message)
}
*/
fun encryptLibSodium(
message: ByteArray,
nonce: ByteArray,
@@ -0,0 +1,179 @@
/*
* 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.nip45Count
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.math.ln
import kotlin.math.pow
/**
* NIP-45 HyperLogLog implementation for probabilistic cardinality estimation.
*
* Uses 256 registers (8-bit precision, p=8) as specified by NIP-45.
* Each register is a single byte storing the maximum number of leading
* zero bits + 1 observed for events mapping to that register.
*
* HLL values are transmitted as 512-character hex strings (256 bytes).
*/
object HyperLogLog {
const val NUM_REGISTERS = 256
// Bias correction constant for m=256: α = 0.7213 / (1 + 1.079/m)
private val ALPHA_M: Double = 0.7213 / (1.0 + 1.079 / NUM_REGISTERS)
/**
* Merges multiple HLL register arrays by taking the maximum value
* for each register position across all inputs.
*
* @param hlls List of 256-byte register arrays from different relays
* @return Merged 256-byte register array
*/
fun merge(hlls: List<ByteArray>): ByteArray {
val merged = ByteArray(NUM_REGISTERS)
for (hll in hlls) {
for (i in 0 until NUM_REGISTERS) {
val value = hll[i].toInt() and 0xFF
val current = merged[i].toInt() and 0xFF
if (value > current) {
merged[i] = value.toByte()
}
}
}
return merged
}
/**
* Estimates the cardinality from an HLL register array using the
* standard HyperLogLog algorithm with small/large range corrections.
*
* @param registers 256-byte HLL register array
* @return Estimated cardinality
*/
fun estimate(registers: ByteArray): Long {
var harmonicSum = 0.0
var zeroRegisters = 0
for (i in 0 until NUM_REGISTERS) {
val value = registers[i].toInt() and 0xFF
harmonicSum += 2.0.pow(-value.toDouble())
if (value == 0) zeroRegisters++
}
val m = NUM_REGISTERS.toDouble()
var estimate = ALPHA_M * m * m / harmonicSum
// Small range correction: use linear counting when estimate is small
// and there are empty registers
if (estimate <= 2.5 * m && zeroRegisters > 0) {
estimate = m * ln(m / zeroRegisters.toDouble())
}
return estimate.toLong()
}
/**
* Decodes a 512-character hex string into a 256-byte HLL register array.
*
* @param hex 512-character hex string
* @return 256-byte register array, or null if the hex string is invalid
*/
fun decode(hex: String): ByteArray? {
if (hex.length != NUM_REGISTERS * 2) return null
return try {
Hex.decode(hex)
} catch (_: Exception) {
null
}
}
/**
* Encodes a 256-byte HLL register array into a 512-character hex string.
*
* @param registers 256-byte register array
* @return 512-character hex string
*/
fun encode(registers: ByteArray): String = Hex.encode(registers)
/**
* Computes the deterministic offset for a given filter, as specified
* by NIP-45. The offset determines which byte of event pubkeys is
* used as the register index.
*
* Algorithm:
* 1. Extract the first item from the filter's first tag attribute
* 2. Convert to a 64-character hex string:
* - If already a 64-char hex (event ID or pubkey): use directly
* - If an address (kind:pubkey:dtag): extract the pubkey
* - Otherwise: SHA-256 hash the value
* 3. Take the hex character at position 32
* 4. Parse as base-16 and add 8
*
* @param filter The filter to compute offset for
* @return The offset (8-23), or null if the filter has no tag attributes
*/
fun computeOffset(filter: Filter): Int? {
val firstTagValue = extractFirstTagValue(filter) ?: return null
val hex64 = toHex64(firstTagValue) ?: return null
val charAtPos32 = hex64[32]
val hexValue = charAtPos32.digitToIntOrNull(16) ?: return null
return hexValue + 8
}
/**
* Extracts the first value from the first tag attribute in the filter.
*/
private fun extractFirstTagValue(filter: Filter): String? {
val tags = filter.tags ?: return null
for ((_, values) in tags) {
if (values.isNotEmpty()) {
return values[0]
}
}
return null
}
/**
* Converts a tag value to a 64-character hex string.
*
* - If it's already a 64-char hex string: return as-is
* - If it's a Nostr address (kind:pubkey:dtag): extract pubkey
* - Otherwise: SHA-256 hash and hex-encode
*/
private fun toHex64(value: String): String? {
// Already a 64-char hex (event ID or pubkey)
if (value.length == 64 && Hex.isHex(value)) {
return value
}
// Try to parse as address (kind:pubkey:dtag)
val address = Address.parse(value)
if (address != null) {
return address.pubKeyHex
}
// Fall back to SHA-256
val hash = sha256(value.encodeToByteArray())
return Hex.encode(hash)
}
}
@@ -34,7 +34,7 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.fail
class Nip44v2Test {
class Nip44v2BaseTest {
private val vectors: VectorFile =
JsonMapper.jsonInstance.decodeFromString<VectorFile>(
TestResourceLoader().loadString("nip44.vectors.json"),
@@ -30,7 +30,7 @@ import kotlin.test.assertEquals
* NIP-44v2 tests verifying conversation key derivation, ECDH symmetry,
* and encrypt/decrypt round-trips on JVM Desktop.
*/
class Nip44v2JvmTest {
class Nip44v2Test {
@Test
fun conversationKeyFromSpecVector1() {
val nip44v2 = Nip44v2()
@@ -42,9 +42,9 @@ class LnZapPaymentRequestNip44EventTest {
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.toHexKey()
val request = GetBalanceMethod.create()
val request = GetBalanceMethod.Companion.create()
val event =
LnZapPaymentRequestEvent.createRequest(
LnZapPaymentRequestEvent.Companion.createRequest(
request = request,
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
@@ -66,9 +66,9 @@ class LnZapPaymentRequestNip44EventTest {
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.toHexKey()
val request = GetInfoMethod.create()
val request = GetInfoMethod.Companion.create()
val event =
LnZapPaymentRequestEvent.createRequest(
LnZapPaymentRequestEvent.Companion.createRequest(
request = request,
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
@@ -1,91 +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.nip44Encryption
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import kotlin.test.Test
import kotlin.test.assertEquals
class Nip44v1Test {
private val nip44v1 = Nip44v1()
@Test
fun testSharedSecretCompatibilityWithCoracle() {
val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561"
val publicKey = "765cd7cf91d3ad07423d114d5a39c61d52b2cdbc18ba055ddbbeec71fbe2aa2f"
val key =
nip44v1.getSharedSecret(
privateKey = privateKey.hexToByteArray(),
pubKey = publicKey.hexToByteArray(),
)
assertEquals("577c966f499dddd8e8dcc34e8f352e283cc177e53ae372794947e0b8ede7cfd8", key.toHexKey())
}
@Test
fun testSharedSecret() {
val sender = KeyPair()
val receiver = KeyPair()
val sharedSecret1 = nip44v1.getSharedSecret(sender.privKey!!, receiver.pubKey)
val sharedSecret2 = nip44v1.getSharedSecret(receiver.privKey!!, sender.pubKey)
assertEquals(sharedSecret1.toHexKey(), sharedSecret2.toHexKey())
val secretKey1 = KeyPair(privKey = sharedSecret1)
val secretKey2 = KeyPair(privKey = sharedSecret2)
assertEquals(secretKey1.pubKey.toHexKey(), secretKey2.pubKey.toHexKey())
assertEquals(secretKey1.privKey?.toHexKey(), secretKey2.privKey?.toHexKey())
}
@Test
fun encryptDecrypt() {
val msg = "Hi"
val privateKey = Nip01Crypto.privKeyCreate()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
val encrypted = nip44v1.encrypt(msg, privateKey, publicKey)
val decrypted = nip44v1.decrypt(encrypted, privateKey, publicKey)
assertEquals(msg, decrypted)
}
@Test
fun encryptDecryptSharedSecret() {
val msg = "Hi"
val privateKey = Nip01Crypto.privKeyCreate()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
val sharedSecret = nip44v1.getSharedSecret(privateKey, publicKey)
val encrypted = nip44v1.encrypt(msg, sharedSecret)
val decrypted = nip44v1.decrypt(encrypted, sharedSecret)
assertEquals(msg, decrypted)
}
}
@@ -1,103 +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.nip44Encryption
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VectorFile(
val v2: V2? = V2(),
)
@Serializable
data class V2(
val valid: Valid? = Valid(),
val invalid: Invalid? = Invalid(),
)
@Serializable
data class Valid(
@SerialName("get_conversation_key") val getConversationKey: ArrayList<GetConversationKey> = arrayListOf(),
@SerialName("get_message_keys") val getMessageKeys: GetMessageKeys? = GetMessageKeys(),
@SerialName("calc_padded_len") val calcPaddedLen: ArrayList<ArrayList<Int>> = arrayListOf(),
@SerialName("encrypt_decrypt") val encryptDecrypt: ArrayList<EncryptDecrypt> = arrayListOf(),
@SerialName("encrypt_decrypt_long_msg")
val encryptDecryptLongMsg: ArrayList<EncryptDecryptLongMsg> = arrayListOf(),
)
@Serializable
data class Invalid(
@SerialName("encrypt_msg_lengths") val encryptMsgLengths: ArrayList<Int> = arrayListOf(),
@SerialName("get_conversation_key") val getConversationKey: ArrayList<GetConversationKey> = arrayListOf(),
@SerialName("decrypt") val decrypt: ArrayList<Decrypt> = arrayListOf(),
)
@Serializable
data class GetConversationKey(
val sec1: String? = null,
val pub2: String? = null,
val note: String? = null,
@SerialName("conversation_key") val conversationKey: String? = null,
)
@Serializable
data class GetMessageKeys(
@SerialName("conversation_key") val conversationKey: String? = null,
val keys: ArrayList<Keys> = arrayListOf(),
)
@Serializable
data class Keys(
@SerialName("nonce") val nonce: String? = null,
@SerialName("chacha_key") val chachaKey: String? = null,
@SerialName("chacha_nonce") val chachaNonce: String? = null,
@SerialName("hmac_key") val hmacKey: String? = null,
)
@Serializable
data class EncryptDecrypt(
val sec1: String? = null,
val sec2: String? = null,
@SerialName("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val plaintext: String? = null,
val payload: String? = null,
)
@Serializable
data class EncryptDecryptLongMsg(
@SerialName("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val pattern: String? = null,
val repeat: Int? = null,
@SerialName("plaintext_sha256") val plaintextSha256: String? = null,
@SerialName("payload_sha256") val payloadSha256: String? = null,
)
@Serializable
data class Decrypt(
@SerialName("conversation_key") val conversationKey: String? = null,
val nonce: String? = null,
val plaintext: String? = null,
val payload: String? = null,
val note: String? = null,
)
@@ -0,0 +1,121 @@
/*
* 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.nip45Count
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.CountResultKSerializer
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class CountResultHllSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun testSerializeWithHll() {
val hll = ByteArray(256) { (it % 16).toByte() }
val result = CountResult(count = 42, approximate = true, hll = hll)
val element = CountResultKSerializer.serializeToElement(result)
assertEquals(42, element["count"]!!.jsonPrimitive.int)
assertNotNull(element["hll"])
assertEquals(512, element["hll"]!!.jsonPrimitive.content.length)
}
@Test
fun testSerializeWithoutHll() {
val result = CountResult(count = 10, approximate = false)
val element = CountResultKSerializer.serializeToElement(result)
assertEquals(10, element["count"]!!.jsonPrimitive.int)
assertNull(element["hll"])
}
@Test
fun testDeserializeWithHll() {
val hll = ByteArray(256) { (it % 8).toByte() }
val hllHex = HyperLogLog.encode(hll)
val jsonObject: JsonObject =
buildJsonObject {
put("count", 100)
put("approximate", true)
put("hll", hllHex)
}
val result = CountResultKSerializer.deserializeFromElement(jsonObject)
assertEquals(100, result.count)
assertTrue(result.approximate)
assertNotNull(result.hll)
assertTrue(hll.contentEquals(result.hll!!))
}
@Test
fun testDeserializeWithoutHll() {
val jsonObject: JsonObject =
buildJsonObject {
put("count", 50)
}
val result = CountResultKSerializer.deserializeFromElement(jsonObject)
assertEquals(50, result.count)
assertNull(result.hll)
}
@Test
fun testRoundTripSerialization() {
val hll = ByteArray(256) { (it * 3 % 20).toByte() }
val original = CountResult(count = 75, approximate = true, hll = hll)
val element = CountResultKSerializer.serializeToElement(original)
val deserialized = CountResultKSerializer.deserializeFromElement(element)
assertEquals(original.count, deserialized.count)
assertNotNull(deserialized.hll)
assertTrue(hll.contentEquals(deserialized.hll!!))
}
@Test
fun testDeserializeFromRelayJsonWithHll() {
// Simulate a real relay response: ["COUNT", "sub1", {"count": 42, "hll": "00010203..."}]
val hll = ByteArray(256) { 5 }
val hllHex = HyperLogLog.encode(hll)
val jsonStr = """{"count":42,"hll":"$hllHex"}"""
val jsonObject = json.decodeFromString<JsonObject>(jsonStr)
val result = CountResultKSerializer.deserializeFromElement(jsonObject)
assertEquals(42, result.count)
assertNotNull(result.hll)
assertEquals(256, result.hll!!.size)
assertEquals(5, result.hll!![0].toInt() and 0xFF)
}
}
@@ -0,0 +1,162 @@
/*
* 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.nip45Count
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HyperLogLogTest {
@Test
fun testEmptyRegistersEstimateZero() {
val registers = ByteArray(256)
val estimate = HyperLogLog.estimate(registers)
assertEquals(0L, estimate)
}
@Test
fun testEncodeDecodeRoundTrip() {
val registers = ByteArray(256) { (it % 10).toByte() }
val hex = HyperLogLog.encode(registers)
assertEquals(512, hex.length)
val decoded = HyperLogLog.decode(hex)
assertNotNull(decoded)
assertTrue(registers.contentEquals(decoded))
}
@Test
fun testDecodeInvalidHex() {
assertNull(HyperLogLog.decode("too_short"))
assertNull(HyperLogLog.decode(""))
}
@Test
fun testMergeSelectsMaxPerRegister() {
val hll1 = ByteArray(256) { 1 }
val hll2 = ByteArray(256) { 2 }
val hll3 = ByteArray(256) { 0 }
// Set some specific registers higher in hll1
hll1[0] = 5
hll1[100] = 10
val merged = HyperLogLog.merge(listOf(hll1, hll2, hll3))
// Register 0: max(5, 2, 0) = 5
assertEquals(5, merged[0].toInt() and 0xFF)
// Register 1: max(1, 2, 0) = 2
assertEquals(2, merged[1].toInt() and 0xFF)
// Register 100: max(10, 2, 0) = 10
assertEquals(10, merged[100].toInt() and 0xFF)
}
@Test
fun testMergeSingleHll() {
val hll = ByteArray(256) { (it % 5).toByte() }
val merged = HyperLogLog.merge(listOf(hll))
assertTrue(hll.contentEquals(merged))
}
@Test
fun testMergeEmptyList() {
val merged = HyperLogLog.merge(emptyList())
assertEquals(256, merged.size)
assertTrue(merged.all { it == 0.toByte() })
}
@Test
fun testEstimateNonZeroRegisters() {
// Set all registers to a uniform value > 0
val registers = ByteArray(256) { 3 }
val estimate = HyperLogLog.estimate(registers)
// With all registers at 3, the estimate should be positive
assertTrue(estimate > 0)
}
@Test
fun testEstimateIncreaseWithHigherValues() {
val lowRegisters = ByteArray(256) { 2 }
val highRegisters = ByteArray(256) { 5 }
val lowEstimate = HyperLogLog.estimate(lowRegisters)
val highEstimate = HyperLogLog.estimate(highRegisters)
assertTrue(highEstimate > lowEstimate)
}
@Test
fun testComputeOffsetWithEventIdFilter() {
// Event ID hex: the char at position 32 determines the offset
val eventId = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
val filter = Filter(tags = mapOf("e" to listOf(eventId)))
val offset = HyperLogLog.computeOffset(filter)
// char at position 32 is 'a' = 10, offset = 10 + 8 = 18
assertNotNull(offset)
assertEquals(18, offset)
}
@Test
fun testComputeOffsetWithPubkeyFilter() {
val pubkey = "0000000000000000000000000000000000000000000000000000000000000000"
val filter = Filter(tags = mapOf("p" to listOf(pubkey)))
val offset = HyperLogLog.computeOffset(filter)
// char at position 32 is '0' = 0, offset = 0 + 8 = 8
assertNotNull(offset)
assertEquals(8, offset)
}
@Test
fun testComputeOffsetWithNoTags() {
val filter = Filter(kinds = listOf(1))
val offset = HyperLogLog.computeOffset(filter)
assertNull(offset)
}
@Test
fun testComputeOffsetWithEmptyTags() {
val filter = Filter(tags = mapOf("e" to emptyList()))
val offset = HyperLogLog.computeOffset(filter)
assertNull(offset)
}
@Test
fun testComputeOffsetWithNonHexValue() {
// Non-hex value should be SHA-256 hashed
val filter = Filter(tags = mapOf("t" to listOf("nostr")))
val offset = HyperLogLog.computeOffset(filter)
assertNotNull(offset)
assertTrue(offset in 8..23)
}
@Test
fun testComputeOffsetDeterministic() {
val filter = Filter(tags = mapOf("e" to listOf("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")))
val offset1 = HyperLogLog.computeOffset(filter)
val offset2 = HyperLogLog.computeOffset(filter)
assertEquals(offset1, offset2)
}
}
@@ -1,84 +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.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
class LnZapPaymentRequestNip44EventTest {
@Test
fun testCreateRequestWithNip44() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.toHexKey()
val request = GetBalanceMethod.create()
val event =
LnZapPaymentRequestEvent.createRequest(
request = request,
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
createdAt = 1000L,
useNip44 = true,
)
assertEquals(23194, event.kind)
assertEquals("nip44_v2", event.encryptionScheme())
}
@Test
fun testDecryptNip44Request() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletSigner = NostrSignerInternal(walletKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.toHexKey()
val request = GetInfoMethod.create()
val event =
LnZapPaymentRequestEvent.createRequest(
request = request,
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
useNip44 = true,
)
assertEquals("nip44_v2", event.encryptionScheme())
// Wallet service should be able to decrypt NIP-44 encrypted request
val decrypted = event.decryptRequest(walletSigner)
assertIs<GetInfoMethod>(decrypted)
}
}