From 979d07679cc0b7c0f87249e5b4c80d70499ba067 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 11 Mar 2026 09:29:27 +0200 Subject: [PATCH] fix(search): loading indicator, result delivery, and history race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../commons/search/AdvancedSearchBarState.kt | 15 +- .../amethyst/desktop/ui/SearchScreen.kt | 131 ++++++++++-------- .../desktop/ui/search/SearchResultsList.kt | 11 -- 3 files changed, 86 insertions(+), 71 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt index e46bcef97..6a6e23fa1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -33,7 +33,9 @@ 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, @@ -77,8 +79,11 @@ class AdvancedSearchBarState( private val _noteResults = MutableStateFlow>(persistentListOf()) val noteResults: StateFlow> = _noteResults.asStateFlow() - private val _isSearching = MutableStateFlow(false) - val isSearching: StateFlow = _isSearching.asStateFlow() + private val activeSubscriptionCount = MutableStateFlow(0) + val isSearching: StateFlow = + activeSubscriptionCount + .map { it > 0 } + .stateIn(scope, SharingStarted.Eagerly, false) // Expanded panel state private val _panelExpanded = MutableStateFlow(false) @@ -176,16 +181,16 @@ class AdvancedSearchBarState( _query.value = SearchQuery.EMPTY _peopleResults.value = persistentListOf() _noteResults.value = persistentListOf() - _isSearching.value = false + activeSubscriptionCount.value = 0 } // Results management (called from subscription callbacks) fun startSearching() { - _isSearching.value = true + activeSubscriptionCount.update { it + 1 } } fun stopSearching() { - _isSearching.value = false + activeSubscriptionCount.update { maxOf(0, it - 1) } } fun clearResults() { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 74323e8d4..2d87dad25 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -55,6 +56,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -65,8 +67,10 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment 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.input.key.Key @@ -124,6 +128,8 @@ fun SearchScreen( } 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 query by state.query.collectAsState() val debouncedQuery by state.debouncedQuery.collectAsState() @@ -135,19 +141,24 @@ fun SearchScreen( // Bech32 parsing (immediate, no debounce) 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) { - if (connectedRelays.isEmpty() || debouncedQuery.isEmpty) { + if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { return@rememberSubscription null } - // Skip if bech32 detected if (bech32Results.isNotEmpty()) return@rememberSubscription null - state.startSearching() - state.clearResults() - createSearchPeopleSubscription( - relays = connectedRelays, + relays = allRelayUrls, searchQuery = debouncedQuery.text.ifBlank { 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) { - if (connectedRelays.isEmpty() || debouncedQuery.isEmpty) { + if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { return@rememberSubscription null } if (bech32Results.isNotEmpty()) return@rememberSubscription null @@ -182,9 +193,8 @@ fun SearchScreen( SubscriptionConfig( subId = generateSubId("adv-search"), filters = filters, - relays = connectedRelays, + relays = allRelayUrls, onEvent = { event, _, _, _ -> - // Skip metadata events (handled by people search) if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) if (filtered.isNotEmpty()) { @@ -218,11 +228,14 @@ fun SearchScreen( } } - // Save to history when search completes - LaunchedEffect(isSearching, debouncedQuery) { - if (!isSearching && !debouncedQuery.isEmpty) { - SearchHistoryStore.addToHistory(debouncedQuery) - } + // Save to history when search completes (snapshotFlow avoids LaunchedEffect race) + LaunchedEffect(Unit) { + snapshotFlow { isSearching to debouncedQuery } + .collect { (searching, query) -> + if (!searching && !query.isEmpty) { + SearchHistoryStore.addToHistory(query) + } + } } // History state @@ -281,39 +294,53 @@ fun SearchScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedTextField( - value = - TextFieldValue( - text = displayText, - selection = TextRange(displayText.length), - ), - onValueChange = { state.updateFromText(it.text) }, - modifier = - Modifier - .weight(1f) - .focusRequester(focusRequester), - placeholder = { Text("Search notes, people, tags... or use operators") }, - leadingIcon = { - Icon( - Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailingIcon = { - if (displayText.isNotEmpty()) { - IconButton(onClick = { state.clearSearch() }) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box(modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = + TextFieldValue( + text = displayText, + selection = TextRange(displayText.length), + ), + onValueChange = { state.updateFromText(it.text) }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + placeholder = { Text("Search notes, people, tags... or use operators") }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (displayText.isNotEmpty()) { + IconButton(onClick = { state.clearSearch() }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - ) + }, + singleLine = true, + 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() }) { Icon( Icons.Default.Tune, @@ -386,13 +413,7 @@ fun SearchScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) - } else if (isSearching) { - Text( - "Searching relays...", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - } else { + } else if (!isSearching) { // Empty state: show history + saved searches + operator hints SearchEmptyState( historyItems = historyItems, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index f51d186ef..b5bce1111 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -76,20 +76,9 @@ fun SearchResultsList( ) { val people by state.peopleResults.collectAsState() val notes by state.noteResults.collectAsState() - val isSearching by state.isSearching.collectAsState() 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 // Group notes by kind