refactor: one holder for what a search is

Both front ends had grown their own copy of the same state — the text,
the parse, the debounce, the scope, the sort orders, "is it fair to say
nothing was found yet" — and the copies disagreed on every one of them.
Android parsed the same string about nine times per keystroke across four
debounce windows; Desktop parsed once and had no scope at all. Neither
difference was a decision.

`SearchState` in commons owns all of it. `SearchBarViewModel` keeps only
what a shared holder cannot: a LazyListState, a FocusRequester, invite
routing, NIP-05 resolution, and the seven result flows — the acquisition
of results, which is a cache scan here and a relay callback on Desktop.
615 lines down to 508.

Three things fall out of it:

`SearchInput` carries the text and its parse as one value. Most callers
want the query; a couple genuinely want the characters (a relay finder
matching wss://, an id lookup deciding whether the box holds a pointer or
a phrase), and reading those from a separately debounced flow lets a
collector pair one keystroke's text with another's parse. It also carries
`nameTerms`, which was the most-repeated parse of all — every people and
channel finder re-parsed the whole box to ask for it.

Two debounce windows instead of ten, each named for what it protects:
100ms before a cache scan, 300ms before a REQ, because a REQ opens a
subscription on every search relay and withdrawing it a keystroke later
is traffic nobody wanted. Eight collectors had been declaring the 100
separately, which is not eight policies but one policy that could drift.

`edit {}` writes a filter into the box as text rather than holding it
beside the text. Desktop carried a `ChangeSource` flag to decide whether
to show what was typed or the serialized query; with the box
authoritative there is nothing to decide, and a chip stays something the
reader can delete by editing the words.

One fragility fixed on the way: `listState` is now declared above every
eagerly-shared collector that scrolls it. It was above the only one that
did, and `debouncedForRelays` — a StateFlow with a seeded value, where
the old flow waited out 300ms first — added another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
This commit is contained in:
Claude
2026-09-10 14:59:21 +00:00
parent 8a5002fc52
commit 4f26534336
4 changed files with 517 additions and 131 deletions
@@ -33,15 +33,13 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayClient.search.SearchQueryState
import com.vitorpamplona.amethyst.commons.search.QueryParser
import com.vitorpamplona.amethyst.commons.search.RenderableKinds
import com.vitorpamplona.amethyst.commons.search.SearchFilterBuilder
import com.vitorpamplona.amethyst.commons.search.SearchPipeline
import com.vitorpamplona.amethyst.commons.search.SearchResultKind
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.SearchState
import com.vitorpamplona.amethyst.commons.search.wholeInputNip19
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.Account
@@ -72,21 +70,17 @@ import com.vitorpamplona.quartz.utils.startsWithAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
@Stable
@@ -106,10 +100,19 @@ class SearchBarViewModel(
var searchValue by mutableStateOf(initialQuery.orEmpty())
val invalidations = MutableStateFlow(0)
val searchValueFlow = MutableStateFlow(searchValue)
/**
* True while a token picker is open under the field.
* What is being searched, and everything about it that is not Android's: the text, its parse,
* the two debounce windows, the scope, the sort orders. Shared with Desktop, which had grown
* its own copy of all of it.
*
* What stays here is what a shared holder cannot own: a `LazyListState`, a `FocusRequester`,
* invite-link routing, NIP-05 resolution, and the seven result flows — the *acquisition* of
* results, which on Android is a cache scan and on Desktop is a relay callback.
*/
val state = SearchState(viewModelScope, initialText = initialQuery.orEmpty())
/** True while a token picker is open under the field.
*
* The search screen pins its bars on this. A picker is a scrollable inside the *top bar*, and
* [com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarNestedScroll] moves the bars
@@ -119,75 +122,27 @@ class SearchBarViewModel(
*/
val pickerOpen = MutableStateFlow(false)
/**
* True when the box holds filters but none a relay can be asked for.
*
* A bare `kind:` window is the case that matters: [SearchFilterBuilder] refuses it, because
* "every recent article" is an unbounded REQ rather than a search. A screen that seeds its
* kind therefore opens holding a chip and showing nothing, which looks broken unless the box
* says what it is waiting for.
*/
val queryAsksNothing: StateFlow<Boolean> =
searchValueFlow
.map { text ->
val query = QueryParser.parse(text)
!query.isEmpty && SearchFilterBuilder.build(query).isEmpty()
}.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val queryAsksNothing get() = state.asksNothing
val searchSettled get() = state.settled
val scopePinnedToNotes get() = state.scopePinnedToNotes
val scope get() = state.scope
/**
* True once enough time has passed since the query last changed that "nothing found" is a
* fair thing to say.
*
* A heuristic, and deliberately so: no EOSE from the search subscription reaches this screen,
* so there is nothing that actually knows the relays have finished. Without the delay an
* empty list would announce failure in the gap before the first event arrives — which is
* every search, for a moment.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val searchSettled: StateFlow<Boolean> =
searchValueFlow
.transformLatest {
emit(false)
delay(NO_RESULTS_GRACE_MS)
emit(true)
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val source get() = state.source
val followsOnly get() = state.followsOnly
val sortOrder get() = state.eventSortOrder
/** The scope the reader picked, which is not always the one that applies — see [scope]. */
private val pickedScope = MutableStateFlow(SearchScope.ALL)
/**
* True while the query names a `kind:`, which only an event can have.
*
* The People half of the toggle cannot answer such a query — a person is not an event of any
* kind — so leaving it selectable offers the reader a scope guaranteed to come back empty.
*/
val scopePinnedToNotes: StateFlow<Boolean> =
searchValueFlow
.map { QueryParser.parse(it).isEventOnly }
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/**
* The scope that actually applies: the reader's pick, unless the query names a kind.
*
* Derived rather than written back over [pickedScope] on purpose — dropping the `kind:` chip
* has to give the reader the scope they chose before, not leave them pinned to Notes by a
* filter that is no longer there.
*/
val scope: StateFlow<SearchScope> =
combine(pickedScope, scopePinnedToNotes) { picked, pinned ->
if (pinned) SearchScope.NOTES else picked
}.stateIn(viewModelScope, SharingStarted.Eagerly, SearchScope.ALL)
val source = MutableStateFlow(SearchSource.RELAYS)
val followsOnly = MutableStateFlow(false)
val sortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT)
// Declared before every Eagerly-shared collector that calls `updateDataSource`, and it must
// stay there: `updateDataSource` scrolls this list, and Kotlin initialises properties in
// declaration order, so from below it would still be null. It never showed while the box
// opened empty, because a blank term returns before the scroll; seeding the field from the
// screen's filter made the term non-blank on the very first pass and turned that into an NPE
// the moment search opened. `state.debouncedForRelays` is a StateFlow with a seeded value, so
// the collector below no longer waits out a debounce window before the first call either.
val listState: LazyListState = LazyListState(0, 0)
val searchTerm =
searchValueFlow
.debounce(300)
.distinctUntilChanged()
state.debouncedForRelays
.map { it.text }
.onEach(::updateDataSource)
.stateIn(viewModelScope, SharingStarted.Eagerly, searchValue)
@@ -200,14 +155,6 @@ class SearchBarViewModel(
followPlusAllMineWithSearchRelays = account.followPlusAllMineWithSearch.flow,
)
// Declared before [sourceWatcher], and it must stay there. That collector is Eagerly
// shared, so it runs `updateDataSource` during construction, and `updateDataSource` scrolls
// this list -- Kotlin initialises properties in declaration order, so from below it would
// still be null. It never showed while the box opened empty, because a blank term returns
// before the scroll; seeding the field from the screen's filter made the term non-blank on
// the very first pass and turned that into an NPE the moment search opened.
val listState: LazyListState = LazyListState(0, 0)
@Suppress("unused")
val sourceWatcher =
source
@@ -335,14 +282,14 @@ class SearchBarViewModel(
val searchResultsUsers =
combine(
searchValueFlow.debounce(100),
state.debounced,
invalidations.debounce(100),
directNip05Resolver,
scope,
combine(followsOnly, account.kind3FollowList.flow) { only, follows ->
if (only) follows.authorsPlusMe else null
},
) { term, _, nip05Resolver, currentScope, follows ->
) { input, _, nip05Resolver, currentScope, follows ->
if (!currentScope.shows(SearchResultKind.PEOPLE)) return@combine emptyList<User>()
if (nip05Resolver != null) {
@@ -359,12 +306,12 @@ class SearchBarViewModel(
// 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 {
(directEntity(input.text) as? IPubKeyEntity)?.let {
LocalCache.consume(it)
LocalCache.getUserIfExists(it.hex) ?: LocalCache.getOrCreateUser(it.hex)
}
val nameTerm = plainTerms(term)
val nameTerm = input.nameTerms
val found =
if (nameTerm.isBlank()) emptyList() else LocalCache.search.findUsersStartingWith(nameTerm, account)
val users = (listOfNotNull(direct) + found).distinctBy { it.pubkeyHex }
@@ -374,25 +321,25 @@ class SearchBarViewModel(
val searchResultsNotes =
combine(
searchValueFlow.debounce(100),
state.debounced,
invalidations,
scope,
sortOrder,
combine(followsOnly, account.kind3FollowList.flow) { only, follows ->
if (only) follows.authorsPlusMe else null
},
) { term, _, currentScope, order, follows ->
) { input, _, currentScope, order, follows ->
if (!currentScope.shows(SearchResultKind.NOTES)) return@combine emptyList()
// The same filters the REQ carries, run against the cache — so `from:`, `to:`,
// `since:`, `#t` and the rest narrow local results exactly as they narrow relay
// 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)
val parsed = input.query
// 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)) {
when (val entity = directEntity(input.text)) {
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()) }
@@ -406,7 +353,7 @@ class SearchBarViewModel(
// `idHex`, which is not content and so nothing a filter's `search` can reach.
// Routed to the scan that knows how to resolve it — and only for text that
// could actually be one, so an ordinary query never pays for two scans.
looksLikeAnEventId(term) -> LocalCache.findNotesStartingWith(term, account.hiddenUsers.flow.value)
looksLikeAnEventId(input.text) -> LocalCache.findNotesStartingWith(input.text, account.hiddenUsers.flow.value)
else ->
// The same filters the REQ carries, over the same kind window. Built by
// the pipeline rather than here, so the cache is asked exactly what the
@@ -437,51 +384,52 @@ class SearchBarViewModel(
val searchResultsPublicChatChannels =
combine(
searchValueFlow.debounce(100),
state.debounced,
invalidations,
scope,
) { term, _, currentScope ->
if (!currentScope.shows(SearchResultKind.PUBLIC_CHATS)) emptyList() else LocalCache.findPublicChatChannelsStartingWith(plainTerms(term))
) { input, _, currentScope ->
if (!currentScope.shows(SearchResultKind.PUBLIC_CHATS)) emptyList() else LocalCache.findPublicChatChannelsStartingWith(input.nameTerms)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val searchResultsEphemeralChannels =
combine(
searchValueFlow.debounce(100),
state.debounced,
invalidations,
scope,
) { term, _, currentScope ->
if (!currentScope.shows(SearchResultKind.EPHEMERAL_CHATS)) emptyList() else LocalCache.findEphemeralChatChannelsStartingWith(plainTerms(term))
) { input, _, currentScope ->
if (!currentScope.shows(SearchResultKind.EPHEMERAL_CHATS)) emptyList() else LocalCache.findEphemeralChatChannelsStartingWith(input.nameTerms)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val searchResultsLiveActivityChannels =
combine(
searchValueFlow.debounce(100),
state.debounced,
invalidations,
scope,
) { term, _, currentScope ->
if (!currentScope.shows(SearchResultKind.LIVE_ACTIVITIES)) emptyList() else LocalCache.findLiveActivityChannelsStartingWith(plainTerms(term))
) { input, _, currentScope ->
if (!currentScope.shows(SearchResultKind.LIVE_ACTIVITIES)) emptyList() else LocalCache.findLiveActivityChannelsStartingWith(input.nameTerms)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val hashtagResults =
combine(
searchValueFlow.debounce(100),
state.debounced,
invalidations,
scope,
) { term, _, currentScope ->
if (!currentScope.shows(SearchResultKind.HASHTAGS)) emptyList() else findHashtags(term)
) { input, _, currentScope ->
if (!currentScope.shows(SearchResultKind.HASHTAGS)) emptyList() else findHashtags(input.text)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
val relayResults =
combine(
searchValueFlow.debounce(100),
state.debounced,
invalidations,
scope,
) { term, _, currentScope ->
) { input, _, currentScope ->
if (!currentScope.shows(SearchResultKind.RELAYS)) return@combine emptyList()
val term = input.text
if (term.length > 1) {
val isTypingRelay = term.length > 7 && (term.startsWith("wss://") || term.startsWith("ws://"))
val relayUrl =
@@ -510,14 +458,6 @@ class SearchBarViewModel(
override val isRefreshing = derivedStateOf { searchValue.isNotBlank() }
/**
* The single word a name search should be given — see [nameSearchTerms]. Not simply the
* leftover text: a query that is nothing but `#bitcoin` leaves no leftovers, and handing the
* finders an empty string means they answer with nobody rather than with the channel called
* "Bitcoin" that the reader was plainly looking for.
*/
private fun plainTerms(term: String): String = QueryParser.parse(term).nameSearchTerms()
/**
* Could this text name an event rather than describe one? A bech32 pointer, or a run of hex
* long enough that it is nobody's search term.
@@ -536,7 +476,7 @@ class SearchBarViewModel(
fun updateSearchValue(newValue: String) {
searchValue = newValue
searchValueFlow.tryEmit(newValue)
state.updateText(newValue)
}
fun clear() = updateSearchValue("")
@@ -550,29 +490,16 @@ class SearchBarViewModel(
}
}
fun updateScope(newScope: SearchScope) {
pickedScope.value = newScope
}
fun updateScope(newScope: SearchScope) = state.updateScope(newScope)
fun updateSource(newSource: SearchSource) {
source.value = newSource
}
fun updateSource(newSource: SearchSource) = state.updateSource(newSource)
fun updateFollowsOnly(value: Boolean) {
followsOnly.value = value
}
fun updateFollowsOnly(value: Boolean) = state.updateFollowsOnly(value)
fun updateSortOrder(order: SearchSortOrder) {
sortOrder.value = order
}
fun updateSortOrder(order: SearchSortOrder) = state.updateEventSortOrder(order)
fun isSearchingFun() = searchValue.isNotBlank()
companion object {
/** How long after the last keystroke an empty result list is allowed to say so. */
private const val NO_RESULTS_GRACE_MS = 1200L
}
class Factory(
val account: Account,
val nip05: INip05Client,
@@ -0,0 +1,50 @@
/*
* 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
/**
* One state of the search box: the text, and what it means.
*
* A pair rather than two flows because both halves are needed and they have to agree. Most
* collectors want [query]; a few genuinely want the characters — a relay finder matching
* `wss://`, an id lookup deciding whether the box holds a bech32 pointer rather than a phrase —
* and reading those from a separately debounced flow lets a collector pair one keystroke's text
* with another's parse.
*
* [nameTerms] is here rather than at the call site because it was the most-repeated parse of all:
* every people-and-channel finder re-parsed the whole box to ask for it.
*/
class SearchInput(
val text: String,
) {
val query: SearchQuery = QueryParser.parse(text)
/**
* The words a name search should be given.
*
* Not simply the leftover text: a query that is nothing but `#bitcoin` leaves no leftovers,
* and handing the finders an empty string means they answer with nobody rather than with the
* channel called "Bitcoin" the reader was plainly looking for.
*/
val nameTerms: String get() = query.nameSearchTerms()
val isBlank: Boolean get() = text.isBlank()
}
@@ -0,0 +1,252 @@
/*
* 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
/**
* What a search *is*, minus where its results come from.
*
* Both front ends had grown their own copy of this — the text, the parse, the debounce, the
* scope, the sort orders, "is there anything to say yet" — and the copies disagreed on every one
* of them. Android parsed the same string about nine times per keystroke across four different
* debounce windows; desktop parsed once but had no scope at all. Neither difference was a
* decision.
*
* So it lives here once, and each front end keeps only what is genuinely its own: how it asks for
* results (a cache scan on Android, relay callbacks on desktop), what result kinds it can render,
* and its own screen.
*
* ## The text is the query
*
* There is exactly one authority for what is being searched, and it is [text] — what is in the
* box. [edit] exists for controls that build a filter without typing (a kind picker, a form
* panel), and all it does is write the token into the box: the reader sees the same text they
* could have typed, can edit it by hand, and the chips they see are that text rendered. Desktop
* carried a `ChangeSource` flag to decide whether to show the raw text or the serialized query;
* with the box authoritative there is nothing to decide.
*
* CLI-safe: flows and data, no Compose.
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchState(
private val coroutineScope: CoroutineScope,
initialText: String = "",
/** How still the box must be before the cache is scanned. See [debounced]. */
private val localDebounceMs: Long = LOCAL_DEBOUNCE_MS,
/** How still the box must be before relays are asked. See [debouncedForRelays]. */
private val relayDebounceMs: Long = RELAY_DEBOUNCE_MS,
private val settleMs: Long = DEFAULT_SETTLE_MS,
) {
private val _text = MutableStateFlow(initialText)
/** What is in the box, which is the only authority for what is being searched. */
val text: StateFlow<String> = _text.asStateFlow()
/**
* [text] and its parse, together. Parsed once — not once per collector.
*
* The pair travels as one value rather than as two flows because a collector needs both and
* they must agree: the relay finder and the id lookup read the raw text, everything else reads
* the query, and two separately debounced flows would let a collector combine last
* keystroke's text with this one's parse. Every reader of a search's structure comes through
* here, including the ones that only want the leftover words
* (`current.value.query.nameSearchTerms()`) — which is what the front ends were re-parsing
* the whole box to get, about nine times per keystroke on Android.
*/
val current: StateFlow<SearchInput> =
_text
.map { SearchInput(it) }
.stateIn(coroutineScope, SharingStarted.Eagerly, SearchInput(initialText))
/**
* [current] once the reader has paused, for answers that cost a cache scan.
*
* There are two windows and only two, each named for what it protects. This one was written
* eight times over on Android — every result flow declared its own `.debounce(100)` — which
* is not eight policies but one policy said eight times, and so one policy that could drift.
*/
val debounced: StateFlow<SearchInput> =
_text
.debounce(localDebounceMs)
.distinctUntilChanged()
.map { SearchInput(it) }
.stateIn(coroutineScope, SharingStarted.Eagerly, SearchInput(initialText))
/**
* [current] once the reader has paused longer, for answers that cost a round trip.
*
* Wider than [debounced] because a REQ opens a subscription on every search relay, and
* withdrawing it a keystroke later is traffic nobody wanted — not because a relay is slower
* to read.
*/
val debouncedForRelays: StateFlow<SearchInput> =
_text
.debounce(relayDebounceMs)
.distinctUntilChanged()
.map { SearchInput(it) }
.stateIn(coroutineScope, SharingStarted.Eagerly, SearchInput(initialText))
/** Shorthand for `current.value.query`, which is what most callers mean. */
val query: SearchQuery get() = current.value.query
/**
* True when the box holds filters but none a relay can be asked for.
*
* A bare `kind:` window is the case that matters: [SearchFilterBuilder] refuses it, because
* "every recent article" is an unbounded REQ rather than a search. A screen that seeds its
* kind therefore opens holding a chip and showing nothing, which looks broken unless the box
* says what it is waiting for.
*/
val asksNothing: StateFlow<Boolean> =
current
.map { !it.query.isEmpty && SearchFilterBuilder.build(it.query).isEmpty() }
.distinctUntilChanged()
.stateIn(coroutineScope, SharingStarted.Eagerly, false)
/**
* True once enough time has passed since the query last changed that "nothing found" is a
* fair thing to say.
*
* A heuristic, and deliberately so: no EOSE from the search subscription reaches the screen,
* so nothing actually knows the relays have finished. Without the delay an empty list would
* announce failure in the gap before the first event arrives — which is every search, for a
* moment.
*/
val settled: StateFlow<Boolean> =
_text
.transformLatest {
emit(false)
delay(settleMs)
emit(true)
}.stateIn(coroutineScope, SharingStarted.Eagerly, false)
/** The scope the reader picked, which is not always the one that applies — see [scope]. */
private val _pickedScope = MutableStateFlow(SearchScope.ALL)
val pickedScope: StateFlow<SearchScope> = _pickedScope.asStateFlow()
/**
* True while the query names a `kind:`, which only an event can have.
*
* The People half of the toggle cannot answer such a query — a person is not an event of any
* kind — so leaving it selectable offers the reader a scope guaranteed to come back empty.
*/
val scopePinnedToNotes: StateFlow<Boolean> =
current
.map { it.query.isEventOnly }
.distinctUntilChanged()
.stateIn(coroutineScope, SharingStarted.Eagerly, false)
/**
* The scope that actually applies: the reader's pick, unless the query names a kind.
*
* Derived rather than written back over [pickedScope] on purpose — dropping the `kind:` chip
* has to give the reader the scope they chose before, not leave them pinned to Notes by a
* filter that is no longer there.
*/
val scope: StateFlow<SearchScope> =
combine(_pickedScope, scopePinnedToNotes) { picked, pinned ->
if (pinned) SearchScope.NOTES else picked
}.stateIn(coroutineScope, SharingStarted.Eagerly, SearchScope.ALL)
private val _eventSortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT)
val eventSortOrder: StateFlow<SearchSortOrder> = _eventSortOrder.asStateFlow()
private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.PEOPLE_DEFAULT)
val peopleSortOrder: StateFlow<SearchSortOrder> = _peopleSortOrder.asStateFlow()
/** Whether results are narrowed to the reader's follows. */
private val _followsOnly = MutableStateFlow(false)
val followsOnly: StateFlow<Boolean> = _followsOnly.asStateFlow()
/** Whether relays are asked at all, or only what is already in the cache. */
private val _source = MutableStateFlow(SearchSource.RELAYS)
val source: StateFlow<SearchSource> = _source.asStateFlow()
/** Typing, pasting, or a screen seeding the box: all the same thing. */
fun updateText(raw: String) {
_text.value = raw
}
/**
* A control that adds or removes a filter without the reader typing it.
*
* The new query is written back into the box as text, so a button press and a typed token
* produce the same state and the same chips — and the reader can undo either one the same
* way, by editing the words.
*/
fun edit(transform: (SearchQuery) -> SearchQuery) {
_text.value = QuerySerializer.serialize(transform(query))
}
fun updateScope(newScope: SearchScope) {
_pickedScope.value = newScope
}
fun updateEventSortOrder(order: SearchSortOrder) {
_eventSortOrder.value = order
}
fun updatePeopleSortOrder(order: SearchSortOrder) {
_peopleSortOrder.value = order
}
fun updateFollowsOnly(value: Boolean) {
_followsOnly.value = value
}
fun updateSource(newSource: SearchSource) {
_source.value = newSource
}
/** Empties the box and puts every control back where it started. */
fun clear() {
_text.value = ""
_pickedScope.value = SearchScope.ALL
_eventSortOrder.value = SearchSortOrder.EVENT_DEFAULT
_peopleSortOrder.value = SearchSortOrder.PEOPLE_DEFAULT
_followsOnly.value = false
}
companion object {
/** See [debounced]. */
const val LOCAL_DEBOUNCE_MS = 100L
/** See [debouncedForRelays]. */
const val RELAY_DEBOUNCE_MS = 300L
/** How long after the last keystroke an empty result list is allowed to say so. */
const val DEFAULT_SETTLE_MS = 1200L
}
}
@@ -0,0 +1,157 @@
/*
* 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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The state both front ends now share, at the points where their two copies used to disagree.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class SearchStateTest {
@Test
fun aSeededBoxIsAlreadyParsedBeforeAnythingTicks() =
runTest {
// A screen seeds the box on the way in, and the search opens showing chips. If the
// parse waited for a debounce window the first frame would draw the raw tokens.
val s = SearchState(backgroundScope, "kind:article bitcoin")
runCurrent()
assertEquals(listOf(30023), s.current.value.query.kinds)
assertEquals("bitcoin", s.current.value.query.text)
assertEquals(listOf(30023), s.debounced.value.query.kinds)
}
@Test
fun theTextAndItsParseAlwaysAgree() =
runTest {
// The point of carrying them as one value: a collector cannot pair one keystroke's
// characters with another's parse.
val s = SearchState(backgroundScope)
s.updateText("#nostr")
runCurrent()
assertEquals("#nostr", s.current.value.text)
assertEquals(listOf("nostr"), s.current.value.query.hashtags)
}
@Test
fun relaysAreAskedLaterThanTheCacheIs() =
runTest {
val s = SearchState(backgroundScope)
s.updateText("bitcoin")
advanceTimeBy(SearchState.LOCAL_DEBOUNCE_MS + 1)
assertEquals("bitcoin", s.debounced.value.text, "the cache scan runs on the short window")
assertEquals("", s.debouncedForRelays.value.text, "a REQ does not")
advanceTimeBy(SearchState.RELAY_DEBOUNCE_MS)
assertEquals("bitcoin", s.debouncedForRelays.value.text)
}
@Test
fun aButtonPressBecomesTextTheReaderCouldHaveTyped() =
runTest {
// The rule the whole token language rests on: a control that adds a filter writes it
// into the box, so a chip is always something editable rather than hidden state.
val s = SearchState(backgroundScope)
s.updateText("bitcoin")
runCurrent()
s.edit { it.copy(kinds = kotlinx.collections.immutable.persistentListOf(30023)) }
runCurrent()
assertEquals("kind:article bitcoin", s.text.value)
assertEquals(listOf(30023), s.current.value.query.kinds)
}
@Test
fun aKindPinsTheScopeToNotesAndReleasesItOnDeletion() =
runTest {
// Pinned rather than assigned: dropping the chip must give back the scope the reader
// chose, not leave them stuck in Notes.
val s = SearchState(backgroundScope)
s.updateScope(SearchScope.PEOPLE)
s.updateText("kind:article")
runCurrent()
assertTrue(s.scopePinnedToNotes.value)
assertEquals(SearchScope.NOTES, s.scope.value)
assertEquals(SearchScope.PEOPLE, s.pickedScope.value)
s.updateText("")
runCurrent()
assertFalse(s.scopePinnedToNotes.value)
assertEquals(SearchScope.PEOPLE, s.scope.value)
}
@Test
fun aBareKindWindowIsAQueryThatAsksNothing() =
runTest {
// "every recent article" is an unbounded REQ, so the builder refuses it. Without
// saying so, a screen that seeds its kind opens holding a chip and showing nothing.
val s = SearchState(backgroundScope)
s.updateText("kind:article")
runCurrent()
assertTrue(s.asksNothing.value)
s.updateText("kind:article bitcoin")
runCurrent()
assertFalse(s.asksNothing.value)
s.updateText("")
runCurrent()
assertFalse(s.asksNothing.value, "an empty box is not asking anything wrong")
}
@Test
fun nothingIsSaidToBeMissingUntilTheSearchHasSettled() =
runTest {
val s = SearchState(backgroundScope)
s.updateText("bitcoin")
runCurrent()
assertFalse(s.settled.value)
advanceTimeBy(SearchState.DEFAULT_SETTLE_MS + 1)
assertTrue(s.settled.value)
// And a further keystroke takes the claim back.
s.updateText("bitcoins")
runCurrent()
assertFalse(s.settled.value)
}
@Test
fun clearingPutsEveryControlBackWhereItStarted() =
runTest {
val s = SearchState(backgroundScope, "kind:article")
s.updateScope(SearchScope.PEOPLE)
s.updateFollowsOnly(true)
s.updateEventSortOrder(SearchSortOrder.OLDEST)
runCurrent()
s.clear()
runCurrent()
assertEquals("", s.text.value)
assertEquals(SearchScope.ALL, s.scope.value)
assertFalse(s.followsOnly.value)
assertEquals(SearchSortOrder.EVENT_DEFAULT, s.eventSortOrder.value)
}
}