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:
+10
-5
@@ -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() {
|
||||||
|
|||||||
+47
-26
@@ -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,
|
||||||
|
|||||||
-11
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user