mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+58
-131
@@ -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,
|
||||
|
||||
+50
@@ -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()
|
||||
}
|
||||
+252
@@ -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
|
||||
}
|
||||
}
|
||||
+157
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user