mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
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:
co-authored by
Claude Opus 5
parent
3328447e87
commit
73a9d37f2d
+39
-1
@@ -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.
|
||||
*
|
||||
|
||||
+64
-5
@@ -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>
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+17
-1
@@ -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()
|
||||
|
||||
+42
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user