From 318e4250de0c93d993f7a3469c6a3a6540ce1d89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 20:35:37 +0000 Subject: [PATCH] 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 ->