feat(search): add AdvancedSearchBarState with bidirectional sync

Phase 3: State holder with MutableStateFlow<SearchQuery> as SSOT,
sourceOfChange discriminator to prevent parse/serialize loops,
debounced query for subscriptions, results management, author
name resolution via cache autocomplete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-10 11:44:57 +02:00
parent c3a45822d5
commit 8265d3f70a
@@ -0,0 +1,228 @@
/*
* 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 com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
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.stateIn
enum class ChangeSource {
TEXT,
FORM,
INIT,
}
@OptIn(FlowPreview::class)
class AdvancedSearchBarState(
private val cache: ICacheProvider,
private val scope: CoroutineScope,
private val debounceMs: Long = 300L,
) {
private val _query = MutableStateFlow(SearchQuery.EMPTY)
val query: StateFlow<SearchQuery> = _query.asStateFlow()
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 debouncedQuery: StateFlow<SearchQuery> =
_query
.debounce(debounceMs)
.stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY)
// People search results (from cache + relay)
private val _peopleResults = MutableStateFlow<ImmutableList<User>>(persistentListOf())
val peopleResults: StateFlow<ImmutableList<User>> = _peopleResults.asStateFlow()
// Note/event results (from relay subscriptions)
private val _noteResults = MutableStateFlow<ImmutableList<Event>>(persistentListOf())
val noteResults: StateFlow<ImmutableList<Event>> = _noteResults.asStateFlow()
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
// Author name suggestions for autocomplete
private val _authorSuggestions = MutableStateFlow<ImmutableList<User>>(persistentListOf())
val authorSuggestions: StateFlow<ImmutableList<User>> = _authorSuggestions.asStateFlow()
// Expanded panel state
private val _panelExpanded = MutableStateFlow(false)
val panelExpanded: StateFlow<Boolean> = _panelExpanded.asStateFlow()
// Text bar input
fun updateFromText(rawText: String) {
_changeSource = ChangeSource.TEXT
_rawText.value = rawText
_query.value = QueryParser.parse(rawText)
}
// Form panel inputs
fun updateKinds(kinds: List<Int>) {
_changeSource = ChangeSource.FORM
_query.value = _query.value.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 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 updateDateRange(
since: Long?,
until: Long?,
) {
_changeSource = ChangeSource.FORM
_query.value = _query.value.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 removeHashtag(tag: String) {
_changeSource = ChangeSource.FORM
val current = _query.value
_query.value = current.copy(hashtags = current.hashtags.filter { it != 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 removeExcludeTerm(term: String) {
_changeSource = ChangeSource.FORM
val current = _query.value
_query.value = current.copy(excludeTerms = current.excludeTerms.filter { it != term }.toImmutableList())
}
fun updateLanguage(lang: String?) {
_changeSource = ChangeSource.FORM
_query.value = _query.value.copy(language = lang)
}
fun togglePanel() {
_panelExpanded.value = !_panelExpanded.value
}
fun clearSearch() {
_changeSource = ChangeSource.INIT
_rawText.value = ""
_query.value = SearchQuery.EMPTY
_peopleResults.value = persistentListOf()
_noteResults.value = persistentListOf()
_isSearching.value = false
}
// Results management (called from subscription callbacks)
fun startSearching() {
_isSearching.value = true
}
fun stopSearching() {
_isSearching.value = false
}
fun clearResults() {
_peopleResults.value = persistentListOf()
_noteResults.value = persistentListOf()
}
fun addPeopleResult(user: User) {
val current = _peopleResults.value
if (current.none { it.pubkeyHex == user.pubkeyHex }) {
_peopleResults.value = (current + user).toImmutableList()
}
}
fun addNoteResults(events: List<Event>) {
val current = _noteResults.value
val existingIds = current.map { it.id }.toSet()
val newEvents = events.filter { it.id !in existingIds }
if (newEvents.isNotEmpty()) {
_noteResults.value = (current + newEvents).sortedByDescending { it.createdAt }.toImmutableList()
}
}
// Name resolution for autocomplete
@Suppress("UNCHECKED_CAST")
fun resolveAuthorName(name: String) {
if (name.length < 2) {
_authorSuggestions.value = persistentListOf()
return
}
val results = cache.findUsersStartingWith(name, 5) as List<User>
_authorSuggestions.value = results.toImmutableList()
}
}