fix(search): loading indicator, result delivery, and history race

- Replace boolean isSearching with counter-based activeSubscriptionCount
  so loading state stays true until both subs (people + notes) complete
- Add indeterminate LinearProgressIndicator overlaid on search bar bottom
  border with matching 12dp rounding, replacing "Searching relays..." text
- Send search REQs to all configured relays (relayStatuses) instead of
  only connected ones — fixes NIP-50 relays never receiving search queries
- Move clearResults/startSearching out of remember() into LaunchedEffect
  to avoid side effects during composition
- Fix history saving partial queries by replacing LaunchedEffect with
  snapshotFlow to properly observe isSearching transitions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-11 09:29:27 +02:00
parent a702dcc0ee
commit 979d07679c
3 changed files with 86 additions and 71 deletions
@@ -33,7 +33,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
enum class ChangeSource { enum class ChangeSource {
TEXT, TEXT,
@@ -77,8 +79,11 @@ class AdvancedSearchBarState(
private val _noteResults = MutableStateFlow<ImmutableList<Event>>(persistentListOf()) private val _noteResults = MutableStateFlow<ImmutableList<Event>>(persistentListOf())
val noteResults: StateFlow<ImmutableList<Event>> = _noteResults.asStateFlow() val noteResults: StateFlow<ImmutableList<Event>> = _noteResults.asStateFlow()
private val _isSearching = MutableStateFlow(false) private val activeSubscriptionCount = MutableStateFlow(0)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow() val isSearching: StateFlow<Boolean> =
activeSubscriptionCount
.map { it > 0 }
.stateIn(scope, SharingStarted.Eagerly, false)
// Expanded panel state // Expanded panel state
private val _panelExpanded = MutableStateFlow(false) private val _panelExpanded = MutableStateFlow(false)
@@ -176,16 +181,16 @@ class AdvancedSearchBarState(
_query.value = SearchQuery.EMPTY _query.value = SearchQuery.EMPTY
_peopleResults.value = persistentListOf() _peopleResults.value = persistentListOf()
_noteResults.value = persistentListOf() _noteResults.value = persistentListOf()
_isSearching.value = false activeSubscriptionCount.value = 0
} }
// Results management (called from subscription callbacks) // Results management (called from subscription callbacks)
fun startSearching() { fun startSearching() {
_isSearching.value = true activeSubscriptionCount.update { it + 1 }
} }
fun stopSearching() { fun stopSearching() {
_isSearching.value = false activeSubscriptionCount.update { maxOf(0, it - 1) }
} }
fun clearResults() { fun clearResults() {
@@ -27,6 +27,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -55,6 +56,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -65,8 +67,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@@ -124,6 +128,8 @@ fun SearchScreen(
} }
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
val displayText by state.displayText.collectAsState() val displayText by state.displayText.collectAsState()
val query by state.query.collectAsState() val query by state.query.collectAsState()
val debouncedQuery by state.debouncedQuery.collectAsState() val debouncedQuery by state.debouncedQuery.collectAsState()
@@ -135,19 +141,24 @@ fun SearchScreen(
// Bech32 parsing (immediate, no debounce) // Bech32 parsing (immediate, no debounce)
val bech32Results = remember(displayText) { parseSearchInput(displayText) } val bech32Results = remember(displayText) { parseSearchInput(displayText) }
// NIP-50 people search subscription // Clear results and start loading when query changes
LaunchedEffect(debouncedQuery) {
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
state.clearResults()
state.startSearching()
state.startSearching()
}
}
// NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect)
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
if (connectedRelays.isEmpty() || debouncedQuery.isEmpty) { if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
return@rememberSubscription null return@rememberSubscription null
} }
// Skip if bech32 detected
if (bech32Results.isNotEmpty()) return@rememberSubscription null if (bech32Results.isNotEmpty()) return@rememberSubscription null
state.startSearching()
state.clearResults()
createSearchPeopleSubscription( createSearchPeopleSubscription(
relays = connectedRelays, relays = allRelayUrls,
searchQuery = searchQuery =
debouncedQuery.text.ifBlank { debouncedQuery.text.ifBlank {
QuerySerializer.serialize(debouncedQuery) QuerySerializer.serialize(debouncedQuery)
@@ -169,9 +180,9 @@ fun SearchScreen(
) )
} }
// NIP-50 advanced note search subscription (kinds beyond people) // NIP-50 advanced note search subscription (use allRelayUrls)
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
if (connectedRelays.isEmpty() || debouncedQuery.isEmpty) { if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
return@rememberSubscription null return@rememberSubscription null
} }
if (bech32Results.isNotEmpty()) return@rememberSubscription null if (bech32Results.isNotEmpty()) return@rememberSubscription null
@@ -182,9 +193,8 @@ fun SearchScreen(
SubscriptionConfig( SubscriptionConfig(
subId = generateSubId("adv-search"), subId = generateSubId("adv-search"),
filters = filters, filters = filters,
relays = connectedRelays, relays = allRelayUrls,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Skip metadata events (handled by people search)
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery)
if (filtered.isNotEmpty()) { if (filtered.isNotEmpty()) {
@@ -218,10 +228,13 @@ fun SearchScreen(
} }
} }
// Save to history when search completes // Save to history when search completes (snapshotFlow avoids LaunchedEffect race)
LaunchedEffect(isSearching, debouncedQuery) { LaunchedEffect(Unit) {
if (!isSearching && !debouncedQuery.isEmpty) { snapshotFlow { isSearching to debouncedQuery }
SearchHistoryStore.addToHistory(debouncedQuery) .collect { (searching, query) ->
if (!searching && !query.isEmpty) {
SearchHistoryStore.addToHistory(query)
}
} }
} }
@@ -281,6 +294,7 @@ fun SearchScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Box(modifier = Modifier.weight(1f)) {
OutlinedTextField( OutlinedTextField(
value = value =
TextFieldValue( TextFieldValue(
@@ -288,10 +302,7 @@ fun SearchScreen(
selection = TextRange(displayText.length), selection = TextRange(displayText.length),
), ),
onValueChange = { state.updateFromText(it.text) }, onValueChange = { state.updateFromText(it.text) },
modifier = modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
Modifier
.weight(1f)
.focusRequester(focusRequester),
placeholder = { Text("Search notes, people, tags... or use operators") }, placeholder = { Text("Search notes, people, tags... or use operators") },
leadingIcon = { leadingIcon = {
Icon( Icon(
@@ -314,6 +325,22 @@ fun SearchScreen(
singleLine = true, singleLine = true,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
) )
androidx.compose.animation.AnimatedVisibility(
visible = isSearching,
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 1.dp)
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
IconButton(onClick = { state.togglePanel() }) { IconButton(onClick = { state.togglePanel() }) {
Icon( Icon(
Icons.Default.Tune, Icons.Default.Tune,
@@ -386,13 +413,7 @@ fun SearchScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} else if (isSearching) { } else if (!isSearching) {
Text(
"Searching relays...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
} else {
// Empty state: show history + saved searches + operator hints // Empty state: show history + saved searches + operator hints
SearchEmptyState( SearchEmptyState(
historyItems = historyItems, historyItems = historyItems,
@@ -76,20 +76,9 @@ fun SearchResultsList(
) { ) {
val people by state.peopleResults.collectAsState() val people by state.peopleResults.collectAsState()
val notes by state.noteResults.collectAsState() val notes by state.noteResults.collectAsState()
val isSearching by state.isSearching.collectAsState()
val hasResults = people.isNotEmpty() || notes.isNotEmpty() val hasResults = people.isNotEmpty() || notes.isNotEmpty()
if (!hasResults && isSearching) {
Text(
"Searching relays...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier.padding(vertical = 8.dp),
)
return
}
if (!hasResults) return if (!hasResults) return
// Group notes by kind // Group notes by kind