From 1f72b3c0801653efa3b1f281f0e13c0f17243e52 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:52:15 +0000 Subject: [PATCH] fix(search): stop typing an author filter from navigating away mid-query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV --- .../loggedIn/search/SearchBarViewModel.kt | 100 ++++++++++-------- .../ui/screen/loggedIn/search/SearchScreen.kt | 8 +- .../amethyst/commons/search/SearchParser.kt | 6 +- .../amethyst/commons/search/WholeInputCode.kt | 45 ++++++++ .../commons/search/WholeInputCodeTest.kt | 66 ++++++++++++ 5 files changed, 173 insertions(+), 52 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/WholeInputCode.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/WholeInputCodeTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 41b85b068a..067de4ce80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -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 = 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() - 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 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 78bc97a071..ccac1a4a6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt index 787daa4ac8..2da2505b4d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt @@ -45,8 +45,10 @@ fun parseSearchInput(input: String): List { val trimmed = input.trim() val results = mutableListOf() - // 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 -> { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/WholeInputCode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/WholeInputCode.kt new file mode 100644 index 0000000000..ec35696c77 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/WholeInputCode.kt @@ -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") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/WholeInputCodeTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/WholeInputCodeTest.kt new file mode 100644 index 0000000000..bd34a95189 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/WholeInputCodeTest.kt @@ -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))) + } +}