refactor(search): extract thread-safe EventDeduplicator, unify dedup
Replace raw MutableSet with synchronized EventDeduplicator class. Remove dual dedup in addNoteResults — trackRelayEvent now gates all event processing. SearchScreen callbacks skip dupes early via return value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+78
-13
@@ -20,8 +20,12 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.search
|
package com.vitorpamplona.amethyst.commons.search
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncState
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@@ -79,16 +83,22 @@ 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 activeSubscriptionCount = MutableStateFlow(0)
|
private val activeSubIds = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val isSearching: StateFlow<Boolean> =
|
val isSearching: StateFlow<Boolean> =
|
||||||
activeSubscriptionCount
|
activeSubIds
|
||||||
.map { it > 0 }
|
.map { it.isNotEmpty() }
|
||||||
.stateIn(scope, SharingStarted.Eagerly, false)
|
.stateIn(scope, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
|
private val eventDeduplicator = EventDeduplicator()
|
||||||
|
|
||||||
// Expanded panel state
|
// Expanded panel state
|
||||||
private val _panelExpanded = MutableStateFlow(false)
|
private val _panelExpanded = MutableStateFlow(false)
|
||||||
val panelExpanded: StateFlow<Boolean> = _panelExpanded.asStateFlow()
|
val panelExpanded: StateFlow<Boolean> = _panelExpanded.asStateFlow()
|
||||||
|
|
||||||
|
// Per-relay sync status
|
||||||
|
private val _relayStates = MutableStateFlow<ImmutableList<RelaySyncState>>(persistentListOf())
|
||||||
|
val relayStates: StateFlow<ImmutableList<RelaySyncState>> = _relayStates.asStateFlow()
|
||||||
|
|
||||||
// Text bar input
|
// Text bar input
|
||||||
fun updateFromText(rawText: String) {
|
fun updateFromText(rawText: String) {
|
||||||
_changeSource = ChangeSource.TEXT
|
_changeSource = ChangeSource.TEXT
|
||||||
@@ -171,6 +181,49 @@ class AdvancedSearchBarState(
|
|||||||
_query.value = _query.value.copy(language = lang)
|
_query.value = _query.value.copy(language = lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun initRelayStates(relays: Set<NormalizedRelayUrl>) {
|
||||||
|
_relayStates.value =
|
||||||
|
relays
|
||||||
|
.map {
|
||||||
|
RelaySyncState(
|
||||||
|
url = it.url,
|
||||||
|
displayName = it.displayUrl(),
|
||||||
|
status = RelaySyncStatus.WAITING,
|
||||||
|
)
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRelayState(
|
||||||
|
relayUrl: String,
|
||||||
|
status: RelaySyncStatus,
|
||||||
|
eventsDelta: Int = 0,
|
||||||
|
) {
|
||||||
|
_relayStates.update { states ->
|
||||||
|
states
|
||||||
|
.map {
|
||||||
|
if (it.url == relayUrl) {
|
||||||
|
it.copy(status = status, eventsReceived = it.eventsReceived + eventsDelta)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun timeoutWaitingRelays() {
|
||||||
|
_relayStates.update { states ->
|
||||||
|
states
|
||||||
|
.map {
|
||||||
|
if (it.status == RelaySyncStatus.WAITING || it.status == RelaySyncStatus.CONNECTING) {
|
||||||
|
it.copy(status = RelaySyncStatus.FAILED)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
activeSubIds.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
fun togglePanel() {
|
fun togglePanel() {
|
||||||
_panelExpanded.value = !_panelExpanded.value
|
_panelExpanded.value = !_panelExpanded.value
|
||||||
}
|
}
|
||||||
@@ -181,21 +234,35 @@ class AdvancedSearchBarState(
|
|||||||
_query.value = SearchQuery.EMPTY
|
_query.value = SearchQuery.EMPTY
|
||||||
_peopleResults.value = persistentListOf()
|
_peopleResults.value = persistentListOf()
|
||||||
_noteResults.value = persistentListOf()
|
_noteResults.value = persistentListOf()
|
||||||
activeSubscriptionCount.value = 0
|
_relayStates.value = persistentListOf()
|
||||||
|
activeSubIds.value = emptySet()
|
||||||
|
eventDeduplicator.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Results management (called from subscription callbacks)
|
// Results management (called from subscription callbacks)
|
||||||
fun startSearching() {
|
fun startSearching(subId: String) {
|
||||||
activeSubscriptionCount.update { it + 1 }
|
activeSubIds.update { it + subId }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopSearching() {
|
fun stopSearching(subId: String) {
|
||||||
activeSubscriptionCount.update { maxOf(0, it - 1) }
|
activeSubIds.update { it - subId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trackRelayEvent(
|
||||||
|
relayUrl: String,
|
||||||
|
eventId: String,
|
||||||
|
): Boolean {
|
||||||
|
val isNew = eventDeduplicator.tryAdd(eventId)
|
||||||
|
if (isNew) {
|
||||||
|
updateRelayState(relayUrl, RelaySyncStatus.RECEIVING, eventsDelta = 1)
|
||||||
|
}
|
||||||
|
return isNew
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearResults() {
|
fun clearResults() {
|
||||||
_peopleResults.value = persistentListOf()
|
_peopleResults.value = persistentListOf()
|
||||||
_noteResults.value = persistentListOf()
|
_noteResults.value = persistentListOf()
|
||||||
|
eventDeduplicator.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addPeopleResult(user: User) {
|
fun addPeopleResult(user: User) {
|
||||||
@@ -206,11 +273,9 @@ class AdvancedSearchBarState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addNoteResults(events: List<Event>) {
|
fun addNoteResults(events: List<Event>) {
|
||||||
val current = _noteResults.value
|
if (events.isNotEmpty()) {
|
||||||
val existingIds = current.map { it.id }.toSet()
|
val current = _noteResults.value
|
||||||
val newEvents = events.filter { it.id !in existingIds }
|
_noteResults.value = (current + events).sortedByDescending { it.createdAt }.toImmutableList()
|
||||||
if (newEvents.isNotEmpty()) {
|
|
||||||
_noteResults.value = (current + newEvents).sortedByDescending { it.createdAt }.toImmutableList()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.search
|
||||||
|
|
||||||
|
class EventDeduplicator {
|
||||||
|
private val lock = Any()
|
||||||
|
private val seenIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) }
|
||||||
|
|
||||||
|
fun contains(id: String): Boolean = synchronized(lock) { id in seenIds }
|
||||||
|
|
||||||
|
fun clear() = synchronized(lock) { seenIds.clear() }
|
||||||
|
|
||||||
|
val size: Int get() = synchronized(lock) { seenIds.size }
|
||||||
|
}
|
||||||
+1
@@ -45,5 +45,6 @@ object DefaultRelays {
|
|||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
"wss://relay.snort.social",
|
"wss://relay.snort.social",
|
||||||
"wss://nostr.wine",
|
"wss://nostr.wine",
|
||||||
|
"wss://relay.noswhere.com",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -145,6 +145,7 @@ fun createSearchPeopleSubscription(
|
|||||||
limit: Int = 50,
|
limit: Int = 50,
|
||||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
onClosed: (NormalizedRelayUrl, String, List<Filter>?) -> Unit = { _, _, _ -> },
|
||||||
): SubscriptionConfig? {
|
): SubscriptionConfig? {
|
||||||
if (searchQuery.isBlank()) return null
|
if (searchQuery.isBlank()) return null
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ fun createSearchPeopleSubscription(
|
|||||||
relays = relays,
|
relays = relays,
|
||||||
onEvent = onEvent,
|
onEvent = onEvent,
|
||||||
onEose = onEose,
|
onEose = onEose,
|
||||||
|
onClosed = onClosed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
@@ -46,6 +46,7 @@ data class SubscriptionConfig(
|
|||||||
val relays: Set<NormalizedRelayUrl>,
|
val relays: Set<NormalizedRelayUrl>,
|
||||||
val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
val onClosed: (NormalizedRelayUrl, String, List<Filter>?) -> Unit = { _, _, _ -> },
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,6 +96,14 @@ fun rememberSubscription(
|
|||||||
) {
|
) {
|
||||||
cfg.onEose(relay, forFilters)
|
cfg.onEose(relay, forFilters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
message: String,
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
cfg.onClosed(relay, message, forFilters)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-65
@@ -27,7 +27,6 @@ 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
|
||||||
@@ -70,7 +69,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
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
|
||||||
@@ -82,6 +80,7 @@ import androidx.compose.ui.text.TextRange
|
|||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||||
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
||||||
@@ -102,6 +101,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
|||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
|
||||||
@@ -137,6 +137,7 @@ fun SearchScreen(
|
|||||||
val isSearching by state.isSearching.collectAsState()
|
val isSearching by state.isSearching.collectAsState()
|
||||||
val peopleResults by state.peopleResults.collectAsState()
|
val peopleResults by state.peopleResults.collectAsState()
|
||||||
val noteResults by state.noteResults.collectAsState()
|
val noteResults by state.noteResults.collectAsState()
|
||||||
|
val relayStates by state.relayStates.collectAsState()
|
||||||
|
|
||||||
// Bech32 parsing (immediate, no debounce)
|
// Bech32 parsing (immediate, no debounce)
|
||||||
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
|
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
|
||||||
@@ -145,8 +146,12 @@ fun SearchScreen(
|
|||||||
LaunchedEffect(debouncedQuery) {
|
LaunchedEffect(debouncedQuery) {
|
||||||
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
||||||
state.clearResults()
|
state.clearResults()
|
||||||
state.startSearching()
|
state.initRelayStates(allRelayUrls)
|
||||||
state.startSearching()
|
state.startSearching("people-search")
|
||||||
|
state.startSearching("adv-search")
|
||||||
|
// Timeout relays that silently ignore NIP-50 (e.g. strfry)
|
||||||
|
kotlinx.coroutines.delay(10_000L)
|
||||||
|
state.timeoutWaitingRelays()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,18 +169,25 @@ fun SearchScreen(
|
|||||||
QuerySerializer.serialize(debouncedQuery)
|
QuerySerializer.serialize(debouncedQuery)
|
||||||
},
|
},
|
||||||
limit = 20,
|
limit = 20,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, relay, _ ->
|
||||||
if (event is MetadataEvent) {
|
if (state.trackRelayEvent(relay.url, event.id)) {
|
||||||
localCache.consumeMetadata(event)
|
if (event is MetadataEvent) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
localCache.consumeMetadata(event)
|
||||||
val user = localCache.getUserIfExists(event.pubKey) as? User
|
@Suppress("UNCHECKED_CAST")
|
||||||
if (user != null) {
|
val user = localCache.getUserIfExists(event.pubKey) as? User
|
||||||
state.addPeopleResult(user)
|
if (user != null) {
|
||||||
|
state.addPeopleResult(user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEose = { _, _ ->
|
onEose = { relay, _ ->
|
||||||
state.stopSearching()
|
state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
|
||||||
|
state.stopSearching("people-search")
|
||||||
|
},
|
||||||
|
onClosed = { relay, _, _ ->
|
||||||
|
state.updateRelayState(relay.url, RelaySyncStatus.FAILED)
|
||||||
|
state.stopSearching("people-search")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -194,15 +206,22 @@ fun SearchScreen(
|
|||||||
subId = generateSubId("adv-search"),
|
subId = generateSubId("adv-search"),
|
||||||
filters = filters,
|
filters = filters,
|
||||||
relays = allRelayUrls,
|
relays = allRelayUrls,
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, relay, _ ->
|
||||||
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
|
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
|
||||||
val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery)
|
if (state.trackRelayEvent(relay.url, event.id)) {
|
||||||
if (filtered.isNotEmpty()) {
|
val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery)
|
||||||
state.addNoteResults(filtered)
|
if (filtered.isNotEmpty()) {
|
||||||
|
state.addNoteResults(filtered)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEose = { _, _ ->
|
onEose = { relay, _ ->
|
||||||
state.stopSearching()
|
state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
|
||||||
|
state.stopSearching("adv-search")
|
||||||
|
},
|
||||||
|
onClosed = { relay, _, _ ->
|
||||||
|
state.updateRelayState(relay.url, RelaySyncStatus.FAILED)
|
||||||
|
state.stopSearching("adv-search")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -269,6 +288,26 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
// Progress bar at very top
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isSearching,
|
||||||
|
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||||
|
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay status banner
|
||||||
|
SearchSyncBanner(
|
||||||
|
relayStates = relayStates,
|
||||||
|
isSearching = isSearching,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Title row
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -294,53 +333,36 @@ 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(
|
text = displayText,
|
||||||
text = displayText,
|
selection = TextRange(displayText.length),
|
||||||
selection = TextRange(displayText.length),
|
),
|
||||||
),
|
onValueChange = { state.updateFromText(it.text) },
|
||||||
onValueChange = { state.updateFromText(it.text) },
|
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
placeholder = { Text("Search notes, people, tags... or use operators") },
|
||||||
placeholder = { Text("Search notes, people, tags... or use operators") },
|
leadingIcon = {
|
||||||
leadingIcon = {
|
Icon(
|
||||||
Icon(
|
Icons.Default.Search,
|
||||||
Icons.Default.Search,
|
contentDescription = "Search",
|
||||||
contentDescription = "Search",
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
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),
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
trailingIcon = {
|
||||||
|
if (displayText.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { state.clearSearch() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Clear,
|
||||||
|
contentDescription = "Clear",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
IconButton(onClick = { state.togglePanel() }) {
|
IconButton(onClick = { state.togglePanel() }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Tune,
|
Icons.Default.Tune,
|
||||||
|
|||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.desktop.ui.search
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.CloudDownload
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncState
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchSyncBanner(
|
||||||
|
relayStates: ImmutableList<RelaySyncState>,
|
||||||
|
isSearching: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val isVisible = isSearching || relayStates.isNotEmpty()
|
||||||
|
var isExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||||
|
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// Collapsed summary row
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { isExpanded = !isExpanded }
|
||||||
|
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
val responded = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
|
||||||
|
val total = relayStates.size
|
||||||
|
val totalEvents = relayStates.sumOf { it.eventsReceived }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (total > 0) {
|
||||||
|
"$responded/$total relays responded \u00B7 $totalEvents events"
|
||||||
|
} else {
|
||||||
|
"Connecting to relays..."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded per-relay details
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded && relayStates.isNotEmpty(),
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
relayStates.forEach { relay ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = relayStatusIcon(relay.status),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = relayStatusColor(relay.status),
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = relay.displayName,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${relay.eventsReceived} events",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relayStatusIcon(status: RelaySyncStatus): ImageVector =
|
||||||
|
when (status) {
|
||||||
|
RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty
|
||||||
|
RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty
|
||||||
|
RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload
|
||||||
|
RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle
|
||||||
|
RelaySyncStatus.FAILED -> Icons.Default.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun relayStatusColor(status: RelaySyncStatus): Color =
|
||||||
|
when (status) {
|
||||||
|
RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary
|
||||||
|
RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary
|
||||||
|
RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary
|
||||||
|
RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary
|
||||||
|
RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user