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:
Claude
2026-09-09 12:52:15 +00:00
parent 4cfe644eb8
commit 1f72b3c080
5 changed files with 173 additions and 52 deletions
@@ -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 -> {
@@ -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,
@@ -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 -> {
@@ -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")
@@ -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)))
}
}