refactor(reads): address all review findings — security, performance, simplicity

P1 Critical:
- Add 300ms debounce on editor preview to prevent AST re-parse per keystroke
- Hoist Regex constants in ReadingTimeCalculator and TableOfContents
- Add URI scheme allowlist to editor onLinkClick (was missing vs reader)
- Add MAX_CONTENT_BYTES (100KB) validation before publish

P2 Important:
- Delete MarkdownSpikeScreen (278 LOC spike artifact)
- Delete HighlightCreator (95 LOC, zero callers — YAGNI)
- Delete DraftLongTextNoteEvent (162 LOC, no consumers — YAGNI)
- Replace ArticleMediaRenderer interface with onLinkClick lambda
- Extract inline subscription to FilterBuilders.longFormByAddress()
- Replace SimpleDateFormat with thread-safe DateTimeFormatter
- Remove dead placeholders (bookmark button, reactions row, unused param)
- Cache draft index in memory to avoid redundant disk reads
- Add TODO for publish-before-relay-ack issue

P3 Nice-to-have:
- Remove timestamp from subscription subId for stability
- Remove redundant slug ".." removal (regex handles it)
- Add metadata field length limits (title 256, summary 1024)
- Fix identical isMacOS branches in editor
- Validate image URLs (http/https only) in ArticleHeader

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-24 07:18:42 +02:00
parent 4fcbffdb3b
commit 328fcf86d7
20 changed files with 368 additions and 419 deletions
@@ -53,7 +53,9 @@ fun ArticleHeader(
) {
Column(modifier = modifier.fillMaxWidth()) {
// Banner image
if (!bannerUrl.isNullOrBlank()) {
if (!bannerUrl.isNullOrBlank() &&
(bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://"))
) {
AsyncImage(
model = bannerUrl,
contentDescription = "Article banner",
@@ -79,7 +81,9 @@ fun ArticleHeader(
// Author + metadata row
Row(verticalAlignment = Alignment.CenterVertically) {
if (!authorPicture.isNullOrBlank()) {
if (!authorPicture.isNullOrBlank() &&
(authorPicture.startsWith("https://") || authorPicture.startsWith("http://"))
) {
AsyncImage(
model = authorPicture,
contentDescription = "Author",
@@ -37,6 +37,9 @@ 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,
@@ -60,13 +63,13 @@ fun extractTableOfContents(markdown: String): List<TocEntry> {
}
if (inCodeBlock) return@forEach
val match = Regex("^(#{1,6})\\s+(.+)").find(trimmed)
val match = HEADING_REGEX.find(trimmed)
if (match != null) {
val level = match.groupValues[1].length
val text =
match.groupValues[2]
.trim()
.replace(Regex("#+$"), "")
.replace(TRAILING_HASHES_REGEX, "")
.trim()
if (text.isNotEmpty() && level <= 3) {
entries.add(TocEntry(level = level, text = text, index = headingIndex))
@@ -74,18 +74,20 @@ fun MetadataPanel(
) {
OutlinedTextField(
value = title,
onValueChange = onTitleChange,
onValueChange = { if (it.length <= 256) onTitleChange(it) },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("${title.length}/256") },
)
OutlinedTextField(
value = summary,
onValueChange = onSummaryChange,
onValueChange = { if (it.length <= 1024) onSummaryChange(it) },
label = { Text("Summary") },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("${summary.length}/1024") },
)
OutlinedTextField(
@@ -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.amethyst.commons.compose.highlight
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.foundation.layout.padding
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.text.font.FontStyle
import androidx.compose.ui.unit.dp
/**
* Dialog for creating a NIP-84 highlight from selected text.
* Shows the highlighted text (read-only) and a comment input field.
*/
@Composable
fun HighlightCreator(
highlightText: String,
onConfirm: (highlightText: String, comment: String) -> Unit,
onDismiss: () -> Unit,
) {
var comment by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create Highlight") },
text = {
Column {
Text(
text = "Selected text:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
text = highlightText,
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 8.dp),
maxLines = 6,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text("Comment (optional)") },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(highlightText, comment) }) {
Text("Highlight")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@@ -1,37 +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.amethyst.commons.compose.markdown
import androidx.compose.runtime.Composable
/**
* Interface for platform-specific media rendering in markdown articles.
* Desktop and Android provide different implementations for image loading and navigation.
*/
interface ArticleMediaRenderer {
@Composable
fun renderImage(
url: String,
alt: String?,
)
fun onLinkClick(url: String)
}
@@ -37,7 +37,7 @@ private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning")
@Composable
fun RenderMarkdown(
content: String,
mediaRenderer: ArticleMediaRenderer,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val astNode =
@@ -46,12 +46,12 @@ fun RenderMarkdown(
}
val uriHandler =
remember(mediaRenderer) {
remember(onLinkClick) {
object : UriHandler {
override fun openUri(uri: String) {
val scheme = uri.substringBefore(":").lowercase()
if (scheme in ALLOWED_SCHEMES) {
mediaRenderer.onLinkClick(uri)
onLinkClick(uri)
}
}
}
@@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* 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).
*
@@ -56,6 +58,10 @@ object LongFormPublishAction {
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,
@@ -32,6 +32,14 @@ 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
@@ -47,24 +55,24 @@ object ReadingTimeCalculator {
}
if (inCodeBlock) {
codeWords += trimmed.split("\\s+".toRegex()).count { it.isNotBlank() }
codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() }
return@forEach
}
// Count images
val imageMatches = Regex("!\\[.*?]\\(.*?\\)").findAll(trimmed)
val imageMatches = IMAGE_REGEX.findAll(trimmed)
imageCount += imageMatches.count()
// Strip markdown syntax for word counting
val stripped =
trimmed
.replace(Regex("!\\[.*?]\\(.*?\\)"), "") // images
.replace(Regex("\\[([^]]*)]\\([^)]*\\)"), "$1") // links -> text only
.replace(Regex("[*_~`#>]"), "") // formatting
.replace(Regex("^-\\s+|^\\d+\\.\\s+"), "") // list markers
.replace(Regex("^---+$|^\\*\\*\\*+$"), "") // horizontal rules
.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("\\s+".toRegex()).count { it.isNotBlank() }
proseWords += stripped.split(WHITESPACE_REGEX).count { it.isNotBlank() }
}
// Medium's image time decay: 12 sec first, -1 each, min 3