feat: route [text](#hashtag) markdown links to the hashtag feed (#1564)

Markdown links whose target is a bare hashtag fragment, e.g.
[Nostr Multiplayer Games](#NostrMultiplayerGames), did nothing when
tapped: uriToRoute had no branch for them, so the tap fell through to
the system uri handler, which silently fails on a bare fragment.

- uriToRoute now resolves a bare #tag target to Route.Hashtag, reusing
  RichTextParser.hashTagsPattern so tag validity matches how #hashtags
  linkify in plain text (including unicode tags). Full URLs with
  anchors are unaffected.
- MarkdownMediaRenderer rewrites the link destination to
  nostr:hashtag?id=<tag> (mirroring renderHashtag) while keeping the
  custom link text, so the tap flows through the same route string as
  a plain #hashtag.

Read side only: the composer still never emits this syntax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D382kMNdcmMLZrwKeZAPTY
This commit is contained in:
Claude
2026-07-10 22:36:52 +00:00
parent 4d16d6e38f
commit 6d4262bc2a
3 changed files with 97 additions and 1 deletions
@@ -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)
}
@@ -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) {
@@ -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<Account>()
@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"))
}
}