From 318e4250de0c93d993f7a3469c6a3a6540ce1d89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 20:35:37 +0000 Subject: [PATCH 1/5] feat: render nowhere links inline as branded cards Nowhere (https://github.com/5t34k/nowhere) encodes whole mini-sites in a URL fragment, so the OpenGraph preview path returns nothing useful and hostednowhere.com 403s scrapers. Detect URLs on hostednowhere.com and nowhr.xyz that carry a fragment as a NowhereLinkSegment and render them through a NowhereLinkCard that labels the tool type (Event, Store, Message, ...) and opens the URL in the browser on click. --- .../amethyst/ui/components/NowhereLinkCard.kt | 87 +++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 4 + .../commons/richtext/RichTextParser.kt | 27 ++++++ .../richtext/RichTextParserSegments.kt | 7 ++ .../commons/richtext/RichTextParserTest.kt | 29 +++++++ 5 files changed, 154 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NowhereLinkCard.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NowhereLinkCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NowhereLinkCard.kt new file mode 100644 index 000000000..95a64e361 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NowhereLinkCard.kt @@ -0,0 +1,87 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier + +// Maps the first path segment of a nowhere URL to its tool. The nowhere project ships eight +// tools (event, fundraiser, store, petition, message, drop, art, forum) and the URL path +// uses the first letter as a discriminator (e.g. nowhr.xyz/s#... is a store). Unknown codes +// fall through to a generic "Nowhere site" label. +private val nowhereTools = + mapOf( + "e" to "Event", + "f" to "Fundraiser", + "s" to "Store", + "p" to "Petition", + "m" to "Message", + "d" to "Drop", + "a" to "Art", + "fo" to "Forum", + ) + +@Composable +fun NowhereLinkCard(segment: NowhereLinkSegment) { + val uri = LocalUriHandler.current + val toolLabel = segment.tool?.let { nowhereTools[it.lowercase()] } + val title = toolLabel?.let { "Nowhere $it" } ?: "Nowhere site" + + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .clickable { + runCatching { + val target = if (segment.segmentText.contains("://")) segment.segmentText else "https://${segment.segmentText}" + uri.openUri(target) + } + }.padding(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.padding(top = 2.dp)) + + Text( + text = segment.host, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index a9f9c6c16..45db5967b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment +import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment import com.vitorpamplona.amethyst.commons.richtext.ParagraphState import com.vitorpamplona.amethyst.commons.richtext.PdfSegment import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment @@ -481,6 +482,8 @@ private fun RenderWordWithoutPreview( is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) + is NowhereLinkSegment -> ClickableUrl(word.segmentText, word.segmentText) + is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) // Don't offer to pay invoices @@ -530,6 +533,7 @@ private fun RenderWordWithPreview( is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) + is NowhereLinkSegment -> NowhereLinkCard(word) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index abddd2325..513a86a22 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -332,6 +332,10 @@ class RichTextParser { } } + if (urls.withoutScheme.contains(word) || urls.withScheme.contains(word)) { + parseNowhereLink(word)?.let { return it } + } + if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word) if (urls.withScheme.contains(word)) return LinkSegment(word) @@ -529,6 +533,29 @@ class RichTextParser { } fun isUrlWithoutScheme(url: String) = noProtocolUrlValidator.matches(url) + + // Nowhere links (https://github.com/5t34k/nowhere) encode an entire mini-site in the URL + // fragment. Servers never see the fragment, so OpenGraph previews return nothing useful + // (and hostednowhere.com 403s scrapers). Detect them by host + presence of a fragment + // so the renderer can show a branded card instead of falling through to LoadUrlPreview. + private val nowhereHosts = listOf("nowhr.xyz", "hostednowhere.com") + + fun parseNowhereLink(word: String): NowhereLinkSegment? { + val afterScheme = + when { + word.startsWith("https://", ignoreCase = true) -> word.substring(8) + word.startsWith("http://", ignoreCase = true) -> word.substring(7) + else -> word + } + val slash = afterScheme.indexOf('/') + if (slash < 0) return null + val host = afterScheme.substring(0, slash).lowercase() + if (host !in nowhereHosts) return null + val hash = afterScheme.indexOf('#', slash) + if (hash < 0) return null + val pathSegment = afterScheme.substring(slash + 1, hash).substringBefore('/').takeIf { it.isNotEmpty() } + return NowhereLinkSegment(word, host, pathSegment) + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index c0aa426c0..e03b8a6dd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -158,6 +158,13 @@ class SchemelessUrlSegment( segment: String, ) : Segment(segment) +@Immutable +class NowhereLinkSegment( + segment: String, + val host: String, + val tool: String?, +) : Segment(segment) + @Immutable class RegularTextSegment( segment: String, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt index 745576d5f..9a5ce0bb0 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt @@ -4400,6 +4400,35 @@ class RichTextParserTest { } } + @Test + fun testNowhereLinkInTheMiddleOfAPost() { + val text = "Check out my store https://nowhr.xyz/s#bYxNC4JAEEDRX7LbpcsirR9F1MW0jRDLPsEOMZaHinJTCzOl right now" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val nowhere = state.paragraphs.flatMap { it.words }.firstOrNull { it is NowhereLinkSegment } as? NowhereLinkSegment + assertEquals("https://nowhr.xyz/s#bYxNC4JAEEDRX7LbpcsirR9F1MW0jRDLPsEOMZaHinJTCzOl", nowhere?.segmentText) + assertEquals("nowhr.xyz", nowhere?.host) + assertEquals("s", nowhere?.tool) + } + + @Test + fun testHostedNowhereSchemelessLink() { + val text = "see hostednowhere.com/m#bYxNAB end" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val nowhere = state.paragraphs.flatMap { it.words }.firstOrNull { it is NowhereLinkSegment } as? NowhereLinkSegment + assertEquals("hostednowhere.com/m#bYxNAB", nowhere?.segmentText) + assertEquals("hostednowhere.com", nowhere?.host) + assertEquals("m", nowhere?.tool) + } + + @Test + fun testPlainNowhereHostWithoutFragmentStaysAsLink() { + // No fragment => not a nowhere site, fall through to LinkSegment. + val text = "Visit https://hostednowhere.com/manage to manage" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val nowhere = state.paragraphs.flatMap { it.words }.firstOrNull { it is NowhereLinkSegment } + assertEquals(null, nowhere) + } + private fun printStateForDebug(state: RichTextViewerState) { state.paragraphs.forEach { paragraph -> paragraph.words.forEach { seg -> From a1e37109c364ade79ffbc0e47c371041113d65d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 21:01:53 +0000 Subject: [PATCH 2/5] test: cover ~5KB nowhere-link fragments end-to-end through the parser --- .../commons/richtext/RichTextParserTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt index 9a5ce0bb0..f0f8ff5a9 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt @@ -4420,6 +4420,23 @@ class RichTextParserTest { assertEquals("m", nowhere?.tool) } + @Test + fun testNowhereLinkWithLargeBase64UrlFragment() { + // Real nowhere links can be kilobytes long (a full store catalogue or forum thread + // base64url-encoded). Verify both UrlDetector capture and our classifier survive a + // ~5KB fragment containing every base64url-legal character (A-Z, a-z, 0-9, -, _). + val alphabet = ('A'..'Z').joinToString("") + ('a'..'z').joinToString("") + ('0'..'9').joinToString("") + "-_" + val payload = (0 until 80).joinToString("") { alphabet } + val url = "https://nowhr.xyz/s#$payload" + val text = "look at this big store $url and reply" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val nowhere = state.paragraphs.flatMap { it.words }.firstOrNull { it is NowhereLinkSegment } as? NowhereLinkSegment + assertEquals(url, nowhere?.segmentText) + assertEquals(payload.length, nowhere?.segmentText?.substringAfter('#')?.length) + assertEquals("nowhr.xyz", nowhere?.host) + assertEquals("s", nowhere?.tool) + } + @Test fun testPlainNowhereHostWithoutFragmentStaysAsLink() { // No fragment => not a nowhere site, fall through to LinkSegment. From ca9f7233f2ec0ecd796812fe21097afbc3ec7108 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 21:16:45 +0000 Subject: [PATCH 3/5] test: verify real-world 5.5KB nowhr.xyz/s store link round-trips through the parser --- .../richtext/RichTextParserNowhereJvmTest.kt | 55 +++++++++++++++++++ .../richtext/realNowhereStoreUrl.txt | 1 + 2 files changed, 56 insertions(+) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserNowhereJvmTest.kt create mode 100644 commons/src/jvmTest/resources/richtext/realNowhereStoreUrl.txt diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserNowhereJvmTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserNowhereJvmTest.kt new file mode 100644 index 000000000..27e3f61e8 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserNowhereJvmTest.kt @@ -0,0 +1,55 @@ +/* + * 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.assertTrue + +class RichTextParserNowhereJvmTest { + @Test + fun realWorldNowhereStoreLinkRoundtripsThroughTheParser() { + // Verbatim from a real Nostr note ("Works, thanks!\n\nhttps://nowhr.xyz/s#..."): a + // published nowhr.xyz store catalogue with a ~5KB base64url fragment. Kept as a + // resource so the URL isn't subject to source-editor truncation. + val url = + this::class.java + .classLoader!! + .getResourceAsStream("richtext/realNowhereStoreUrl.txt")!! + .bufferedReader() + .readText() + .trim() + + assertTrue(url.startsWith("https://nowhr.xyz/s#"), "resource lost its prefix") + assertTrue(url.length > 5000, "resource shorter than expected: ${url.length}") + + val text = "Works, thanks!\n\n$url" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + val nowhere = state.paragraphs.flatMap { it.words }.firstOrNull { it is NowhereLinkSegment } as? NowhereLinkSegment + + assertEquals(url, nowhere?.segmentText) + assertEquals("nowhr.xyz", nowhere?.host) + assertEquals("s", nowhere?.tool) + assertTrue(state.urlSet.withScheme.contains(url), "url not in detected withScheme set") + } +} diff --git a/commons/src/jvmTest/resources/richtext/realNowhereStoreUrl.txt b/commons/src/jvmTest/resources/richtext/realNowhereStoreUrl.txt new file mode 100644 index 000000000..8e4d1005f --- /dev/null +++ b/commons/src/jvmTest/resources/richtext/realNowhereStoreUrl.txt @@ -0,0 +1 @@ +https://nowhr.xyz/s#7Vtrjx3HcYW-zq9ob77EgIbb74cRCZD1MGxINiEriQUIMekrmlJyZRLiWpIJ_XjXOafmLsWVX6GABEgk8vbu3J7u6nqeqmp-kf_zrccPf__-x-989OTJe7_7fcrpV8_u_-aX5cPPPn6U3v3t7372-PdffXx-67dPPnjr56-fPvvy8-fPn189ePbea_d-8sn5R-_86u2PPr7_bnj21eNw_19_-v7P3w5X-_X1v5e3r6_f-eid8Ot_-1nIMaa4Yr2-fveXV1u4-uzm5ulPrq-__vrre1-Xe0--fHz90YfXmHT94btv7_bGfnnDlri2pVO89-nNp1dvfnLGPg9-8dZrb1yle_EqfPPF-Q_P3vieFW2FiDdtv2_bU0yPa96L_O_pzVV48PwXdx9-9fmjr3_65Js3rmKI4YXvXvzZFnz65aNnj7786tFbz54-Ot18-PDm8ydvXH3zweeffmx_w4OH918DpV88unn46cObh29uD370s8e_-1N4-uTmy4enRyHdS_318OC9as_uP7p59GX49aPz5394bD_g3Hb4tLZPztf_8f5_vWbr2HnbMFq__fA3r_1zdCpef4GiH4cHf7zBV0lf7cdPP77avv3jb-zVf3LSw7f3_2S_PhjvkMKnD28-C5--cfVBawNn7K2FUwz7DHsLe8Lf5GM3huz4ZYa8wl5yyIlfpzi3PZVQEybkUEsK9hlzSPqs9uawryp2CHslN8OZP-D_zD1tg73bzxu2s0dGQ9TWwyloz6-uv0NzLRU0V3t_x8q5h2Lr51Bsei2hdFBcZujB6MRvuQabtNujbETXHsbdNYut2Vq2NQuItlPsJWKBvdphC4kBsVVDmoOEggNGe688ZGrVzkzuYMQTTRs45V4KWbWTQntcNGjKsvfI0aX5RsOes5hQsZPWyHlISstYGKAzGuZlF7wwNw3cI4MJGWIJ2UgoA-qGb3LEghxIJ7YsPN4Kvlbc9gaBY5mYpRg5Nu5pjDFas525Lr6YeLwcpS7HyXqoAV9uPFsCo-0xdo46CUi3JalXM4BBQ2slqd4S13ywt01bJmb4XlRJbtVCJRsGyCJxmbIrNq9psDnSdKxubMr83U7e-A4VqoUeXGslxKjFSUIU4Uky02_YL28Qwpn8dFHZHKmT63OOVVsvyBf0Qbco46Gx204bGbzEZ5sQIZw9m-CgOqbKmVLVn0xNhUpTNbFLB90VDzdYBcTZJJxGKZLmCQmagteiXwrMFqu0CErAAUjkOGg2ohbdgVEghcQI3q3gSlvkKWBpTb9BVbPUmWoDOzWz3IxvDVvaH46gINI-W5WZxoJjuz7WScknbCyh0DhwPppiwzaY4r4ju72OMClB-bMkVXM3V12ZJoUPu0uyx3n4uxgm1bTbmAo5slORwWRIJ1EuY0gJk7ydadSQJdKE4FZELo-5OBdy1MmgJokmQsowJ3Vpkz3c9iw3WEmSubdJnnYtQQ_SZT_NzaS572grnKlXBV6wtY2ftI7bQc_-wjf9-AQzLEqcbFygz3g13AVNuRj6nSzJ9-zmAg22H5I-oUl2phpolgX7GGc3EF1gM1QpRIOhoDDJkwU9Who9EhVuV6X4-RBvKZuO7hrrStJdYfgD7XzwD08Jg-dy8-AblQeqkOWzGVcwpKxYNkKWDWDR5a8X13wpcMEX8oO7qdXCOeTmKRczgeSOViohFu3ULXfeWChByDYLm9gAP2XsO1PN15DCNDqXFOUvB2WRepH6ko49i10aS_AD5uon7lG-rwT3PaSxw6YvwSAqcolpU4oLdkBoG92k6RpkFiGW1BPDX4feZfpoftYiBTCv0rDyqLAqOvECM60ZdgOmaYG9L-k8FU065dw6DKVRJFPoQydseE-RL3vgzR49p8faNCQhX6y4th6q5QMfrO3vmvV_dq0swUjT4BU3CG4pdMOaBVuk1hzoWhmIDmy0H1izuJvzV8om1cuH2xYUZOhKt6ihQCnGQbMb9OEoZNfwyHISjZ9dVIcLrur62_nOBfce41SIr-7b85jStYJQD6zkXIDj0g_98oBI2VzSOcvhZ8490RI3rtJccZPwBV2M6LLDk-r4wsmdTCzLyEojNNZsGZHhZCsNWAackLBS4vICQ4STB-QAYeZjhy1A39jlOPJ2ou9MOlfXQO-HjUcNDn-SG9NMAkBRZ56CCMKB24WH0hX7ewg5-w9zmk9DQJ7ujfZj7HJoxi3DvYiD9M6A3q55yd2v0o_LDww8dCvQmYo_BVpLX2b-WBChdWqyodaM78kA4q2w3LsRQCUBSARZaTmJL8KYjPMM4S1IkelxkGpg73ZEi-IAiRZhYx5HViINLZuinxIN4DvkQ-HIvijtogiRhR4N04FPFRqSCWs7Y8xMjhiG7HFmKc9MVKoSBBRwqqb8psnBTlpwm246jCeL2Cq5VSgwOeZwVzyD0kIGbntzKr7lrKB7zkTWRLNVhosxN-V-ngPmdRlMVQVNi76r7fisVHLTbyxTCwipJh8aySUFKC5ECB28BNbSoMgva-cRNvJ4yZdlT8aM3hVeIIBD7dySn5hKGGPPM_2VWQs5Y_Q7DW47gdAQ0Md0wFDRWWy5PZFSNyWultBtGWDYLDgCliPSY02BQZkasyHBQOxCIAAixORTypVspK8DmTtw1U7M2tMkmChdxpZKq7dppL0jq6M6nKJblklf3ooTHXfQUxVY0MlwntxsgtIyL6zl4LDnJ-KRReRILNqn6XXl2esh4eT8yExUMy0KgmrxGMzaDM-bwFvZZoXCn2rHMCvsdMI0tGsjfJgAWPZJ8gpTT6ox0DxnJBi5GcolvsCMtJ0voFgh7Sjyzp35GjxnMLTDNEWpBeQER2cmZ7ydwNI402lmIii4YqQtCBpwGyMzSWhNrrd5gguaT9QcGDBzt9AlZSobl0w8BdQCiROyi4zkpMpbHnWbF6oKWzpy0xS9qOBw8IiQMmsiWGpKoBER0stBe3xcm4BzUqZ8JB4OhiG74U54eOis3GppjqICtYXFkul2Sp8EXWeKSkIhDzlZ1z97Qkc-oASDmhUP_NA2pq2sqDDYKr1yYGIkLHC-4ojcg2UpPGCy62nc4PnzRsBKDy2FIbcyvczF0013yXKFM7DSgBQGMQFWZRZvQDYxhZbHaTxpFt-9VLZP5w7LOcor4xG5oh8ZiFpLXeAUkyUsAEdHbS2Z5KfWqeGNj1tDZEmt0pbIUjlzEaiaWw40XCb9R0xDMKnIeCEYMMvGBb7vnmAjqG1erzkKENNTgyGtWZI8xcVhzy55uh0oRueLFXUAiEHBU2jOGcQIw5MTAyWmEJxGjeJsSghlJvNuZbPPs1dyzH4Issx0-EqtYlrlSQfrPN1VFegxhXmLWkra4FDi0PEQjREkpwNO_eCJVGhSizJp3iXLbywJu4LrgBfKm4ZOEhkToFsR5UqEGGGWwWw7EBMUqaTYQNhhiSTJnEvlNKmhFwqrBMGqV1LimDzopehlSXpYDGmL9LOefSoqukAPyJGObLmHIwdUblamMlfqoRHVJH6WAOQIlqTdCcxRgcG5igs4KzRnesWu5A_cyBb_AjnBehcTDOJtvNRoqnl6NpG0nbY1F0oHDd1HOhy3CePlL3h7ErgxlYU-dre8nOT7hbUVHaPHg9KEnWHGmEkUkRPMQoiD7-KBrXxGpY8YDcsD2DP3z3KtmV4MZ9lG0VtdxRDis0y_klkVIbtW1Vvw6mfm-2DEmojKjTJO2TxxZ5XEuFEJkDtsth3CTyxvCQO558r1cIzryMiiKqwbp9F_eJqeqO_K_HcvTXollyqLQeovhVh8CFPcGKoSfz_sY0-XRCHS7oR4q3IR7aMEM6n-ofRyo774m55yHOFDyk0SqBHj4oqOckwMt4lrVHZ7m7Z-_6zwd836_7X-J9c6wMC-jmDKFDhROTEQlzJpLI7HUSlfF6tInS4oEsocaAnqs1QxxytViVOj9lk-xNyLn5NV98lC-XmHewmTS50GgdNQ9Q5BBV6wXQrS3CpL7aOquUc7xRXelkekUHbcVBNn1liNNPs_EueOyk9lqXN5AuYwhP4azw1eybxweKYL5FQQ6Eseqb18DANKXtVTHmGBDwiCw3Kuz8Kjr3qUAp1v7Pwc4nJ2q8LAcNE97Z3isPcPlnpguT0PX3g15K8YezuEF1VMjEr5yhYv6NvmY6lE1p8cktAb08urApFU71Sulrqqmwy4YyMWhG8_fEsh2V7NhjNftoGtDumabE5etzpw7SV6ImFWk4EpqQCFO3yslLHMBDiYi51LhlOhZk_zWclWFxBL1MCuFbA7kCprQMgPWbpmOh4dCuDYYGmRap3YolAewBL0uE1oEpP9UlSqSKrHD2f14qkpJKxWkT-ePOXfGRK5H6tHt1o-JP9RSKwFo064ACqncAyVC59D9ay-oKYTi1ERkEzUPsVcGs5RM1quloyMsOAByxhaD4hk40AZlYjtyhD6I1cVF0wLwauFhmEi-piUcFWhJXsIas-3L3BQU_UXNGn3QlBy8NDYEpJ0D-vCY6PAcmMoTEXFDxYNy6lM3dYKXhnJXiVgD2NJ53Q617d0VDnV14Uusv4VCdWygNBZVRZmxyyHGM8ru3tMlw_qzU2QFQi_dHFrq0r5z91biaobVFUkoJ4nHHnhcPuKyk9X8t6YAyVWv3JsivJnFHakg-asCIVXHEFVFJVMHO2cbrFPLl5JMSeDGIDEybJs5j3nPqi9rI3QbIxirNtkfu3iKI6_jnU3JcDuMJpyqED2frebS1NUVffEhkZTclem93YG2kAsNhqtC8gRn5Xr7obIKhsMWAa1Q9xkYNGjORBHOg2N64CDayC3pMKxdAUoTz2HykQ1pf_w-fNHwUoPLYUht8K9zMXTTXfJcoUzsNKAFAYxAVZlFm9ANjGFlsdpPGkW373UNg-nDks5yiviEbmiHxlIWkpd4BSTJSwAR0VtL5jkp9ap4Y2PW0NkSa3SlshSOXMRqJpbDjRcJv1HTEEwqch4IRgwy8YFvu-eYCOobV4vOAoQ01OBoa1Zkjzlx2DvRGNwsVfSOoFCduUERSe43xdHJiYGCMzQS3Qc5LtIRcsyAtBuoBM5ndp3FbgyHpEbmiHxlIWkpd4BSTJSwAR0VtL5jkp9ap4Y2PW0NkSa3SlshSOXMRqJpbDjRcJv1HTEEwqch4IRgwy8YFvu-eYCOobV4vOAoQ01OBoa1Zkjzlx2DvRGNwsVfSOoFCduUERSe43xdHJiYGCMzQS3Qc5LtIRcsyAtBuoBM5ndp3FbgyHpEbmiHxlIWkpd4BSTJSwAR0VtL5jkp9ap4Y2PW0NkSa3SlshSOXMRqJpbDjRcJv1HTEEwqch4IRgwy8YFvu-eYCOobV4vOAoQ01OBoa1Zkjzlx2DvRGNwsVfSOoFCduUERSe43xdHJiYGCMzQS3Qc5LtIRcsyAtBuoBM5ndp3FbgyHpEbmiHxlIWkpd4BSTJSwAR0VtL5jkp9ap4Y2PW0NkSa3SlshSOXMRqJpbDjRcJv1HTEEwqch4IRgwy8YFvu-eY From a2da9ae2c67307944a9ed03963d183c689b03104 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:33:23 +0000 Subject: [PATCH 4/5] i18n: extract NowhereLinkCard tool labels to string resources --- .../amethyst/ui/components/NowhereLinkCard.kt | 31 ++++++++++--------- amethyst/src/main/res/values/strings.xml | 11 +++++++ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NowhereLinkCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NowhereLinkCard.kt index 95a64e361..a4abd49ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NowhereLinkCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NowhereLinkCard.kt @@ -33,30 +33,31 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.innerPostModifier -// Maps the first path segment of a nowhere URL to its tool. The nowhere project ships eight -// tools (event, fundraiser, store, petition, message, drop, art, forum) and the URL path +// Maps the first path segment of a nowhere URL to a localized label. The nowhere project ships +// eight tools (event, fundraiser, store, petition, message, drop, art, forum) and the URL path // uses the first letter as a discriminator (e.g. nowhr.xyz/s#... is a store). Unknown codes -// fall through to a generic "Nowhere site" label. -private val nowhereTools = +// fall through to the generic "Nowhere site" label. +private val nowhereToolLabels = mapOf( - "e" to "Event", - "f" to "Fundraiser", - "s" to "Store", - "p" to "Petition", - "m" to "Message", - "d" to "Drop", - "a" to "Art", - "fo" to "Forum", + "e" to R.string.nowhere_link_card_event, + "f" to R.string.nowhere_link_card_fundraiser, + "s" to R.string.nowhere_link_card_store, + "p" to R.string.nowhere_link_card_petition, + "m" to R.string.nowhere_link_card_message, + "d" to R.string.nowhere_link_card_drop, + "a" to R.string.nowhere_link_card_art, + "fo" to R.string.nowhere_link_card_forum, ) @Composable fun NowhereLinkCard(segment: NowhereLinkSegment) { val uri = LocalUriHandler.current - val toolLabel = segment.tool?.let { nowhereTools[it.lowercase()] } - val title = toolLabel?.let { "Nowhere $it" } ?: "Nowhere site" + val titleRes = segment.tool?.lowercase()?.let { nowhereToolLabels[it] } ?: R.string.nowhere_link_card_generic Column( modifier = @@ -69,7 +70,7 @@ fun NowhereLinkCard(segment: NowhereLinkSegment) { }.padding(12.dp), ) { Text( - text = title, + text = stringRes(titleRes), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d4dcea91c..39d579e2a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2882,4 +2882,15 @@ You have already posted %1$d events of this kind today (limit %2$d). You don\'t pass this community\'s web-of-trust requirements. The latest community rules document was rejected as stale. + + + Nowhere site + Nowhere Event + Nowhere Fundraiser + Nowhere Store + Nowhere Petition + Nowhere Message + Nowhere Drop + Nowhere Art + Nowhere Forum From b16d44721c99c53becb80715553b85cb602e3826 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:54:35 +0000 Subject: [PATCH 5/5] perf(richtext): collapse nowhere-link classifier into the existing URL branches --- .../amethyst/commons/richtext/RichTextParser.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 513a86a22..05399c493 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -332,13 +332,15 @@ class RichTextParser { } } - if (urls.withoutScheme.contains(word) || urls.withScheme.contains(word)) { + if (urls.withoutScheme.contains(word)) { parseNowhereLink(word)?.let { return it } + return SchemelessUrlSegment(word) } - if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word) - - if (urls.withScheme.contains(word)) return LinkSegment(word) + if (urls.withScheme.contains(word)) { + parseNowhereLink(word)?.let { return it } + return LinkSegment(word) + } if (urls.emails.contains(word)) return EmailSegment(word)