diff --git a/README.md b/README.md
index a19f165504..dce91b635f 100644
--- a/README.md
+++ b/README.md
@@ -405,24 +405,24 @@ to `onPause` methods.
### Feature Parity Table
-| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
-| :--- | :--- | :---: | :---: | :--- |
-| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. |
-| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. |
-| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. |
-| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. |
-| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. |
-| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. |
-| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. |
-| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. |
-| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. |
-| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. |
-| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
-| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
-| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. |
-| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. |
-| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
-| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
+| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
+|:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------|
+| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | |
+| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | |
+| | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | |
+| | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | |
+| | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | |
+| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. |
+| | GZip Compression | ✅ Full | ✅ Full | |
+| | BitSet | ✅ Full | ✅ Full | |
+| | LargeCache | ✅ Full | ✅ Full | |
+| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | |
+| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
+| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
+| **Utilities** | URL Encoding / Decoding | ✅ Full | ✅ Full | |
+| | Unicode Normalization | ✅ Full | ✅ Full | |
+| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
+| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
## Contributing
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt
index 411996a8f4..27291e8c1c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt
index 89c945c919..d7289698d5 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt
@@ -82,6 +82,12 @@ fun DisplayErrorMessages(
}
}
+ is ThrowableToastMsg2 -> {
+ InformationDialog(obj) {
+ toastManager.clearToasts()
+ }
+ }
+
is MultiErrorToastMsg -> {
MultiUserErrorMessageDialog(obj, accountViewModel, nav)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt
index 99d24adaae..4148e4a817 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ThrowableToastMsg.kt
@@ -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()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt
index a19b889a07..dad9a09f87 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt
index 49dfbc4401..76cd1f6445 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt
@@ -34,12 +34,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Approval
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ErrorOutline
-import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.RemoveDone
@@ -84,7 +81,6 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus
-import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
@@ -106,8 +102,7 @@ fun RenderAttestationPreview() {
arrayOf(
arrayOf("d", "af5aa898:fe108febb997:1773941524"),
arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"),
- arrayOf("s", "verified"),
- arrayOf("v", "valid"),
+ arrayOf("s", "valid"),
arrayOf("client", "attestr.xyz"),
),
)
@@ -161,19 +156,17 @@ fun RenderAttestation(
accountViewModel: AccountViewModel,
nav: INav,
) {
- val validity = remember(noteEvent) { noteEvent.validity() }
val status = remember(noteEvent) { noteEvent.status() }
val validFrom = remember(noteEvent) { noteEvent.validFrom() }
val validTo = remember(noteEvent) { noteEvent.validTo() }
val content = remember(noteEvent) { noteEvent.content.ifBlank { null } }
- val statusColor = remember(status, validity) { attestationColor(status, validity) }
- val statusIcon = remember(status, validity) { attestationIcon(status, validity) }
- val statusLabel = attestationStatusLabel(status, validity)
+ val statusColor = remember(status) { attestationColor(status) }
+ val statusIcon = remember(status) { attestationIcon(status) }
+ val statusLabel = attestationStatusLabel(status)
val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() }
val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() }
- val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() }
Column(
modifier =
@@ -257,13 +250,6 @@ fun RenderAttestation(
)
}
}
- } else if (aboutPubkey != null) {
- LoadUser(aboutPubkey, accountViewModel) {
- if (it != null) {
- Spacer(modifier = DoubleVertSpacer)
- UserCompose(it, accountViewModel = accountViewModel, nav = nav)
- }
- }
}
}
@@ -590,49 +576,31 @@ fun RenderAttestorProficiency(
}
}
-private fun attestationColor(
- status: AttestationStatus?,
- validity: Validity?,
-): Color =
+private fun attestationColor(status: AttestationStatus?): Color =
when {
- validity == Validity.INVALID -> Color(0xFFB71C1C)
- validity == Validity.VALID -> Color(0xFF2E7D32)
+ status == AttestationStatus.INVALID -> Color(0xFFB71C1C)
+ status == AttestationStatus.VALID -> Color(0xFF2E7D32)
status == AttestationStatus.REVOKED -> Color(0xFFB21CB7)
- status == AttestationStatus.REJECTED -> Color(0xFFB71C1C)
- status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32)
status == AttestationStatus.VERIFYING -> Color(0xFF173CF5)
- status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0)
else -> Color(0xFF757575)
}
-private fun attestationIcon(
- status: AttestationStatus?,
- validity: Validity?,
-): ImageVector =
+private fun attestationIcon(status: AttestationStatus?): ImageVector =
when {
- validity == Validity.INVALID -> Icons.Default.Close
- validity == Validity.VALID -> Icons.Default.CheckCircle
+ status == AttestationStatus.INVALID -> Icons.Default.Close
+ status == AttestationStatus.VALID -> Icons.Default.CheckCircle
status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone
- status == AttestationStatus.REJECTED -> Icons.Default.Delete
- status == AttestationStatus.VERIFIED -> Icons.Default.Approval
status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop
- status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty
else -> Icons.Default.ErrorOutline
}
@Composable
-private fun attestationStatusLabel(
- status: AttestationStatus?,
- validity: Validity?,
-): String =
+private fun attestationStatusLabel(status: AttestationStatus?): String =
when {
- validity == Validity.INVALID -> stringRes(R.string.attestation_invalid)
- validity == Validity.VALID -> stringRes(R.string.attestation_valid)
+ status == AttestationStatus.INVALID -> stringRes(R.string.attestation_invalid)
+ status == AttestationStatus.VALID -> stringRes(R.string.attestation_valid)
status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked)
- status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected)
- status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified)
status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying)
- status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted)
else -> stringRes(R.string.attestation)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
index 0e83f65e5d..59351fc050 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
@@ -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,
+ )
}
}
}
diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml
index e371e164f0..0d8d10d584 100644
--- a/amethyst/src/main/res/values-bn-rBD/strings.xml
+++ b/amethyst/src/main/res/values-bn-rBD/strings.xml
@@ -71,7 +71,7 @@
" অনুসরণ"
" অনুসারী"
"%1$s অনুসরণ"
- ""
+ "%1$s অনুসারী"
প্রোফাইল
নিরাপত্তা-ফিল্টার
লগ আউট
diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml
index a63640a6ff..38d929d100 100644
--- a/amethyst/src/main/res/values-zh-rCN/strings.xml
+++ b/amethyst/src/main/res/values-zh-rCN/strings.xml
@@ -448,6 +448,8 @@
打闪最高金额
共识
(0–100)%
+ 单选
+ 多选
投票结束日期 & 时间
投票结束于 %1$s
后关闭
@@ -487,6 +489,9 @@
接收方和公众不知道谁发送了付款
非打闪
Nostr 上没有痕迹,仅在闪电上
+ 匿名
+ 使用新的一次性身份发布。您的帐户将不会被链接到这个回复。
+ 此回复将从新的匿名身份发布
文件服务器
选择上传文件时使用的服务器
闪电地址或 @User
@@ -786,7 +791,7 @@
偏好设置
用户首选项
翻译
- 反应
+ 回应
设置
账户设置
应用程序设置
@@ -1119,8 +1124,8 @@
点赞
打闪
修改快速回应
- 反应设置
- 配置显示的反应按钮、它们的顺序以及是否显示计数器。
+ 回应设置
+ 配置显示的回应按钮、按钮顺序及是否显示回应计数。
已启用
显示计数
调整顺序
@@ -1129,7 +1134,7 @@
Boost
转发或引用此笔记
点赞
- 用表情符号对此笔记进行反应
+ 使用表情符号回应笔记
打闪
给作者发送 Lightning 网络付款
分享
@@ -1541,7 +1546,7 @@
私密中继
代理中继
公开消息
- 反应
+ 回应
名片
中继认证
中继发现
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 9652292077..216f20f77b 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -68,7 +68,8 @@
Signer not found
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.
-
+ Signer misbehaved
+ External signer returned a payload that is strange for the request. There might be a bug on either Amethyst or the Signer.
Zaps
View count
diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt
index 296a8927f8..407e8292f9 100644
--- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt
+++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt
@@ -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)
+ }
}
diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts
index c55f9e4099..72d905ec8d 100644
--- a/commons/build.gradle.kts
+++ b/commons/build.gradle.kts
@@ -72,6 +72,11 @@ kotlin {
// Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources)
+
+ // Markdown rendering (richtext-commonmark)
+ implementation(libs.markdown.commonmark)
+ implementation(libs.markdown.ui)
+ implementation(libs.markdown.ui.material3)
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt
new file mode 100644
index 0000000000..7e978be460
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.compose.article
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil3.compose.AsyncImage
+
+@Composable
+fun ArticleHeader(
+ title: String,
+ authorName: String?,
+ authorPicture: String?,
+ publishedAt: String?,
+ readingTimeMinutes: Int?,
+ bannerUrl: String?,
+ modifier: Modifier = Modifier,
+ onAuthorClick: (() -> Unit)? = null,
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ // Banner image
+ if (!bannerUrl.isNullOrBlank() &&
+ (bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://"))
+ ) {
+ AsyncImage(
+ model = bannerUrl,
+ contentDescription = "Article banner",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxWidth().height(300.dp),
+ )
+ Spacer(Modifier.height(24.dp))
+ }
+
+ // Title
+ Text(
+ text = title,
+ style =
+ MaterialTheme.typography.headlineLarge.copy(
+ fontSize = 34.sp,
+ fontWeight = FontWeight.Bold,
+ lineHeight = 40.sp,
+ letterSpacing = (-0.5).sp,
+ ),
+ )
+
+ Spacer(Modifier.height(16.dp))
+
+ // Author + metadata row
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (!authorPicture.isNullOrBlank() &&
+ (authorPicture.startsWith("https://") || authorPicture.startsWith("http://"))
+ ) {
+ AsyncImage(
+ model = authorPicture,
+ contentDescription = "Author",
+ modifier = Modifier.size(40.dp).clip(CircleShape),
+ contentScale = ContentScale.Crop,
+ )
+ Spacer(Modifier.width(12.dp))
+ }
+
+ Column {
+ if (!authorName.isNullOrBlank()) {
+ Text(
+ text = authorName,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
+ )
+ }
+
+ val metaParts = mutableListOf()
+ readingTimeMinutes?.let { metaParts.add("$it min read") }
+ publishedAt?.let { metaParts.add(it) }
+
+ if (metaParts.isNotEmpty()) {
+ Text(
+ text = metaParts.joinToString(" · "),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(24.dp))
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt
new file mode 100644
index 0000000000..292fb67291
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.compose.article
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)")
+private val TRAILING_HASHES_REGEX = Regex("#+$")
+
+data class TocEntry(
+ val level: Int,
+ val text: String,
+ val index: Int,
+)
+
+/**
+ * Extracts table of contents entries from markdown content.
+ * Parses ATX headings (# H1, ## H2, etc.), skipping code blocks.
+ */
+fun extractTableOfContents(markdown: String): List {
+ val entries = mutableListOf()
+ var inCodeBlock = false
+ var headingIndex = 0
+
+ markdown.lines().forEach { line ->
+ val trimmed = line.trim()
+ if (trimmed.startsWith("```")) {
+ inCodeBlock = !inCodeBlock
+ return@forEach
+ }
+ if (inCodeBlock) return@forEach
+
+ val match = HEADING_REGEX.find(trimmed)
+ if (match != null) {
+ val level = match.groupValues[1].length
+ val text =
+ match.groupValues[2]
+ .trim()
+ .replace(TRAILING_HASHES_REGEX, "")
+ .trim()
+ if (text.isNotEmpty() && level <= 3) {
+ entries.add(TocEntry(level = level, text = text, index = headingIndex))
+ }
+ headingIndex++
+ }
+ }
+ return entries
+}
+
+@Composable
+fun TableOfContents(
+ entries: List,
+ activeEntryIndex: Int?,
+ onEntryClick: (TocEntry) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier =
+ modifier
+ .width(240.dp)
+ .verticalScroll(scrollState)
+ .padding(vertical = 16.dp),
+ ) {
+ entries.forEach { entry ->
+ val isActive = entry.index == activeEntryIndex
+ val accentColor = MaterialTheme.colorScheme.primary
+
+ Text(
+ text = entry.text,
+ style =
+ MaterialTheme.typography.bodySmall.copy(
+ fontSize = 13.sp,
+ fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
+ color =
+ if (isActive) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ ),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .clickable { onEntryClick(entry) }
+ .padding(
+ start = ((entry.level - 1) * 16).dp,
+ top = 4.dp,
+ bottom = 4.dp,
+ end = 8.dp,
+ ).then(
+ if (isActive) {
+ Modifier.drawBehind {
+ drawLine(
+ color = accentColor,
+ start = Offset(0f, 0f),
+ end = Offset(0f, size.height),
+ strokeWidth = 3.dp.toPx(),
+ )
+ }
+ } else {
+ Modifier
+ },
+ ),
+ )
+ }
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt
new file mode 100644
index 0000000000..7c69d6fdb9
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt
@@ -0,0 +1,363 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.compose.editor
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+
+/**
+ * State holder for a markdown editor with selection-aware formatting.
+ *
+ * Solves the focus/selection bug: toolbar buttons steal focus from TextField,
+ * collapsing the selection. We cache the last known selection on every value
+ * change, and toolbar operations use the cached selection.
+ */
+class MarkdownEditorState(
+ initial: String = "",
+) {
+ var value by mutableStateOf(TextFieldValue(initial))
+ private set
+
+ /** Cached selection — updated on every onValueChange, survives focus loss. */
+ var lastSelection: TextRange = TextRange.Zero
+ private set
+
+ fun onValueChange(newValue: TextFieldValue) {
+ value = newValue
+ // Only cache non-zero selections (focus loss sends collapsed range)
+ if (newValue.selection.length > 0 || lastSelection == TextRange.Zero) {
+ lastSelection = newValue.selection
+ }
+ // Also cache cursor position when no selection
+ if (newValue.selection.collapsed) {
+ lastSelection = newValue.selection
+ }
+ }
+
+ fun loadContent(content: String) {
+ value = TextFieldValue(content)
+ lastSelection = TextRange.Zero
+ }
+
+ val text: String get() = value.text
+
+ // --- Active state detection (uses current value.selection for display) ---
+
+ val isBold: Boolean
+ get() = isWrapped("**", "**")
+
+ val isItalic: Boolean
+ get() = isWrappedItalic()
+
+ val isStrikethrough: Boolean
+ get() = isWrapped("~~", "~~")
+
+ val isInlineCode: Boolean
+ get() = isWrapped("`", "`")
+
+ val isBlockquote: Boolean
+ get() = isLinePrefix("> ")
+
+ val isUnorderedList: Boolean
+ get() = isLinePrefix("- ")
+
+ val isOrderedList: Boolean
+ get() {
+ val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
+ val line = text.substring(lineStart)
+ return line.matches(Regex("^\\d+\\.\\s.*"))
+ }
+
+ val isTaskList: Boolean
+ get() = isLinePrefix("- [ ] ") || isLinePrefix("- [x] ")
+
+ val headingLevel: Int?
+ get() {
+ val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
+ val line = text.substring(lineStart)
+ return when {
+ line.startsWith("### ") -> 3
+ line.startsWith("## ") -> 2
+ line.startsWith("# ") -> 1
+ else -> null
+ }
+ }
+
+ // --- Formatting operations (use lastSelection to survive focus loss) ---
+
+ fun toggleBold() {
+ applyToggleWrap("**", "**")
+ }
+
+ fun toggleItalic() {
+ applyToggleWrapItalic()
+ }
+
+ fun toggleStrikethrough() {
+ applyToggleWrap("~~", "~~")
+ }
+
+ fun toggleInlineCode() {
+ applyToggleWrap("`", "`")
+ }
+
+ fun setHeading(level: Int?) {
+ val sel = lastSelection
+ val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
+ val line = text.substring(lineStart)
+
+ // Remove existing heading prefix
+ val stripped =
+ when {
+ line.startsWith("### ") -> line.removePrefix("### ")
+ line.startsWith("## ") -> line.removePrefix("## ")
+ line.startsWith("# ") -> line.removePrefix("# ")
+ else -> line
+ }
+ val oldPrefixLen =
+ when {
+ line.startsWith("### ") -> 4
+ line.startsWith("## ") -> 3
+ line.startsWith("# ") -> 2
+ else -> 0
+ }
+
+ val newPrefix =
+ when (level) {
+ 1 -> "# "
+ 2 -> "## "
+ 3 -> "### "
+ else -> ""
+ }
+
+ val lineEnd = text.indexOf('\n', lineStart).let { if (it == -1) text.length else it }
+ val newText = text.substring(0, lineStart) + newPrefix + stripped + text.substring(lineEnd)
+ val shift = newPrefix.length - oldPrefixLen
+
+ value =
+ TextFieldValue(
+ text = newText,
+ selection = TextRange((sel.min + shift).coerceAtLeast(lineStart), (sel.max + shift).coerceAtLeast(lineStart)),
+ )
+ lastSelection = value.selection
+ }
+
+ fun toggleBlockquote() {
+ applyToggleLinePrefix("> ")
+ }
+
+ fun toggleUnorderedList() {
+ applyToggleLinePrefix("- ")
+ }
+
+ fun toggleOrderedList() {
+ val sel = lastSelection
+ val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
+ val line = text.substring(lineStart)
+
+ if (line.matches(Regex("^\\d+\\.\\s.*"))) {
+ // Remove ordered list prefix
+ val prefixEnd = line.indexOf(". ") + 2
+ val newText = text.substring(0, lineStart) + line.substring(prefixEnd) + text.substring(lineStart + line.indexOf('\n').let { if (it == -1) line.length else it })
+ value =
+ TextFieldValue(
+ text = text.substring(0, lineStart) + line.substring(prefixEnd),
+ selection = TextRange((sel.min - prefixEnd).coerceAtLeast(lineStart)),
+ )
+ } else {
+ applyToggleLinePrefix("1. ")
+ }
+ lastSelection = value.selection
+ }
+
+ fun toggleTaskList() {
+ val sel = lastSelection
+ val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
+
+ when {
+ text.startsWith("- [ ] ", lineStart) -> {
+ // Remove task list prefix
+ val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
+ val shift = 6
+ value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
+ lastSelection = value.selection
+ }
+
+ text.startsWith("- [x] ", lineStart) -> {
+ val newText = text.substring(0, lineStart) + text.substring(lineStart + 6)
+ val shift = 6
+ value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart)))
+ lastSelection = value.selection
+ }
+
+ else -> {
+ applyToggleLinePrefix("- [ ] ")
+ }
+ }
+ }
+
+ fun toggleCodeBlock() {
+ applyToggleWrap("```\n", "\n```")
+ }
+
+ fun insertHorizontalRule() {
+ val sel = lastSelection
+ val insert = "\n---\n"
+ val newText = text.substring(0, sel.min) + insert + text.substring(sel.max)
+ value = TextFieldValue(text = newText, selection = TextRange(sel.min + insert.length))
+ lastSelection = value.selection
+ }
+
+ fun insertLink() {
+ val sel = lastSelection
+ val selected = text.substring(sel.min, sel.max)
+
+ if (selected.isNotEmpty()) {
+ val newText = text.substring(0, sel.min) + "[$selected](url)" + text.substring(sel.max)
+ val urlStart = sel.min + selected.length + 3
+ value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
+ } else {
+ val newText = text.substring(0, sel.min) + "[](url)" + text.substring(sel.min)
+ value = TextFieldValue(text = newText, selection = TextRange(sel.min + 1))
+ }
+ lastSelection = value.selection
+ }
+
+ fun insertImage() {
+ val sel = lastSelection
+ val selected = text.substring(sel.min, sel.max)
+
+ if (selected.isNotEmpty()) {
+ val newText = text.substring(0, sel.min) + "" + text.substring(sel.max)
+ val urlStart = sel.min + selected.length + 4
+ value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
+ } else {
+ val newText = text.substring(0, sel.min) + "" + text.substring(sel.min)
+ val urlStart = sel.min + 7
+ value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3))
+ }
+ lastSelection = value.selection
+ }
+
+ // --- Private helpers ---
+
+ private fun isWrapped(
+ prefix: String,
+ suffix: String,
+ ): Boolean {
+ val sel = value.selection
+ val start = sel.min
+ val end = sel.max
+ return start >= prefix.length &&
+ end + suffix.length <= text.length &&
+ text.substring(start - prefix.length, start) == prefix &&
+ text.substring(end, end + suffix.length) == suffix
+ }
+
+ private fun isWrappedItalic(): Boolean {
+ val sel = value.selection
+ val start = sel.min
+ val end = sel.max
+ if (start < 1 || end + 1 > text.length) return false
+ if (text[start - 1] != '*' || text[end] != '*') return false
+ val hasBoldBefore = start >= 2 && text[start - 2] == '*'
+ val hasBoldAfter = end + 1 < text.length && text[end + 1] == '*'
+ return !hasBoldBefore && !hasBoldAfter
+ }
+
+ private fun isLinePrefix(prefix: String): Boolean {
+ val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1
+ return text.startsWith(prefix, lineStart)
+ }
+
+ private fun applyToggleWrap(
+ prefix: String,
+ suffix: String,
+ ) {
+ val sel = lastSelection
+ val start = sel.min
+ val end = sel.max
+
+ val wrapped =
+ start >= prefix.length &&
+ end + suffix.length <= text.length &&
+ text.substring(start - prefix.length, start) == prefix &&
+ text.substring(end, end + suffix.length) == suffix
+
+ value =
+ if (wrapped) {
+ val newText =
+ text.substring(0, start - prefix.length) +
+ text.substring(start, end) +
+ text.substring(end + suffix.length)
+ TextFieldValue(newText, TextRange(start - prefix.length, end - prefix.length))
+ } else if (start == end) {
+ val newText = text.substring(0, start) + prefix + suffix + text.substring(start)
+ TextFieldValue(newText, TextRange(start + prefix.length))
+ } else {
+ val newText = text.substring(0, start) + prefix + text.substring(start, end) + suffix + text.substring(end)
+ TextFieldValue(newText, TextRange(start + prefix.length, end + prefix.length))
+ }
+ lastSelection = value.selection
+ }
+
+ private fun applyToggleWrapItalic() {
+ val sel = lastSelection
+ val start = sel.min
+ val end = sel.max
+
+ val isItalic =
+ start >= 1 &&
+ end + 1 <= text.length &&
+ text[start - 1] == '*' &&
+ text[end] == '*' &&
+ !(start >= 2 && text[start - 2] == '*') &&
+ !(end + 1 < text.length && text[end + 1] == '*')
+
+ if (isItalic) {
+ val newText = text.substring(0, start - 1) + text.substring(start, end) + text.substring(end + 1)
+ value = TextFieldValue(newText, TextRange(start - 1, end - 1))
+ } else {
+ applyToggleWrap("*", "*")
+ return
+ }
+ lastSelection = value.selection
+ }
+
+ private fun applyToggleLinePrefix(prefix: String) {
+ val sel = lastSelection
+ val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1
+
+ value =
+ if (text.startsWith(prefix, lineStart)) {
+ val newText = text.substring(0, lineStart) + text.substring(lineStart + prefix.length)
+ val shift = prefix.length
+ TextFieldValue(newText, TextRange((sel.min - shift).coerceAtLeast(lineStart), (sel.max - shift).coerceAtLeast(lineStart)))
+ } else {
+ val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart)
+ TextFieldValue(newText, TextRange(sel.min + prefix.length, sel.max + prefix.length))
+ }
+ lastSelection = value.selection
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt
new file mode 100644
index 0000000000..4709625b15
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.compose.editor
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Checklist
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.FormatBold
+import androidx.compose.material.icons.filled.FormatItalic
+import androidx.compose.material.icons.filled.FormatListBulleted
+import androidx.compose.material.icons.filled.FormatListNumbered
+import androidx.compose.material.icons.filled.FormatQuote
+import androidx.compose.material.icons.filled.FormatStrikethrough
+import androidx.compose.material.icons.filled.HorizontalRule
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.Link
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SmallFloatingActionButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+/**
+ * Markdown toolbar with Material icons, grouped formatting buttons, and active state.
+ * Uses [MarkdownEditorState] for selection-aware toggle behavior.
+ *
+ * Buttons use `focusProperties { canFocus = false }` to prevent stealing focus
+ * from the editor TextField, preserving the user's text selection.
+ */
+@Composable
+fun MarkdownToolbar(
+ state: MarkdownEditorState,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // --- Headings ---
+ ToolbarButton(label = "H1", active = state.headingLevel == 1) { state.setHeading(if (state.headingLevel == 1) null else 1) }
+ ToolbarButton(label = "H2", active = state.headingLevel == 2) { state.setHeading(if (state.headingLevel == 2) null else 2) }
+ ToolbarButton(label = "H3", active = state.headingLevel == 3) { state.setHeading(if (state.headingLevel == 3) null else 3) }
+
+ Separator()
+
+ // --- Inline formatting ---
+ ToolbarIconButton(Icons.Default.FormatBold, "Bold", state.isBold) { state.toggleBold() }
+ ToolbarIconButton(Icons.Default.FormatItalic, "Italic", state.isItalic) { state.toggleItalic() }
+ ToolbarIconButton(Icons.Default.FormatStrikethrough, "Strikethrough", state.isStrikethrough) { state.toggleStrikethrough() }
+ ToolbarIconButton(Icons.Default.Code, "Inline code", state.isInlineCode) { state.toggleInlineCode() }
+
+ Separator()
+
+ // --- Lists ---
+ ToolbarIconButton(Icons.Default.FormatListBulleted, "Bullet list", state.isUnorderedList) { state.toggleUnorderedList() }
+ ToolbarIconButton(Icons.Default.FormatListNumbered, "Numbered list", state.isOrderedList) { state.toggleOrderedList() }
+ ToolbarIconButton(Icons.Default.Checklist, "Task list", state.isTaskList) { state.toggleTaskList() }
+
+ Separator()
+
+ // --- Block elements ---
+ ToolbarIconButton(Icons.Default.FormatQuote, "Blockquote", state.isBlockquote) { state.toggleBlockquote() }
+ ToolbarButton(label = "```", active = false) { state.toggleCodeBlock() }
+ ToolbarIconButton(Icons.Default.HorizontalRule, "Horizontal rule", false) { state.insertHorizontalRule() }
+
+ Separator()
+
+ // --- Insert ---
+ ToolbarIconButton(Icons.Default.Link, "Link", false) { state.insertLink() }
+ ToolbarIconButton(Icons.Default.Image, "Image", false) { state.insertImage() }
+ }
+}
+
+@Composable
+private fun Separator() {
+ VerticalDivider(
+ modifier = Modifier.height(24.dp).padding(horizontal = 4.dp),
+ color = MaterialTheme.colorScheme.outlineVariant,
+ )
+}
+
+@Composable
+private fun ToolbarIconButton(
+ icon: ImageVector,
+ contentDescription: String,
+ active: Boolean,
+ onClick: () -> Unit,
+) {
+ val containerColor =
+ if (active) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ }
+ val contentColor =
+ if (active) {
+ MaterialTheme.colorScheme.onPrimary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ SmallFloatingActionButton(
+ onClick = onClick,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ modifier =
+ Modifier
+ .size(32.dp)
+ .focusProperties { canFocus = false },
+ ) {
+ Icon(
+ icon,
+ contentDescription = contentDescription,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+}
+
+@Composable
+private fun ToolbarButton(
+ label: String,
+ active: Boolean,
+ onClick: () -> Unit,
+) {
+ val containerColor =
+ if (active) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ }
+ val contentColor =
+ if (active) {
+ MaterialTheme.colorScheme.onPrimary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ SmallFloatingActionButton(
+ onClick = onClick,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ modifier =
+ Modifier
+ .size(32.dp)
+ .focusProperties { canFocus = false },
+ ) {
+ Text(
+ text = label,
+ fontSize = 11.sp,
+ color = contentColor,
+ )
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt
new file mode 100644
index 0000000000..28a22e0801
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt
@@ -0,0 +1,160 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.compose.editor
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.unit.dp
+
+/**
+ * Form fields for article metadata: title, summary, banner, tags, slug.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun MetadataPanel(
+ title: String,
+ onTitleChange: (String) -> Unit,
+ summary: String,
+ onSummaryChange: (String) -> Unit,
+ bannerUrl: String,
+ onBannerUrlChange: (String) -> Unit,
+ tags: List,
+ onTagsChange: (List) -> Unit,
+ slug: String,
+ onSlugChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var tagInput by remember { mutableStateOf("") }
+
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedTextField(
+ value = title,
+ onValueChange = { if (it.length <= 256) onTitleChange(it) },
+ label = { Text("Title") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ supportingText = { Text("${title.length}/256") },
+ )
+
+ OutlinedTextField(
+ value = summary,
+ onValueChange = { if (it.length <= 1024) onSummaryChange(it) },
+ label = { Text("Summary") },
+ maxLines = 3,
+ modifier = Modifier.fillMaxWidth(),
+ supportingText = { Text("${summary.length}/1024") },
+ )
+
+ OutlinedTextField(
+ value = bannerUrl,
+ onValueChange = onBannerUrlChange,
+ label = { Text("Banner Image URL") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ // Tags chip input
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ OutlinedTextField(
+ value = tagInput,
+ onValueChange = { tagInput = it },
+ label = { Text("Add tag (Enter to add)") },
+ singleLine = true,
+ modifier =
+ Modifier.weight(1f).onKeyEvent { event ->
+ if (event.key == Key.Enter && tagInput.isNotBlank()) {
+ val newTag = tagInput.trim().lowercase()
+ if (newTag !in tags) {
+ onTagsChange(tags + newTag)
+ }
+ tagInput = ""
+ true
+ } else {
+ false
+ }
+ },
+ )
+ }
+
+ if (tags.isNotEmpty()) {
+ Spacer(Modifier.height(4.dp))
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ ) {
+ tags.forEach { tag ->
+ AssistChip(
+ onClick = { onTagsChange(tags - tag) },
+ label = { Text(tag) },
+ trailingIcon = {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Remove $tag",
+ modifier = Modifier.size(16.dp),
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+
+ OutlinedTextField(
+ value = slug,
+ onValueChange = onSlugChange,
+ label = { Text("Slug (d-tag)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ supportingText = { Text("Used as the unique identifier for this article") },
+ )
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt
new file mode 100644
index 0000000000..c31109d4af
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.compose.markdown
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.unit.Density
+import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
+import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
+import com.halilibo.richtext.markdown.BasicMarkdown
+import com.halilibo.richtext.ui.RichTextStyle
+import com.halilibo.richtext.ui.material3.RichText
+
+private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning", "highlight")
+
+/**
+ * Escapes markdown special characters inside highlighted text so it doesn't
+ * break the markdown parser when wrapped in a link.
+ */
+private fun escapeMarkdownInLink(text: String): String =
+ text
+ .replace("[", "\\[")
+ .replace("]", "\\]")
+ .replace("(", "\\(")
+ .replace(")", "\\)")
+
+@Composable
+fun RenderMarkdown(
+ content: String,
+ onLinkClick: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ fontScale: Float = 1.0f,
+ highlightedTexts: List = emptyList(),
+) {
+ val processedContent =
+ remember(content, highlightedTexts) {
+ if (highlightedTexts.isEmpty()) {
+ content
+ } else {
+ var result = content
+ highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text ->
+ val idx = result.indexOf(text)
+ if (idx >= 0) {
+ val escaped = escapeMarkdownInLink(text)
+ result = result.replaceFirst(text, "[$escaped](highlight://$index)")
+ }
+ }
+ result
+ }
+ }
+
+ val astNode =
+ remember(processedContent) {
+ CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent)
+ }
+
+ val uriHandler =
+ remember(onLinkClick) {
+ object : UriHandler {
+ override fun openUri(uri: String) {
+ val scheme = uri.substringBefore(":").lowercase()
+ if (scheme in ALLOWED_SCHEMES) {
+ onLinkClick(uri)
+ }
+ }
+ }
+ }
+
+ val currentDensity = LocalDensity.current
+ val scaledDensity =
+ remember(fontScale, currentDensity) {
+ if (fontScale == 1.0f) {
+ currentDensity
+ } else {
+ Density(
+ density = currentDensity.density * fontScale,
+ fontScale = currentDensity.fontScale,
+ )
+ }
+ }
+
+ CompositionLocalProvider(
+ LocalUriHandler provides uriHandler,
+ LocalDensity provides scaledDensity,
+ ) {
+ RichText(
+ modifier = modifier,
+ style = RichTextStyle(),
+ ) {
+ BasicMarkdown(astNode)
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt
similarity index 62%
rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt
rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt
index 62b5399206..4e1d866289 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt
@@ -18,24 +18,15 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.quartz.experimental.attestations.recommendation.tags
+package com.vitorpamplona.amethyst.commons.model.highlights
-import com.vitorpamplona.quartz.nip01Core.core.has
-import com.vitorpamplona.quartz.utils.ensure
-
-class DescriptionTag {
- companion object {
- const val TAG_NAME = "desc"
-
- fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
-
- fun parse(tag: Array): String? {
- ensure(tag.has(1)) { return null }
- ensure(tag[0] == TAG_NAME) { return null }
- ensure(tag[1].isNotEmpty()) { return null }
- return tag[1]
- }
-
- fun assemble(description: String) = arrayOf(TAG_NAME, description)
- }
-}
+data class HighlightData(
+ val id: String,
+ val text: String,
+ val note: String? = null,
+ val articleAddressTag: String,
+ val articleTitle: String? = null,
+ val createdAt: Long,
+ val published: Boolean = false,
+ val eventId: String? = null,
+)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt
new file mode 100644
index 0000000000..d9c151b018
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip23LongContent
+
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
+import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
+import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+import com.vitorpamplona.quartz.utils.TimeUtils
+
+/**
+ * Shared action for publishing long-form content (NIP-23 kind 30023).
+ * Handles title, summary, image, tags, and d-tag for addressable events.
+ */
+object LongFormPublishAction {
+ private const val MAX_CONTENT_BYTES = 100_000
+
+ /**
+ * Publishes a long-form text note (NIP-23 kind 30023).
+ *
+ * @param title The article title
+ * @param content The markdown body content
+ * @param summary Optional article summary
+ * @param image Optional banner image URL
+ * @param tags List of hashtag topics
+ * @param dTag Unique identifier for this addressable event (slug)
+ * @param signer The NostrSigner to sign the event
+ * @return Signed LongTextNoteEvent ready to broadcast
+ * @throws IllegalStateException if signer is not writeable
+ */
+ suspend fun publish(
+ title: String,
+ content: String,
+ summary: String?,
+ image: String?,
+ tags: List,
+ dTag: String,
+ signer: NostrSigner,
+ ): LongTextNoteEvent {
+ if (!signer.isWriteable()) {
+ throw IllegalStateException("Cannot publish: signer is not writeable")
+ }
+
+ if (content.toByteArray().size > MAX_CONTENT_BYTES) {
+ throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
+ }
+
+ val template =
+ LongTextNoteEvent.build(
+ description = content,
+ title = title,
+ summary = summary,
+ image = image,
+ publishedAt = TimeUtils.now(),
+ dTag = dTag,
+ ) {
+ tags.forEach { hashtag(it) }
+ }
+
+ return signer.sign(template)
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt
new file mode 100644
index 0000000000..b326186bd8
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.model.nip23LongContent
+
+import kotlin.math.ceil
+import kotlin.math.max
+
+/**
+ * Calculates estimated reading time for markdown content.
+ * Uses 238 WPM for prose (Brysbaert 2019), 80 WPM for code blocks,
+ * and Medium's image decay formula (12 sec first, -1 each, min 3).
+ */
+object ReadingTimeCalculator {
+ private const val PROSE_WPM = 238.0
+ private const val CODE_WPM = 80.0
+
+ private val WHITESPACE_REGEX = "\\s+".toRegex()
+ private val IMAGE_REGEX = Regex("!\\[.*?]\\(.*?\\)")
+ private val IMAGE_STRIP_REGEX = Regex("!\\[.*?]\\(.*?\\)")
+ private val LINK_REGEX = Regex("\\[([^]]*)]\\([^)]*\\)")
+ private val FORMATTING_REGEX = Regex("[*_~`#>]")
+ private val LIST_MARKER_REGEX = Regex("^-\\s+|^\\d+\\.\\s+")
+ private val HORIZONTAL_RULE_REGEX = Regex("^---+$|^\\*\\*\\*+$")
+
+ fun calculate(markdownContent: String): Int {
+ var proseWords = 0
+ var codeWords = 0
+ var imageCount = 0
+ var inCodeBlock = false
+
+ markdownContent.lines().forEach { line ->
+ val trimmed = line.trim()
+
+ if (trimmed.startsWith("```")) {
+ inCodeBlock = !inCodeBlock
+ return@forEach
+ }
+
+ if (inCodeBlock) {
+ codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() }
+ return@forEach
+ }
+
+ // Count images
+ val imageMatches = IMAGE_REGEX.findAll(trimmed)
+ imageCount += imageMatches.count()
+
+ // Strip markdown syntax for word counting
+ val stripped =
+ trimmed
+ .replace(IMAGE_STRIP_REGEX, "") // images
+ .replace(LINK_REGEX, "$1") // links -> text only
+ .replace(FORMATTING_REGEX, "") // formatting
+ .replace(LIST_MARKER_REGEX, "") // list markers
+ .replace(HORIZONTAL_RULE_REGEX, "") // horizontal rules
+
+ proseWords += stripped.split(WHITESPACE_REGEX).count { it.isNotBlank() }
+ }
+
+ // Medium's image time decay: 12 sec first, -1 each, min 3
+ val imageSeconds = (0 until imageCount).sumOf { max(12 - it, 3) }
+
+ val totalMinutes = (proseWords / PROSE_WPM) + (codeWords / CODE_WPM) + (imageSeconds / 60.0)
+ return max(1, ceil(totalMinutes).toInt())
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
index 3d106a9e4e..dcc369138d 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
+import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -143,6 +144,16 @@ sealed class DesktopScreen {
val noteId: String,
) : DesktopScreen()
+ data class Article(
+ val addressTag: String,
+ ) : DesktopScreen()
+
+ data class Editor(
+ val draftSlug: String? = null,
+ ) : DesktopScreen()
+
+ data object Drafts : DesktopScreen()
+
data object Settings : DesktopScreen()
}
@@ -369,6 +380,8 @@ fun main() {
Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) })
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
+ Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) })
+ Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) })
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
@@ -597,6 +610,13 @@ fun MainContent(
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
}
+ val highlightStore = remember { DesktopHighlightStore(appScope) }
+ val draftStore =
+ remember {
+ com.vitorpamplona.amethyst.desktop.service.drafts
+ .DesktopDraftStore(appScope)
+ }
+
// Subscribe to incoming DMs and process into chatroomList
LaunchedEffect(account) {
relayManager.connectedRelays.first { it.isNotEmpty() }
@@ -716,6 +736,8 @@ fun MainContent(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
+ highlightStore = highlightStore,
+ draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -753,6 +775,8 @@ fun MainContent(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
+ highlightStore = highlightStore,
+ draftStore = draftStore,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt
new file mode 100644
index 0000000000..ac0dbaade0
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt
@@ -0,0 +1,289 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.service.drafts
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.nio.file.attribute.PosixFilePermission
+import java.time.Instant
+
+data class DraftMetadata(
+ val title: String = "",
+ val summary: String? = null,
+ val image: String? = null,
+ val tags: List = emptyList(),
+ val createdAt: String = Instant.now().toString(),
+ val updatedAt: String = Instant.now().toString(),
+ val published: Boolean = false,
+)
+
+data class DraftEntry(
+ val slug: String,
+ val metadata: DraftMetadata,
+)
+
+/**
+ * Local draft storage for long-form articles.
+ * Stores markdown content as .md files and metadata in index.json.
+ * Uses atomic writes and restrictive file permissions.
+ */
+class DesktopDraftStore(
+ private val scope: CoroutineScope,
+) {
+ private val mapper = jacksonObjectMapper()
+ private val mutex = Mutex()
+ private var cachedIndex: MutableMap? = null
+
+ private val _drafts = MutableStateFlow>(emptyList())
+ val drafts: StateFlow> = _drafts.asStateFlow()
+
+ private val draftsDir: File by lazy {
+ val dir = File(System.getProperty("user.home"), ".amethyst/drafts")
+ if (!dir.exists()) {
+ dir.mkdirs()
+ setDirPermissions(dir)
+ }
+ dir
+ }
+
+ private val indexFile: File get() = File(draftsDir, "index.json")
+
+ init {
+ scope.launch(Dispatchers.IO) {
+ cachedIndex = null
+ loadIndex()
+ }
+ }
+
+ /**
+ * Sanitizes a slug to prevent path traversal and ensure safe filenames.
+ */
+ private fun sanitizeSlug(slug: String): String {
+ val sanitized =
+ slug
+ .replace("/", "")
+ .replace("\\", "")
+ .replace("\u0000", "")
+ .trim()
+ .lowercase()
+ .replace(Regex("[^a-z0-9_-]"), "-")
+ .replace(Regex("-+"), "-")
+ .trimStart('-')
+ .trimEnd('-')
+ .take(128)
+
+ require(sanitized.isNotEmpty()) { "Slug cannot be empty after sanitization" }
+
+ // Validate canonical path stays within drafts dir
+ val resolved = File(draftsDir, "$sanitized.md").canonicalPath
+ require(resolved.startsWith(draftsDir.canonicalPath)) {
+ "Slug resolves outside drafts directory"
+ }
+
+ return sanitized
+ }
+
+ /**
+ * Generates a slug from a title. Falls back to timestamp if title is blank.
+ */
+ fun slugFromTitle(title: String): String {
+ if (title.isBlank()) return "untitled-${Instant.now().epochSecond}"
+ return sanitizeSlug(title)
+ }
+
+ /**
+ * Saves or updates a draft. Creates content file and updates index atomically.
+ */
+ suspend fun saveDraft(
+ slug: String,
+ content: String,
+ metadata: DraftMetadata,
+ ) {
+ val safeSlug = sanitizeSlug(slug)
+
+ mutex.withLock {
+ // Write content file atomically
+ val contentFile = File(draftsDir, "$safeSlug.md")
+ atomicWrite(contentFile, content)
+
+ // Update index
+ val index = loadIndexMap()
+ index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString())
+ atomicWriteIndex(index)
+ cachedIndex = index
+
+ // Refresh state
+ _drafts.value =
+ index.entries
+ .map { DraftEntry(it.key, it.value) }
+ .sortedByDescending { it.metadata.updatedAt }
+ }
+ }
+
+ /**
+ * Loads a draft's content by slug.
+ */
+ suspend fun loadContent(slug: String): String? {
+ val safeSlug = sanitizeSlug(slug)
+ val file = File(draftsDir, "$safeSlug.md")
+ return if (file.exists()) file.readText() else null
+ }
+
+ /**
+ * Loads a draft's metadata by slug.
+ */
+ suspend fun loadMetadata(slug: String): DraftMetadata? {
+ val safeSlug = sanitizeSlug(slug)
+ return mutex.withLock {
+ loadIndexMap()[safeSlug]
+ }
+ }
+
+ /**
+ * Deletes a draft by slug.
+ */
+ suspend fun deleteDraft(slug: String) {
+ val safeSlug = sanitizeSlug(slug)
+ mutex.withLock {
+ File(draftsDir, "$safeSlug.md").delete()
+
+ val index = loadIndexMap()
+ index.remove(safeSlug)
+ atomicWriteIndex(index)
+ cachedIndex = index
+
+ _drafts.value =
+ index.entries
+ .map { DraftEntry(it.key, it.value) }
+ .sortedByDescending { it.metadata.updatedAt }
+ }
+ }
+
+ /**
+ * Marks a draft as published.
+ */
+ suspend fun markPublished(slug: String) {
+ val safeSlug = sanitizeSlug(slug)
+ mutex.withLock {
+ val index = loadIndexMap()
+ val existing = index[safeSlug] ?: return
+ index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString())
+ atomicWriteIndex(index)
+ cachedIndex = index
+
+ _drafts.value =
+ index.entries
+ .map { DraftEntry(it.key, it.value) }
+ .sortedByDescending { it.metadata.updatedAt }
+ }
+ }
+
+ private fun loadIndexMap(): MutableMap {
+ cachedIndex?.let { return it }
+ val loaded: MutableMap =
+ if (!indexFile.exists()) {
+ mutableMapOf()
+ } else {
+ try {
+ mapper.readValue>(indexFile)
+ } catch (e: Exception) {
+ System.err.println("Failed to read drafts index: ${e.message}")
+ mutableMapOf()
+ }
+ }
+ cachedIndex = loaded
+ return loaded
+ }
+
+ private suspend fun loadIndex() {
+ mutex.withLock {
+ _drafts.value =
+ loadIndexMap()
+ .entries
+ .map { DraftEntry(it.key, it.value) }
+ .sortedByDescending { it.metadata.updatedAt }
+ }
+ }
+
+ private fun atomicWrite(
+ file: File,
+ content: String,
+ ) {
+ val tempFile = File(file.parentFile, "${file.name}.tmp")
+ try {
+ tempFile.writeText(content)
+ setFilePermissions(tempFile)
+ Files.move(
+ tempFile.toPath(),
+ file.toPath(),
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ } finally {
+ if (tempFile.exists()) tempFile.delete()
+ }
+ }
+
+ private fun atomicWriteIndex(index: Map) {
+ val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(index)
+ atomicWrite(indexFile, json)
+ }
+
+ private fun setDirPermissions(dir: File) {
+ try {
+ Files.setPosixFilePermissions(
+ dir.toPath(),
+ setOf(
+ PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE,
+ PosixFilePermission.OWNER_EXECUTE,
+ ),
+ )
+ } catch (_: UnsupportedOperationException) {
+ // Windows
+ }
+ }
+
+ private fun setFilePermissions(file: File) {
+ try {
+ Files.setPosixFilePermissions(
+ file.toPath(),
+ setOf(
+ PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE,
+ ),
+ )
+ } catch (_: UnsupportedOperationException) {
+ // Windows
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt
new file mode 100644
index 0000000000..16f238ffda
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.service.highlights
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
+import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * Local highlight storage for article annotations.
+ * Stores highlights as JSON in ~/.amethyst/highlights/index.json.
+ * Uses atomic writes following the same pattern as DesktopDraftStore.
+ */
+class DesktopHighlightStore(
+ private val scope: CoroutineScope,
+) {
+ private val mapper = jacksonObjectMapper()
+ private val mutex = Mutex()
+
+ private val _highlights = MutableStateFlow