mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
refactor: desktop's search bar becomes a shell too
`AdvancedSearchBarState` kept its own text, parse, debounce and sort orders. It now delegates all of that to `SearchState` and keeps what is genuinely Desktop's: relay callbacks, raw `Event` results, per-relay sync status, and the expanded form panel. Two things the shared holder fixes on the way in: Relevance ranked against the whole box. `sortEvents(notes, order, rawText)` scored the literal text of the query's own tokens, so a search with a `from:` or a `kind:` in it ranked on noise — Android had been fixed to score the leftover terms, Desktop had not. Going through `SearchPipeline.rank` fixes it and gives Desktop the stable tiebreak too. `ChangeSource` is gone. It existed to decide whether the field should show what was typed or the serialized query, because a form edit wrote to the query while typing wrote to the text and the two could disagree. `SearchState.edit` writes the token into the box instead, so a button press and a typed token produce the same state — which is the rule the whole chip language rests on. `SearchResultSorter.sortEvents` had no callers left after that, so it is deleted and its tests now exercise `SearchPipeline.rank`. Two orderings of the same list, one of them without a tiebreak, was the duplication this whole pass exists to remove. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
This commit is contained in:
+77
-115
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -36,45 +37,53 @@ 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.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
enum class ChangeSource {
|
||||
TEXT,
|
||||
FORM,
|
||||
INIT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop's search: the relay traffic, the results it produces, and the panel around them.
|
||||
*
|
||||
* Everything about *what is being searched* — the text, its parse, the debounce, the scope, the
|
||||
* sort orders — is [state], shared with Android. This class used to own its own copy of all of
|
||||
* it, which is how it came to rank Relevance against the whole box (chips included, so a query
|
||||
* with a `from:` in it scored on the literal text of its own tokens) while Android ranked against
|
||||
* the leftover words.
|
||||
*
|
||||
* What is left here is genuinely Desktop's: results arrive as relay callbacks rather than from a
|
||||
* cache scan, and are held as raw [Event]s because Desktop renders them that way. Per-relay sync
|
||||
* status and the expanded form panel have no Android counterpart at all.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
class AdvancedSearchBarState(
|
||||
private val scope: CoroutineScope,
|
||||
private val debounceMs: Long = 300L,
|
||||
) {
|
||||
private val _query = MutableStateFlow(SearchQuery.EMPTY)
|
||||
val query: StateFlow<SearchQuery> = _query.asStateFlow()
|
||||
/** What is being searched. Shared. */
|
||||
val state = SearchState(scope)
|
||||
|
||||
private var _changeSource: ChangeSource = ChangeSource.INIT
|
||||
val changeSource get() = _changeSource
|
||||
|
||||
private val _rawText = MutableStateFlow("")
|
||||
val rawText: StateFlow<String> = _rawText.asStateFlow()
|
||||
|
||||
val displayText: StateFlow<String> =
|
||||
combine(_query, _rawText) { query, raw ->
|
||||
if (_changeSource == ChangeSource.TEXT) {
|
||||
raw
|
||||
} else {
|
||||
QuerySerializer.serialize(query)
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, "")
|
||||
val query: StateFlow<SearchQuery> =
|
||||
state.current
|
||||
.map { it.query }
|
||||
.stateIn(scope, SharingStarted.Eagerly, state.query)
|
||||
|
||||
val debouncedQuery: StateFlow<SearchQuery> =
|
||||
_query
|
||||
.debounce(debounceMs)
|
||||
state.debouncedForRelays
|
||||
.map { it.query }
|
||||
.stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY)
|
||||
|
||||
/**
|
||||
* What the field shows, which is simply the box.
|
||||
*
|
||||
* There used to be a `ChangeSource` flag here deciding whether to show the raw text or the
|
||||
* serialized query, because a form edit wrote to the query and typing wrote to the text and
|
||||
* the two could disagree. [SearchState.edit] writes the token into the box instead, so a
|
||||
* button press and a typed token are the same thing and there is nothing left to decide.
|
||||
*/
|
||||
val displayText: StateFlow<String> get() = state.text
|
||||
|
||||
val eventSortOrder: StateFlow<SearchSortOrder> get() = state.eventSortOrder
|
||||
val peopleSortOrder: StateFlow<SearchSortOrder> get() = state.peopleSortOrder
|
||||
|
||||
// People search results (from cache + relay)
|
||||
private val _peopleResults = MutableStateFlow<ImmutableList<User>>(persistentListOf())
|
||||
val peopleResults: StateFlow<ImmutableList<User>> = _peopleResults.asStateFlow()
|
||||
@@ -83,21 +92,20 @@ class AdvancedSearchBarState(
|
||||
private val _noteResults = MutableStateFlow<ImmutableList<Event>>(persistentListOf())
|
||||
val noteResults: StateFlow<ImmutableList<Event>> = _noteResults.asStateFlow()
|
||||
|
||||
// Sort orders
|
||||
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()
|
||||
|
||||
// Derived sorted results
|
||||
/**
|
||||
* The results in the order the reader asked for, through the shared pipeline.
|
||||
*
|
||||
* Ranked on the query's *leftover* terms rather than on the whole box: `from:npub1…` and
|
||||
* `kind:article` are filters, and hunting for their literal text inside an event's content
|
||||
* ranks on noise. This was scoring the raw field text until the pipeline took the job over.
|
||||
*/
|
||||
val sortedNoteResults: StateFlow<ImmutableList<Event>> =
|
||||
combine(_noteResults, _eventSortOrder, _rawText) { notes, order, text ->
|
||||
SearchResultSorter.sortEvents(notes, order, text).toImmutableList()
|
||||
combine(_noteResults, state.eventSortOrder, query) { notes, order, q ->
|
||||
SearchPipeline.rank(notes, order, q.text, { it }).toImmutableList()
|
||||
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
|
||||
|
||||
val sortedPeopleResults: StateFlow<ImmutableList<User>> =
|
||||
combine(_peopleResults, _peopleSortOrder) { people, order ->
|
||||
combine(_peopleResults, state.peopleSortOrder) { people, order ->
|
||||
SearchResultSorter.sortPeople(people, order).toImmutableList()
|
||||
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
|
||||
|
||||
@@ -118,91 +126,53 @@ class AdvancedSearchBarState(
|
||||
val relayStates: StateFlow<ImmutableList<RelaySyncState>> = _relayStates.asStateFlow()
|
||||
|
||||
// Text bar input
|
||||
fun updateFromText(rawText: String) {
|
||||
_changeSource = ChangeSource.TEXT
|
||||
_rawText.value = rawText
|
||||
_query.value = QueryParser.parse(rawText)
|
||||
}
|
||||
fun updateFromText(rawText: String) = state.updateText(rawText)
|
||||
|
||||
// Form panel inputs
|
||||
fun updateKinds(kinds: List<Int>) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
_query.value = _query.value.copy(kinds = kinds.toImmutableList())
|
||||
}
|
||||
// Form panel inputs. Each writes its token into the box; see SearchState.edit.
|
||||
|
||||
fun updatePseudoKinds(pseudoKinds: List<String>) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
_query.value = _query.value.copy(pseudoKinds = pseudoKinds.toImmutableList())
|
||||
}
|
||||
fun updateKinds(kinds: List<Int>) = state.edit { it.copy(kinds = kinds.toImmutableList()) }
|
||||
|
||||
fun addAuthor(hexOrName: String) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
val current = _query.value
|
||||
val hex =
|
||||
com.vitorpamplona.quartz.nip19Bech32
|
||||
.decodePublicKeyAsHexOrNull(hexOrName)
|
||||
if (hex != null) {
|
||||
if (hex !in current.authors) {
|
||||
_query.value = current.copy(authors = (current.authors + hex).toImmutableList())
|
||||
}
|
||||
} else {
|
||||
if (hexOrName !in current.authorNames) {
|
||||
_query.value = current.copy(authorNames = (current.authorNames + hexOrName).toImmutableList())
|
||||
fun updatePseudoKinds(pseudoKinds: List<String>) = state.edit { it.copy(pseudoKinds = pseudoKinds.toImmutableList()) }
|
||||
|
||||
fun addAuthor(hexOrName: String) =
|
||||
state.edit { current ->
|
||||
val hex = decodePublicKeyAsHexOrNull(hexOrName)
|
||||
when {
|
||||
hex != null && hex !in current.authors -> current.copy(authors = (current.authors + hex).toImmutableList())
|
||||
hex == null && hexOrName !in current.authorNames -> current.copy(authorNames = (current.authorNames + hexOrName).toImmutableList())
|
||||
else -> current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAuthor(hex: String) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
val current = _query.value
|
||||
_query.value =
|
||||
current.copy(
|
||||
authors = current.authors.filter { it != hex }.toImmutableList(),
|
||||
authorNames = current.authorNames.filter { it != hex }.toImmutableList(),
|
||||
fun removeAuthor(hex: String) =
|
||||
state.edit {
|
||||
it.copy(
|
||||
authors = it.authors.filter { author -> author != hex }.toImmutableList(),
|
||||
authorNames = it.authorNames.filter { name -> name != hex }.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDateRange(
|
||||
since: Long?,
|
||||
until: Long?,
|
||||
) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
_query.value = _query.value.copy(since = since, until = until)
|
||||
}
|
||||
) = state.edit { it.copy(since = since, until = until) }
|
||||
|
||||
fun addHashtag(tag: String) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
val current = _query.value
|
||||
val cleaned = tag.removePrefix("#")
|
||||
if (cleaned !in current.hashtags) {
|
||||
_query.value = current.copy(hashtags = (current.hashtags + cleaned).toImmutableList())
|
||||
fun addHashtag(tag: String) =
|
||||
state.edit { current ->
|
||||
val cleaned = tag.removePrefix("#")
|
||||
if (cleaned in current.hashtags) current else current.copy(hashtags = (current.hashtags + cleaned).toImmutableList())
|
||||
}
|
||||
}
|
||||
|
||||
fun removeHashtag(tag: String) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
val current = _query.value
|
||||
_query.value = current.copy(hashtags = current.hashtags.filter { it != tag }.toImmutableList())
|
||||
}
|
||||
fun removeHashtag(tag: String) = state.edit { it.copy(hashtags = it.hashtags.filter { h -> h != tag }.toImmutableList()) }
|
||||
|
||||
fun addExcludeTerm(term: String) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
val current = _query.value
|
||||
if (term !in current.excludeTerms) {
|
||||
_query.value = current.copy(excludeTerms = (current.excludeTerms + term).toImmutableList())
|
||||
fun addExcludeTerm(term: String) =
|
||||
state.edit { current ->
|
||||
if (term in current.excludeTerms) current else current.copy(excludeTerms = (current.excludeTerms + term).toImmutableList())
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExcludeTerm(term: String) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
val current = _query.value
|
||||
_query.value = current.copy(excludeTerms = current.excludeTerms.filter { it != term }.toImmutableList())
|
||||
}
|
||||
fun removeExcludeTerm(term: String) = state.edit { it.copy(excludeTerms = it.excludeTerms.filter { t -> t != term }.toImmutableList()) }
|
||||
|
||||
fun updateLanguage(lang: String?) {
|
||||
_changeSource = ChangeSource.FORM
|
||||
_query.value = _query.value.copy(language = lang)
|
||||
}
|
||||
fun updateLanguage(lang: String?) = state.edit { it.copy(language = lang) }
|
||||
|
||||
fun initRelayStates(relays: Set<NormalizedRelayUrl>) {
|
||||
_relayStates.value =
|
||||
@@ -251,23 +221,15 @@ class AdvancedSearchBarState(
|
||||
_panelExpanded.value = !_panelExpanded.value
|
||||
}
|
||||
|
||||
fun updateEventSortOrder(order: SearchSortOrder) {
|
||||
_eventSortOrder.value = order
|
||||
}
|
||||
fun updateEventSortOrder(order: SearchSortOrder) = state.updateEventSortOrder(order)
|
||||
|
||||
fun updatePeopleSortOrder(order: SearchSortOrder) {
|
||||
_peopleSortOrder.value = order
|
||||
}
|
||||
fun updatePeopleSortOrder(order: SearchSortOrder) = state.updatePeopleSortOrder(order)
|
||||
|
||||
fun clearSearch() {
|
||||
_changeSource = ChangeSource.INIT
|
||||
_rawText.value = ""
|
||||
_query.value = SearchQuery.EMPTY
|
||||
state.clear()
|
||||
_peopleResults.value = persistentListOf()
|
||||
_noteResults.value = persistentListOf()
|
||||
_relayStates.value = persistentListOf()
|
||||
_eventSortOrder.value = SearchSortOrder.EVENT_DEFAULT
|
||||
_peopleSortOrder.value = SearchSortOrder.PEOPLE_DEFAULT
|
||||
activeSubIds.value = emptySet()
|
||||
eventDeduplicator.clear()
|
||||
}
|
||||
|
||||
+1
-2
@@ -96,8 +96,7 @@ object SearchPipeline {
|
||||
* double holds every sat total exactly up to 2^53, which is past any zap that will exist.
|
||||
*
|
||||
* A front end holding raw events has no zap totals and leaves it at zero, which collapses
|
||||
* POPULAR into newest for that caller — the same thing [SearchResultSorter.sortEvents]
|
||||
* already does, said once instead of twice.
|
||||
* POPULAR into newest for that caller — the only honest answer a raw `Event` can give.
|
||||
*
|
||||
* Sort keys are snapshotted per item before comparing. A `Note` is a mutable box: a newer
|
||||
* addressable event arriving from a relay mid-sort changes `createdAt` under the comparator,
|
||||
|
||||
-33
@@ -27,39 +27,6 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.currentTimeSeconds
|
||||
|
||||
object SearchResultSorter {
|
||||
fun sortEvents(
|
||||
events: List<Event>,
|
||||
order: SearchSortOrder,
|
||||
searchText: String,
|
||||
): List<Event> =
|
||||
when (order) {
|
||||
SearchSortOrder.NEWEST -> {
|
||||
events.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
|
||||
}
|
||||
|
||||
SearchSortOrder.OLDEST -> {
|
||||
events.sortedWith(compareBy<Event> { it.createdAt }.thenBy { it.id })
|
||||
}
|
||||
|
||||
SearchSortOrder.RELEVANCE -> {
|
||||
if (searchText.isBlank()) {
|
||||
events.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
|
||||
} else {
|
||||
events.sortedByDescending { scoreEvent(it, searchText) }
|
||||
}
|
||||
}
|
||||
|
||||
SearchSortOrder.POPULAR -> {
|
||||
// Raw Event has no zap-total; callers that hold Note objects should sort by
|
||||
// zapsAmount directly. Fall back to newest so the option is still harmless here.
|
||||
events.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
|
||||
}
|
||||
|
||||
else -> {
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
fun sortPeople(
|
||||
people: List<User>,
|
||||
order: SearchSortOrder,
|
||||
|
||||
+10
-7
@@ -89,24 +89,26 @@ class SearchResultSorterTest {
|
||||
|
||||
// --- Event sorting ---
|
||||
|
||||
private val asEvent: (Event) -> Event? = { it }
|
||||
|
||||
@Test
|
||||
fun newestSortsDescending() {
|
||||
val events = listOf(event("a", 100), event("b", 300), event("c", 200))
|
||||
val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.NEWEST, "")
|
||||
val sorted = SearchPipeline.rank(events, SearchSortOrder.NEWEST, "", asEvent)
|
||||
assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldestSortsAscending() {
|
||||
val events = listOf(event("a", 300), event("b", 100), event("c", 200))
|
||||
val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.OLDEST, "")
|
||||
val sorted = SearchPipeline.rank(events, SearchSortOrder.OLDEST, "", asEvent)
|
||||
assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relevanceEmptyQueryFallsBackToRecency() {
|
||||
val events = listOf(event("a", 100), event("b", 300), event("c", 200))
|
||||
val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.RELEVANCE, "")
|
||||
val sorted = SearchPipeline.rank(events, SearchSortOrder.RELEVANCE, "", asEvent)
|
||||
assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
|
||||
}
|
||||
|
||||
@@ -114,7 +116,7 @@ class SearchResultSorterTest {
|
||||
fun relevanceExactMatchBeatsPartial() {
|
||||
val exact = event("exact", 100, content = "bitcoin is great")
|
||||
val partial = event("partial", 100, content = "bit of something")
|
||||
val sorted = SearchResultSorter.sortEvents(listOf(partial, exact), SearchSortOrder.RELEVANCE, "bitcoin")
|
||||
val sorted = SearchPipeline.rank(listOf(partial, exact), SearchSortOrder.RELEVANCE, "bitcoin", asEvent)
|
||||
assertEquals("exact", sorted.first().id)
|
||||
}
|
||||
|
||||
@@ -122,7 +124,7 @@ class SearchResultSorterTest {
|
||||
fun relevanceWordBoundaryBeatsSubstring() {
|
||||
val boundary = event("boundary", 100, content = "I love bitcoin and lightning")
|
||||
val substring = event("substr", 100, content = "bitcoinery is not a word")
|
||||
val sorted = SearchResultSorter.sortEvents(listOf(substring, boundary), SearchSortOrder.RELEVANCE, "bitcoin")
|
||||
val sorted = SearchPipeline.rank(listOf(substring, boundary), SearchSortOrder.RELEVANCE, "bitcoin", asEvent)
|
||||
assertEquals("boundary", sorted.first().id)
|
||||
}
|
||||
|
||||
@@ -130,7 +132,7 @@ class SearchResultSorterTest {
|
||||
fun relevanceArticleTitleBoost() {
|
||||
val withTitle = article("titled", 100, content = "some content", title = "Bitcoin Guide")
|
||||
val withoutTitle = event("notitle", 100, content = "bitcoin bitcoin bitcoin")
|
||||
val sorted = SearchResultSorter.sortEvents(listOf(withoutTitle, withTitle), SearchSortOrder.RELEVANCE, "bitcoin")
|
||||
val sorted = SearchPipeline.rank(listOf(withoutTitle, withTitle), SearchSortOrder.RELEVANCE, "bitcoin", asEvent)
|
||||
assertEquals("titled", sorted.first().id)
|
||||
}
|
||||
|
||||
@@ -139,10 +141,11 @@ class SearchResultSorterTest {
|
||||
val multi = event("multi", 100, content = "bitcoin and lightning network")
|
||||
val single = event("single", 100, content = "bitcoin only here")
|
||||
val sorted =
|
||||
SearchResultSorter.sortEvents(
|
||||
SearchPipeline.rank(
|
||||
listOf(single, multi),
|
||||
SearchSortOrder.RELEVANCE,
|
||||
"bitcoin lightning",
|
||||
asEvent,
|
||||
)
|
||||
assertEquals("multi", sorted.first().id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user