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 e4f1bd4deb..e1cfdf8876 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 @@ -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 = - 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 = - 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 = - 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 = - 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() 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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchInput.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchInput.kt new file mode 100644 index 0000000000..0008b2b297 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchInput.kt @@ -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() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchState.kt new file mode 100644 index 0000000000..069702fb1a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchState.kt @@ -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 = _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 = + _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 = + _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 = + _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 = + 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 = + _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 = _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 = + 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 = + 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 = _eventSortOrder.asStateFlow() + + private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.PEOPLE_DEFAULT) + val peopleSortOrder: StateFlow = _peopleSortOrder.asStateFlow() + + /** Whether results are narrowed to the reader's follows. */ + private val _followsOnly = MutableStateFlow(false) + val followsOnly: StateFlow = _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 = _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 + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchStateTest.kt new file mode 100644 index 0000000000..c2fd443ed6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchStateTest.kt @@ -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) + } +}