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:
+6
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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))
|
||||
|
||||
+4
-2
@@ -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(
|
||||
|
||||
-94
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
-37
@@ -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)
|
||||
}
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+16
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user