From 487269e54f6bb7540a5769fbd6f07aefbc2096c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:59:06 +0000 Subject: [PATCH] fix(richtext): drop the blank line under a trailing link card or media A URL at the end of a line followed by "\n\n" rendered as text, the preview card on its own row, then an empty text line, then the next paragraph: the card already ends the row, so the author's blank line doubled the gap. The shared RichTextViewer now drops the one blank paragraph that directly follows a paragraph ending in a block (link preview when canPreview, image/video/PDF/base64 always), matching what GalleryParser already does for standalone image lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A3S1Fq6pvfTyFxwgrTGpVG --- .../commons/richtext/BlockParagraphSpacing.kt | 72 ++++++++++++++++ .../richtext/BlockParagraphSpacingTest.kt | 86 +++++++++++++++++++ .../commons/ui/richtext/RichTextViewer.kt | 4 +- 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/BlockParagraphSpacing.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/BlockParagraphSpacingTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/BlockParagraphSpacing.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/BlockParagraphSpacing.kt new file mode 100644 index 0000000000..ea9c984669 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/BlockParagraphSpacing.kt @@ -0,0 +1,72 @@ +/* + * 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.richtext + +/** + * True when [segment] renders as a full-width block (a link-preview card, an image, a video, + * a PDF) instead of an inline word. Media always draws as a block; a [LinkSegment] only does + * when previews are on, otherwise it is an inline clickable URL. + */ +fun rendersAsBlock( + segment: Segment, + canPreview: Boolean, +): Boolean = + when (segment) { + is ImageSegment, is VideoSegment, is PdfSegment, is Base64Segment -> true + is LinkSegment -> canPreview + else -> false + } + +/** A paragraph that renders as an empty line: no words, or only blank text. */ +fun ParagraphState.isBlankLine(): Boolean = words.all { it is RegularTextSegment && it.segmentText.isBlank() } + +/** + * Drops the one blank line that directly follows a paragraph ending in a block (see + * [rendersAsBlock]). The block already ends its row, so the `\n\n` the author typed to separate + * the URL from the next sentence would otherwise render as an extra empty text line under the + * card, doubling the gap. Standalone image paragraphs get the same treatment in [GalleryParser]. + * + * Returns [paragraphs] itself when nothing is dropped. + */ +fun dropBlankLineAfterBlocks( + paragraphs: List, + canPreview: Boolean, +): List { + var result: ArrayList? = null + + for (i in paragraphs.indices) { + val paragraph = paragraphs[i] + val previous = paragraphs.getOrNull(i - 1) + val drop = + previous != null && + paragraph.isBlankLine() && + !previous.isBlankLine() && + rendersAsBlock(previous.words.last(), canPreview) + + if (drop) { + if (result == null) result = ArrayList(paragraphs.size).apply { addAll(paragraphs.subList(0, i)) } + } else { + result?.add(paragraph) + } + } + + return result ?: paragraphs +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/BlockParagraphSpacingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/BlockParagraphSpacingTest.kt new file mode 100644 index 0000000000..39b8494f55 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/BlockParagraphSpacingTest.kt @@ -0,0 +1,86 @@ +/* + * 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.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class BlockParagraphSpacingTest { + private fun parse(text: String) = RichTextParser().parseText(text, EmptyTagList, null).paragraphs + + @Test + fun blankLineAfterTrailingLinkPreviewIsDropped() { + val paragraphs = parse("See if you can restore your follow list here: https://metadata.nostr.com/#\n\nSomething zeroed it out.") + + // The parser keeps the author's blank line as its own paragraph... + assertEquals(3, paragraphs.size) + assertTrue(paragraphs[0].words.last() is LinkSegment) + assertTrue(paragraphs[1].isBlankLine()) + + // ...which the renderer drops under the card. + val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = true) + assertEquals(2, rendered.size) + assertSame(paragraphs[0], rendered[0]) + assertSame(paragraphs[2], rendered[1]) + } + + @Test + fun blankLineAfterInlineLinkIsKeptWithoutPreviews() { + val paragraphs = parse("here: https://metadata.nostr.com/\n\nSomething zeroed it out.") + + assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = false)) + } + + @Test + fun blankLineAfterTrailingImageIsDropped() { + val paragraphs = parse("look at this https://example.com/cat.jpg\n\nnice, right?") + + assertEquals(3, paragraphs.size) + val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = false) + assertEquals(listOf(paragraphs[0], paragraphs[2]), rendered) + } + + @Test + fun onlyOneBlankLineIsDropped() { + val paragraphs = parse("here: https://metadata.nostr.com/\n\n\nSomething zeroed it out.") + + assertEquals(4, paragraphs.size) + val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = true) + assertEquals(listOf(paragraphs[0], paragraphs[2], paragraphs[3]), rendered) + } + + @Test + fun linkInTheMiddleOfALineKeepsTheBlankLine() { + val paragraphs = parse("here: https://metadata.nostr.com/ is the site\n\nSomething zeroed it out.") + + assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = true)) + } + + @Test + fun blankLineBeforeAPreviewIsKept() { + val paragraphs = parse("Something zeroed it out.\n\nhttps://metadata.nostr.com/") + + assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = true)) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt index 15ef8a4f9e..bf30a9d062 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/richtext/RichTextViewer.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.VideoSegment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment +import com.vitorpamplona.amethyst.commons.richtext.dropBlankLineAfterBlocks /** * Cross-platform rich-text renderer. Owns everything identical on every front @@ -104,9 +105,10 @@ fun RichTextViewer( val renderer = LocalRichTextSegmentRenderer.current val baseStyle = LocalTextStyle.current val paragraphStyle = remember(baseStyle) { baseStyle.copy(lineHeight = 1.3.em) } + val paragraphs = remember(state, canPreview) { dropBlankLineAfterBlocks(state.paragraphs, canPreview) } Column(modifier) { - state.paragraphs.forEach { paragraph -> + paragraphs.forEach { paragraph -> val align = if (paragraph.isRTL) Alignment.End else Alignment.Start if (paragraph is ImageGalleryParagraph) { renderer.Gallery(paragraph, state, Modifier.align(align))