mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+11
-1
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user