diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index caf3ea93d8..8c6a05081f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -27,6 +27,7 @@ import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.debugState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -153,6 +154,18 @@ fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) || fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=") +/** + * A markdown link target that is a bare hashtag fragment, e.g. the `#NostrMultiplayerGames` in + * `[Games](#NostrMultiplayerGames)`. Returns the tag without the leading `#`, or null when the + * uri is anything else (full URLs with anchors don't start with `#`). Reuses the same character + * class that linkifies #hashtags in plain text, so validity matches how tags parse everywhere else. + */ +fun fragmentHashtagOrNull(uri: String): String? { + val match = RichTextParser.hashTagsPattern.matchEntire(uri) ?: return null + if (!match.groups[2]?.value.isNullOrEmpty()) return null + return match.groups[1]?.value +} + fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=") fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=") @@ -199,6 +212,9 @@ fun uriToRoute( if (isHashtagRoute(uri)) { return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase()) } + fragmentHashtagOrNull(uri)?.let { + return Route.Hashtag(it.lowercase()) + } if (isUrlRoute(uri)) { return urlRoute(uri) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index 4ff978ce84..073889325a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayFullNote import com.vitorpamplona.amethyst.ui.components.DisplayUser import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.fragmentHashtagOrNull import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink @@ -74,7 +75,10 @@ class MarkdownMediaRenderer( title: String?, uri: String, ): Boolean = - if (canPreview && uri.startsWith("http")) { + if (fragmentHashtagOrNull(uri) != null) { + // claims [text](#hashtag) links so renderLinkPreview can retarget them to the hashtag feed + true + } else if (canPreview && uri.startsWith("http")) { title.isNullOrBlank() || title == uri } else { false @@ -115,6 +119,12 @@ class MarkdownMediaRenderer( uri: String, richTextStringBuilder: RichTextString.Builder, ) { + val fragmentHashtag = fragmentHashtagOrNull(uri) + if (fragmentHashtag != null) { + renderAsCompleteLink(title ?: uri, "nostr:hashtag?id=$fragmentHashtag", richTextStringBuilder) + return + } + val content = parser.createMediaContent(uri, imetaByUrl, startOfText, callbackUri) if (canPreview) { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt new file mode 100644 index 0000000000..798f847409 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt @@ -0,0 +1,70 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class UriToRouteTest { + private val account = mockk() + + @Test + fun fragmentHashtagRoutesToHashtagFeed() { + assertEquals(Route.Hashtag("nostrmultiplayergames"), uriToRoute("#NostrMultiplayerGames", account)) + } + + @Test + fun fragmentHashtagKeepsUnicodeTags() { + assertEquals(Route.Hashtag("日本語"), uriToRoute("#日本語", account)) + } + + @Test + fun invalidFragmentHashtagsAreNotRoutes() { + assertNull(uriToRoute("#", account)) + assertNull(uriToRoute("# ", account)) + assertNull(uriToRoute("#two words", account)) + } + + @Test + fun fullUrlsWithAnchorsAreNotHashtagRoutes() { + assertNull(uriToRoute("https://example.com/page#anchor", account)) + } + + @Test + fun hashtagQueryRoutesStillWork() { + assertEquals(Route.Hashtag("foo"), uriToRoute("hashtag?id=foo", account)) + assertEquals(Route.Hashtag("foo"), uriToRoute("nostr:hashtag?id=foo", account)) + } + + @Test + fun fragmentHashtagOrNullExtractsTheTag() { + assertEquals("NostrMultiplayerGames", fragmentHashtagOrNull("#NostrMultiplayerGames")) + assertNull(fragmentHashtagOrNull("#")) + assertNull(fragmentHashtagOrNull("##double")) + assertNull(fragmentHashtagOrNull("#tag!")) + assertNull(fragmentHashtagOrNull("https://example.com/page#anchor")) + assertNull(fragmentHashtagOrNull("nostr:hashtag?id=foo")) + } +}