Merge pull request #2971 from vitorpamplona/claude/add-nowhere-links-LPMnM

Add Nowhere link detection and branded card rendering
This commit is contained in:
Vitor Pamplona
2026-05-18 19:20:34 -04:00
committed by GitHub
8 changed files with 243 additions and 2 deletions
@@ -0,0 +1,88 @@
/*
* 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.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 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 the generic "Nowhere site" label.
private val nowhereToolLabels =
mapOf(
"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 titleRes = segment.tool?.lowercase()?.let { nowhereToolLabels[it] } ?: R.string.nowhere_link_card_generic
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 = stringRes(titleRes),
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,
)
}
}
@@ -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)
+11
View File
@@ -2893,4 +2893,15 @@
<string name="community_rules_violation_quota_exceeded">You have already posted %1$d events of this kind today (limit %2$d).</string>
<string name="community_rules_violation_wot_gate_failed">You don\'t pass this community\'s web-of-trust requirements.</string>
<string name="community_rules_violation_stale_rules">The latest community rules document was rejected as stale.</string>
<!-- Nowhere links (hostednowhere.com / nowhr.xyz). The site name "Nowhere" is a proper noun, do not translate. -->
<string name="nowhere_link_card_generic">Nowhere site</string>
<string name="nowhere_link_card_event">Nowhere Event</string>
<string name="nowhere_link_card_fundraiser">Nowhere Fundraiser</string>
<string name="nowhere_link_card_store">Nowhere Store</string>
<string name="nowhere_link_card_petition">Nowhere Petition</string>
<string name="nowhere_link_card_message">Nowhere Message</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Art</string>
<string name="nowhere_link_card_forum">Nowhere Forum</string>
</resources>
@@ -332,9 +332,15 @@ class RichTextParser {
}
}
if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word)
if (urls.withoutScheme.contains(word)) {
parseNowhereLink(word)?.let { return it }
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)
@@ -529,6 +535,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)
}
}
}
@@ -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,
@@ -4400,6 +4400,52 @@ 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 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.
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 ->
@@ -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")
}
}
File diff suppressed because one or more lines are too long