diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt new file mode 100644 index 0000000000..595e395bbe --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt @@ -0,0 +1,76 @@ +/* + * 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 + +/** + * A quoted host name (`this bridge-relay "relay.momostr.pink" doesn't appear`) used to be + * detected with the opening quote glued onto it, so the rendered link read + * `"relay.momostr.pink` and pointed at a host that doesn't exist. Quotes are not host + * characters, so they must be left in the surrounding text on both sides. + */ +class RichTextParserQuotedUrlTest { + private fun segmentsOf(text: String) = + RichTextParser() + .parseText(text, EmptyTagList, null) + .paragraphs + .flatMap { it.words } + + @Test + fun quotedSchemelessUrlKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed", + ).filterIsInstance() + + assertEquals(listOf("relay.momostr.pink"), segments.map { it.segmentText }) + } + + @Test + fun quotedUrlWithSchemeKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf( + "the docs are at \"https://example.com/some/page?a=b\" if you need them", + ).filterIsInstance() + + assertEquals(listOf("https://example.com/some/page?a=b"), segments.map { it.segmentText }) + } + + @Test + fun quotedRelayUrlKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf("add \"wss://relay.momostr.pink\" to your list") + .filterIsInstance() + + assertEquals(listOf("wss://relay.momostr.pink"), segments.map { it.segmentText }) + } + + @Test + fun apostrophesInProseDontCreateLinks() { + val segments = segmentsOf("it doesn't appear until you go into the authors' accounts") + + assertEquals(emptyList(), segments.filterIsInstance()) + assertEquals(emptyList(), segments.filterIsInstance()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index f1444b88cc..c0c858b47d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -349,6 +349,34 @@ class UrlParserTest { Urls(withScheme = setOf("https://test.com")), ) + @Test + fun testQuotedRelayName() = + test( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed at all", + Urls(withoutScheme = setOf("relay.momostr.pink")), + ) + + @Test + fun testSingleQuotedRelayName() = + test( + "It seems like this bridge-relay 'relay.momostr.pink' doesn't appear in the feed at all", + Urls(withoutScheme = setOf("relay.momostr.pink")), + ) + + @Test + fun testQuotedUrlWithScheme() = + test( + "the docs are at \"https://example.com/some/page?a=b\" if you need them", + Urls(withScheme = setOf("https://example.com/some/page?a=b")), + ) + + @Test + fun testQuotedRelayUrl() = + test( + "add \"wss://relay.momostr.pink\" to your list", + Urls(relayUrls = setOf("wss://relay.momostr.pink")), + ) + @Test fun testBlossom() { val blossom = "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index cf2e8a5f4c..ae05dee3da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -91,7 +91,17 @@ class UrlDetector( while (!reader.eof()) { // read the next char to process. when (val curr = reader.read()) { - ' ' -> { + // A quote can never be part of a host name, so a note that wraps a bare domain in + // quotes (`the relay "relay.example.com" is down`) must not glue the opening quote + // onto the url. Quotes therefore end the current token exactly like a space does. + // Kept as literals (instead of `in QUOTES`) so this hot branch stays a tableswitch; + // the list must mirror [QUOTES], which the punctuation round-trip test enforces. + ' ', '"', '\'', '`', + '\u00AB', '\u00BB', + '\u2018', '\u2019', '\u201A', '\u201B', + '\u201C', '\u201D', '\u201E', '\u201F', + '\u2039', '\u203A', + -> { // space found; if we have a scheme, attempt to read the domain before resetting if (buffer.isNotEmpty() && hasScheme) { reader.goBack() @@ -719,6 +729,30 @@ class UrlDetector( "$it//" } + /** + * Quotes never belong to a url. The opening side is already dropped when [readDefault] + * breaks the token on them, but the closing side can still be swallowed by the path, + * query or fragment readers (which only stop on a space), so it is stripped on [readEnd]. + */ + val QUOTES = + setOf( + '"', + '\'', + '`', + '\u00AB', + '\u00BB', + '\u2018', + '\u2019', + '\u201A', + '\u201B', + '\u201C', + '\u201D', + '\u201E', + '\u201F', + '\u2039', + '\u203A', + ) + val CANNOT_BEGIN_URLS_WITH = setOf( ',', @@ -734,7 +768,7 @@ class UrlDetector( '\u3002', '\uFF0E', '\uFF61', - ) + ) + QUOTES val CANNOT_END_URLS_WITH = setOf( @@ -752,6 +786,6 @@ class UrlDetector( '\u3002', '\uFF0E', '\uFF61', - ) + ) + QUOTES } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index 76ffc005e8..4a4c45fdc8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -882,6 +882,38 @@ class UriDetectionTest { runTest("visit example.com,", "example.com") } + @Test + fun testQuotedUrlsDropTheQuotes() { + // a bare domain wrapped in quotes must not glue the opening quote onto the host. + runTest( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed", + "relay.momostr.pink", + ) + + UrlDetector.QUOTES.forEach { quote -> + runTest("$quote relay.momostr.pink $quote", "relay.momostr.pink") + runTest("${quote}relay.momostr.pink$quote", "relay.momostr.pink") + runTest("${quote}wss://relay.momostr.pink$quote", "wss://relay.momostr.pink") + + // the closing quote is swallowed by the path/query/fragment readers, which only stop + // on a space, so it has to be stripped at the end of the url instead. + runTest("say ${quote}https://example.com/foo$quote out", "https://example.com/foo") + runTest("say ${quote}https://example.com/foo?a=b$quote out", "https://example.com/foo?a=b") + runTest("say ${quote}https://example.com/foo#b$quote out", "https://example.com/foo#b") + + // a quote glued to the previous word is a boundary too: `href="www.google.com"`. + runTest("href=${quote}www.google.com$quote", "www.google.com") + } + } + + @Test + fun testApostropheStillEndsTheHostForGroupInviteLinks() { + // NIP-29 invite links are `'`; UrlParser recovers the suffix from the + // content, so the detector must keep reporting the relay url alone. + runTest("wss://relay.example.com'groupid", "wss://relay.example.com") + runTest("wss://relay.example.com'groupid?code=xyz", "wss://relay.example.com") + } + private fun runTest( text: String, vararg expected: String?,