feat(search): show which relays the search is still waiting on

The spinner could only ever say "still going", and it said it off a stopwatch:
`settled` means "1200ms since you last typed", not "the relays answered". So a
search that hung looked the same as one that had finished, and neither said why.

The sub-assemblers knew all along. They build the per-relay filters and they are
handed every EOSE — the information was reaching `EOSEByKey` for `since`
bookkeeping and going no further. `SearchQueryState` now records both halves:

- `asked`, written as the filters are built
- `answered`, written from `newEose`

Additive and keyed by the query, because the posts and the people assemblers run
off the same state; a call that replaced the set would leave whichever ran second
looking like the only one that asked anything. `newEose` had to become `open` for
a subclass to see it.

Tapping the spinner now lists the relays outstanding, with the ones already in
below them dimmed, under "Waiting on 2 of 5 relays".

The spinner itself changed with it. The timer is now only the floor — something
has to show in the moment before any relay can reply — and after that the real
signal takes over: it turns while a relay still owes an EOSE. Otherwise it would
vanish 1.2s in and the popup would be untappable, which is how I found this
worth doing.

A relay that never sends EOSE would spin forever, so there is a 12s ceiling on
how long the spinner will admit to waiting. Past it the spinner stops and the
list still names who never answered.

Verified on device: "Waiting on 2 of 5 relays", antiprimal.net and
relay.ditto.pub outstanding, nostr.wine / relay.noswhere.com / search.nos.today
dimmed as answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-10 13:14:07 -04:00
co-authored by Claude Opus 5
parent 3328447e87
commit 73a9d37f2d
7 changed files with 181 additions and 9 deletions
@@ -74,11 +74,13 @@ import com.vitorpamplona.quartz.utils.startsWithAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
@@ -179,6 +181,10 @@ class SearchBarViewModel(
val queryAsksNothing get() = state.asksNothing
val searchSettled get() = state.settled
/** Relays this query went to, and the ones still to answer. Drives the waiting popup. */
val relaysAsked get() = searchDataSourceState.asked
val relaysAnswered get() = searchDataSourceState.answered
val scopePinnedToNotes get() = state.scopePinnedToNotes
val scope get() = state.scope
@@ -526,11 +532,33 @@ class SearchBarViewModel(
* been correct, which is why "nothing found" timed out while the spinner did not.
*/
private var settled by mutableStateOf(false)
private var relaysAskedNow by mutableStateOf<Set<NormalizedRelayUrl>>(emptySet())
private var relaysAnsweredNow by mutableStateOf<Set<NormalizedRelayUrl>>(emptySet())
/**
* Stops the spinner claiming to wait forever on a relay that will never answer.
*
* Some relays simply never send EOSE. Without a ceiling the spinner would turn for the life
* of the screen, which is the behaviour this was meant to end.
*/
private var pastDeadline by mutableStateOf(false)
init {
viewModelScope.launch { state.settled.collect { settled = it } }
viewModelScope.launch { searchDataSourceState.asked.collect { relaysAskedNow = it } }
viewModelScope.launch { searchDataSourceState.answered.collect { relaysAnsweredNow = it } }
viewModelScope.launch {
state.text.collectLatest {
pastDeadline = false
delay(RELAY_WAIT_CEILING_MS)
pastDeadline = true
}
}
}
/** The relays this query went to that have not sent EOSE yet. */
val relaysWaiting = derivedStateOf { relaysAskedNow - relaysAnsweredNow }
/**
* True while a search is actually under way.
*
@@ -543,7 +571,14 @@ class SearchBarViewModel(
* No EOSE from the search subscription reaches this screen, so "under way" is the same
* heuristic the empty state already trusts: the grace window since the query last changed.
*/
override val isRefreshing = derivedStateOf { searchValue.isNotBlank() && !settled }
override val isRefreshing =
derivedStateOf {
// The timer is the floor -- something has to show in the moment before any relay can
// possibly reply. After that the real signal takes over: a relay that has not sent
// EOSE is one the results are still missing, and the popup on the spinner names it.
searchValue.isNotBlank() &&
(!settled || (relaysWaiting.value.isNotEmpty() && !pastDeadline))
}
/**
* Could this text name an event rather than describe one? A bech32 pointer, or a run of hex
@@ -587,6 +622,9 @@ class SearchBarViewModel(
fun isSearchingFun() = searchValue.isNotBlank()
companion object {
/** How long the spinner will admit to waiting on a relay that has gone quiet. */
private const val RELAY_WAIT_CEILING_MS = 12_000L
/**
* At most one cache-driven rescan per this long.
*
@@ -45,6 +45,8 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
@@ -106,6 +108,7 @@ import com.vitorpamplona.amethyst.commons.resources.search_source_local
import com.vitorpamplona.amethyst.commons.resources.search_source_relays
import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin
import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin_explainer
import com.vitorpamplona.amethyst.commons.resources.search_waiting_on_relays
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.commons.search.SearchScope
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
@@ -139,7 +142,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@@ -683,11 +688,7 @@ private fun SearchTextField(
// that is still running is exactly when a reader wants to abandon
// it.
if (searchBarViewModel.isRefreshing.value) {
CircularProgressIndicator(
modifier = Size20Modifier,
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.placeholderText,
)
RelayWaitProgress(searchBarViewModel)
}
IconButton(onClick = { searchBarViewModel.clear() }) {
ClearTextIcon()
@@ -969,3 +970,61 @@ fun HashtagLine(
}
}
}
/**
* The search spinner, and what it is waiting on.
*
* The spinner alone can only say "still going", and it says that off a timer -- there is no EOSE
* on this path, so `settled` means "long enough since you typed", not "the relays answered". The
* sub-assemblers do know which relays were asked and which have sent EOSE, so tapping the spinner
* shows exactly that, and a search that looks stuck names the relay it is stuck on.
*
* Relays that never send EOSE stay in the waiting list, which is the true answer rather than a
* tidy one.
*/
@Composable
private fun RelayWaitProgress(viewModel: SearchBarViewModel) {
var showing by remember { mutableStateOf(false) }
val waiting = viewModel.relaysWaiting.value
val asked by viewModel.relaysAsked.collectAsStateWithLifecycle()
val answered by viewModel.relaysAnswered.collectAsStateWithLifecycle()
Box {
CircularProgressIndicator(
modifier = Size20Modifier.clickable { showing = true },
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.placeholderText,
)
DropdownMenu(expanded = showing, onDismissRequest = { showing = false }) {
Text(
text = stringRes(Res.string.search_waiting_on_relays, waiting.size, asked.size),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp),
)
waiting.sortedBy { it.url }.forEach { relay ->
DropdownMenuItem(
text = { Text(relay.displayUrl(), style = MaterialTheme.typography.bodySmall) },
onClick = { showing = false },
)
}
// The ones already in, dimmed: "three of eight" is easier to read against the list it
// came from than on its own.
(asked intersect answered).sortedBy { it.url }.forEach { relay ->
DropdownMenuItem(
text = {
Text(
text = relay.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
},
onClick = { showing = false },
)
}
}
}
}
@@ -1396,6 +1396,7 @@
<string name="external_id_scope">Comment on an external resource</string>
<string name="url_preview_open_in_browser">Open in browser</string>
<string name="long_form_reading_minutes">%1$d min read</string>
<string name="search_waiting_on_relays">Waiting on %1$d of %2$d relays</string>
<plurals name="publication_section_count">
<item quantity="one">%1$d section</item>
<item quantity="other">%1$d sections</item>
@@ -56,7 +56,8 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
fun since(key: T) = latestEOSEs.since(id(key))
fun newEose(
/** Open so a subclass can also record who answered, not just when. */
open fun newEose(
key: T,
relayUrl: NormalizedRelayUrl,
time: Long,
@@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
@@ -89,7 +91,21 @@ class SearchPostWatcherSubAssembler(
searchPostsByText(mySearchString, it)
}
return directFilters + searchFilters
val filters = directFilters + searchFilters
key.startedAsking(mySearchString, filters.mapTo(mutableSetOf()) { it.relay })
return filters
}
// The base class keeps EOSEs for `since`; the screen needs to know who has answered so it can
// say what it is still waiting on.
override fun newEose(
key: SearchQueryState,
relayUrl: NormalizedRelayUrl,
time: Long,
filters: List<Filter>?,
) {
super.newEose(key, relayUrl, time, filters)
key.answeredBy(relayUrl)
}
override fun id(key: SearchQueryState) = key.searchQuery.hashCode()
@@ -48,4 +48,46 @@ class SearchQueryState(
) : MutableQueryState,
AccountScopedQuery {
override fun flow(): Flow<String> = searchQuery
/**
* Which relays this query went to, and which have answered.
*
* The screen otherwise has no idea: it guesses a search is done by waiting a fixed moment
* after the last keystroke, because nothing told it otherwise. The sub-assemblers know both
* halves already -- they build the per-relay filters, and they are handed every EOSE -- so
* they record it here rather than keeping it to themselves.
*
* "Waiting on" is [asked] minus [answered]. A relay that never sends EOSE simply stays in it,
* which is the honest answer and the reason the timer stays as a backstop rather than being
* replaced by this.
*/
val asked = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val answered = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** The query the two sets describe, so a new one starts from nothing. */
private var askedFor: String? = null
/**
* Records the relays one sub-assembler is about to ask for [query].
*
* Additive, and reset by the query changing rather than by being called: the posts and the
* people assemblers both rebuild off this same state, so a call that replaced the set would
* leave whichever ran second looking like the only one that asked anything.
*/
fun startedAsking(
query: String,
relays: Set<NormalizedRelayUrl>,
) {
if (askedFor != query) {
askedFor = query
asked.value = relays
answered.value = emptySet()
} else {
asked.value = asked.value + relays
}
}
fun answeredBy(relay: NormalizedRelayUrl) {
answered.value = answered.value + relay
}
}
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
@@ -93,7 +95,20 @@ class SearchUserWatcherSubAssembler(
searchPeopleByName(mySearchString, it)
}
return directFilters + searchFilters
val filters = directFilters + searchFilters
key.startedAsking(mySearchString, filters.mapTo(mutableSetOf()) { it.relay })
return filters
}
// See the posts assembler: the screen needs to know who has answered, not just when.
override fun newEose(
key: SearchQueryState,
relayUrl: NormalizedRelayUrl,
time: Long,
filters: List<Filter>?,
) {
super.newEose(key, relayUrl, time, filters)
key.answeredBy(relayUrl)
}
override fun id(key: SearchQueryState) = key.searchQuery.hashCode()