mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
fix(search): stop typing an author filter from navigating away mid-query
`directRouteResolver` auto-navigated on any nip19 code found *anywhere* in the box. `Nip19Parser` extracts a code from inside a string, which is right for spotting a mention in a note and wrong for reading a search field — so once `from:`/`to:` existed, typing `from:npub1…` looked exactly like pasting a profile and threw the reader onto that person's page halfway through writing a filter. The profile/post auto-navigation is gone. Invite links stay: they cannot be typed by accident (both need a URL carrying `/invite/`, which no token produces) and they open a redeem flow rather than a profile or a post. Deleting it alone would have regressed pasting, though — the old code read "navigate on hit without displaying results", so the jump *was* the only path to a pasted code, and a paste of somebody the cache had never seen would have surfaced nothing at all. A whole-input code now resolves into the results list instead: consumed into the cache and offered as an ordinary user or note row. Still one tap away, but the reader chooses when to leave. `wholeInputNip19` in commons is the rule that separates the two cases — a paste is the only thing in the box, a token always carries its prefix. Desktop's `parseSearchInput` had the same extract-from-anywhere behaviour and now goes through it too, so a `from:` filter no longer produces a direct-lookup row for somebody the reader was merely filtering by. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
This commit is contained in:
+53
-47
@@ -39,16 +39,15 @@ import com.vitorpamplona.amethyst.commons.search.SearchScope
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchSource
|
||||
import com.vitorpamplona.amethyst.commons.search.nameSearchTerms
|
||||
import com.vitorpamplona.amethyst.commons.search.wholeInputNip19
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
|
||||
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
|
||||
@@ -57,6 +56,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.IPubKeyEntity
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
@@ -195,6 +195,19 @@ class SearchBarViewModel(
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* The routes the box opens on its own, which is now **only** an invite link.
|
||||
*
|
||||
* It used to auto-navigate on any nip19 code found anywhere in the text, which the token
|
||||
* language broke: `from:npub1…` contains an npub, so typing an author filter threw the reader
|
||||
* out of the search screen and onto that person's profile mid-query. A pasted code now
|
||||
* resolves into the results list instead (see [directEntity]) — still one tap away, but the
|
||||
* reader decides when to leave.
|
||||
*
|
||||
* Invite links survive because they cannot be typed by accident: both require a URL carrying
|
||||
* `/invite/`, which no token can produce, and both open a redeem flow rather than a profile
|
||||
* or a post.
|
||||
*/
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
val directRouteResolver: Flow<Route?> =
|
||||
searchTerm
|
||||
@@ -214,50 +227,23 @@ class SearchBarViewModel(
|
||||
return@mapLatest Route.BuzzInvite(term)
|
||||
}
|
||||
|
||||
val parsed =
|
||||
runCatching { Nip19Parser.uriToRoute(term)?.entity }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
.getOrNull()
|
||||
?: return@mapLatest null
|
||||
when (parsed) {
|
||||
// Both NPub (npub1…) and NProfile (nprofile1…, npub + relay hints)
|
||||
// resolve to the same profile route by hex pubkey.
|
||||
is IPubKeyEntity -> {
|
||||
LocalCache.consume(parsed)
|
||||
Route.Profile(parsed.hex)
|
||||
}
|
||||
|
||||
is NNote -> {
|
||||
LocalCache.consume(parsed)
|
||||
Route.Note(parsed.hex)
|
||||
}
|
||||
|
||||
is NEvent -> {
|
||||
LocalCache.consume(parsed)
|
||||
routeFor(LocalCache.getOrCreateNote(parsed.hex), account)
|
||||
?: Route.EventRedirect(parsed.hex)
|
||||
}
|
||||
|
||||
is NAddress -> {
|
||||
// A bare kind-33301 naddr is a Concord invite bundle — not renderable as a
|
||||
// generic addressable event (and unredeemable without the link's fragment
|
||||
// token). Send it to the invite flow, which shows a clean "needs the full
|
||||
// link" state rather than an "unable to render" event screen.
|
||||
if (parsed.kind == ConcordInviteBundleEvent.KIND) {
|
||||
Route.ConcordInvite(term)
|
||||
} else {
|
||||
LocalCache.consume(parsed)
|
||||
routeFor(LocalCache.getOrCreateAddressableNote(parsed.address()), account)
|
||||
?: Route.EventRedirect(parsed.aTag())
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
null
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* The nip19 entity the box holds *in its entirety*, or null.
|
||||
*
|
||||
* Whole-input only, and that is the point: `Nip19Parser` extracts a code from anywhere in a
|
||||
* string, so anything looser matches the npub inside `from:npub1…` and treats an author filter
|
||||
* as a request to open that person.
|
||||
*/
|
||||
private fun directEntity(term: String): Entity? {
|
||||
val code = wholeInputNip19(term) ?: return null
|
||||
return runCatching { Nip19Parser.uriToRoute(code)?.entity }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
val searchResultsUsers =
|
||||
combine(
|
||||
searchValueFlow.debounce(100),
|
||||
@@ -281,9 +267,18 @@ class SearchBarViewModel(
|
||||
// The leftover terms, not the whole box: a name search handed `#bitcoin` or
|
||||
// `from:npub1…` verbatim matches nobody, and since notes started honouring the
|
||||
// tokens, leaving people and channels on the raw text made one box mean two things.
|
||||
// A pasted npub/nprofile resolves to its owner even when the cache has never seen
|
||||
// them — this is what the auto-navigation used to do, minus the navigation.
|
||||
val direct =
|
||||
(directEntity(term) as? IPubKeyEntity)?.let {
|
||||
LocalCache.consume(it)
|
||||
LocalCache.getUserIfExists(it.hex) ?: LocalCache.getOrCreateUser(it.hex)
|
||||
}
|
||||
|
||||
val nameTerm = plainTerms(term)
|
||||
if (nameTerm.isBlank()) return@combine emptyList<User>()
|
||||
val users = LocalCache.search.findUsersStartingWith(nameTerm, account)
|
||||
val found =
|
||||
if (nameTerm.isBlank()) emptyList() else LocalCache.search.findUsersStartingWith(nameTerm, account)
|
||||
val users = (listOfNotNull(direct) + found).distinctBy { it.pubkeyHex }
|
||||
if (follows != null) users.filter { it.pubkeyHex in follows } else users
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||
@@ -305,6 +300,16 @@ class SearchBarViewModel(
|
||||
// results. A bech32 id typed in full is a lookup, not a search, and keeps its own
|
||||
// path through findNotesStartingWith.
|
||||
val parsed = QueryParser.parse(term)
|
||||
// A pasted note/nevent/naddr resolves even when the cache has never seen it —
|
||||
// what the auto-navigation used to do, minus the navigation.
|
||||
val direct =
|
||||
when (val entity = directEntity(term)) {
|
||||
is NNote -> LocalCache.consume(entity).let { LocalCache.getOrCreateNote(entity.hex) }
|
||||
is NEvent -> LocalCache.consume(entity).let { LocalCache.getOrCreateNote(entity.hex) }
|
||||
is NAddress -> LocalCache.consume(entity).let { LocalCache.getOrCreateAddressableNote(entity.address()) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
val raw =
|
||||
when {
|
||||
parsed.isEmpty -> emptyList()
|
||||
@@ -319,7 +324,8 @@ class SearchBarViewModel(
|
||||
account.hiddenUsers,
|
||||
)
|
||||
}
|
||||
val filtered = if (follows != null) raw.filter { it.author?.pubkeyHex in follows } else raw
|
||||
val withDirect = (listOfNotNull(direct) + raw).distinctBy { it.idHex }
|
||||
val filtered = if (follows != null) withDirect.filter { it.author?.pubkeyHex in follows } else withDirect
|
||||
|
||||
when (order) {
|
||||
SearchSortOrder.POPULAR -> {
|
||||
|
||||
+5
-3
@@ -217,7 +217,9 @@ private fun SearchBar(
|
||||
}
|
||||
}
|
||||
|
||||
// bech32 auto-resolve: navigate on hit without displaying results
|
||||
// Invite links only. A pasted nip19 code used to navigate straight off this screen,
|
||||
// which the token language made unusable: `from:npub1…` contains an npub, so typing an
|
||||
// author filter opened that profile mid-query. Codes now resolve into the results list.
|
||||
launch {
|
||||
searchBarViewModel.directRouteResolver.filterNotNull().collect { route ->
|
||||
nav.nav(route)
|
||||
@@ -243,8 +245,8 @@ private fun SearchBar(
|
||||
// ".bit" host resolves to its `_@host` profile. Surfaces the
|
||||
// in-flight state, the eventual on-chain match, and any failure
|
||||
// explicitly. Tapping the resolved row navigates to the user and
|
||||
// clears the search field, matching the existing bech32 auto-
|
||||
// resolve behaviour in `SearchBarViewModel.directRouteResolver`.
|
||||
// clears the search field, matching the invite-link behaviour in
|
||||
// `SearchBarViewModel.directRouteResolver`.
|
||||
NamecoinResolutionRow(
|
||||
searchInput = searchBarViewModel.searchValue,
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+4
-2
@@ -45,8 +45,10 @@ fun parseSearchInput(input: String): List<SearchResult> {
|
||||
val trimmed = input.trim()
|
||||
val results = mutableListOf<SearchResult>()
|
||||
|
||||
// Try to parse as Bech32 (npub, nevent, naddr, etc.)
|
||||
val parsed = Nip19Parser.uriToRoute(trimmed)?.entity
|
||||
// Whole-input only. `Nip19Parser` extracts a code from anywhere in a string, so without this
|
||||
// an author filter — `from:npub1…` — read as a pasted profile and produced a direct-lookup
|
||||
// row for somebody the reader was only filtering by.
|
||||
val parsed = wholeInputNip19(trimmed)?.let { Nip19Parser.uriToRoute(it)?.entity }
|
||||
if (parsed != null) {
|
||||
when (parsed) {
|
||||
is NPub -> {
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.search
|
||||
|
||||
/**
|
||||
* The nip19 code the search box holds *in its entirety*, or null.
|
||||
*
|
||||
* `Nip19Parser` extracts a code from anywhere inside a string, which is right for finding a
|
||||
* mention in a note and wrong for reading a search box. Once `from:`/`to:` existed, "does this
|
||||
* text contain an npub" started answering yes for an author filter — so pasting a code and typing
|
||||
* a filter became indistinguishable, and the box acted on the filter as though it were a paste.
|
||||
*
|
||||
* Whole-input is the distinction that separates them: a paste is the only thing the reader typed,
|
||||
* while a token always carries its prefix and a filtered query almost always carries other words.
|
||||
*
|
||||
* Returns the code with any `nostr:` prefix stripped, ready to hand to a parser. Hex is not
|
||||
* considered here — an unprefixed 64-character string is ambiguous between a pubkey and an event
|
||||
* id, and the finders already resolve it both ways.
|
||||
*/
|
||||
fun wholeInputNip19(text: String?): String? {
|
||||
val trimmed = text?.trim()?.removePrefix("nostr:") ?: return null
|
||||
if (trimmed.isEmpty() || trimmed.any { it.isWhitespace() }) return null
|
||||
if (NIP19_PREFIXES.none { trimmed.startsWith(it, ignoreCase = true) }) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private val NIP19_PREFIXES = listOf("npub1", "nprofile1", "note1", "nevent1", "naddr1", "nsec1")
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.search
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class WholeInputCodeTest {
|
||||
private companion object {
|
||||
const val NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"
|
||||
const val NOTE = "note1stqea6wmwezg9x6yyr6qkukw95ewtdukyaztycws65l8wppjmtpscawevv"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPastedCodeIsTheWholeInput() {
|
||||
assertEquals(NPUB, wholeInputNip19(NPUB))
|
||||
assertEquals(NPUB, wholeInputNip19(" $NPUB "))
|
||||
assertEquals(NPUB, wholeInputNip19("nostr:$NPUB"))
|
||||
assertEquals(NOTE, wholeInputNip19(NOTE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAuthorFilterIsNotAPastedCode() {
|
||||
// The regression this exists for: `Nip19Parser` finds the npub inside this string, so the
|
||||
// search box treated typing a filter as pasting a profile and navigated away mid-query.
|
||||
assertNull(wholeInputNip19("from:$NPUB"))
|
||||
assertNull(wholeInputNip19("to:$NPUB"))
|
||||
assertNull(wholeInputNip19("to:$NOTE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCodeWithOtherWordsAroundItIsNotAPaste() {
|
||||
assertNull(wholeInputNip19("$NPUB bitcoin"))
|
||||
assertNull(wholeInputNip19("look at $NPUB"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anythingThatIsNotACodeIsNull() {
|
||||
assertNull(wholeInputNip19(null))
|
||||
assertNull(wholeInputNip19(""))
|
||||
assertNull(wholeInputNip19(" "))
|
||||
assertNull(wholeInputNip19("bitcoin"))
|
||||
assertNull(wholeInputNip19("#bitcoin"))
|
||||
// Hex is deliberately out of scope: it is ambiguous between a key and an event id.
|
||||
assertNull(wholeInputNip19("a".repeat(64)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user