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
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.chess.RelaySyncState
|
||||
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
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.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -79,16 +83,22 @@ class AdvancedSearchBarState(
|
||||
private val _noteResults = MutableStateFlow<ImmutableList<Event>>(persistentListOf())
|
||||
val noteResults: StateFlow<ImmutableList<Event>> = _noteResults.asStateFlow()
|
||||
|
||||
private val activeSubscriptionCount = MutableStateFlow(0)
|
||||
private val activeSubIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val isSearching: StateFlow<Boolean> =
|
||||
activeSubscriptionCount
|
||||
.map { it > 0 }
|
||||
activeSubIds
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
private val eventDeduplicator = EventDeduplicator()
|
||||
|
||||
// Expanded panel state
|
||||
private val _panelExpanded = MutableStateFlow(false)
|
||||
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
|
||||
fun updateFromText(rawText: String) {
|
||||
_changeSource = ChangeSource.TEXT
|
||||
@@ -171,6 +181,49 @@ class AdvancedSearchBarState(
|
||||
_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() {
|
||||
_panelExpanded.value = !_panelExpanded.value
|
||||
}
|
||||
@@ -181,21 +234,35 @@ class AdvancedSearchBarState(
|
||||
_query.value = SearchQuery.EMPTY
|
||||
_peopleResults.value = persistentListOf()
|
||||
_noteResults.value = persistentListOf()
|
||||
activeSubscriptionCount.value = 0
|
||||
_relayStates.value = persistentListOf()
|
||||
activeSubIds.value = emptySet()
|
||||
eventDeduplicator.clear()
|
||||
}
|
||||
|
||||
// Results management (called from subscription callbacks)
|
||||
fun startSearching() {
|
||||
activeSubscriptionCount.update { it + 1 }
|
||||
fun startSearching(subId: String) {
|
||||
activeSubIds.update { it + subId }
|
||||
}
|
||||
|
||||
fun stopSearching() {
|
||||
activeSubscriptionCount.update { maxOf(0, it - 1) }
|
||||
fun stopSearching(subId: String) {
|
||||
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() {
|
||||
_peopleResults.value = persistentListOf()
|
||||
_noteResults.value = persistentListOf()
|
||||
eventDeduplicator.clear()
|
||||
}
|
||||
|
||||
fun addPeopleResult(user: User) {
|
||||
@@ -206,11 +273,9 @@ class AdvancedSearchBarState(
|
||||
}
|
||||
|
||||
fun addNoteResults(events: List<Event>) {
|
||||
val current = _noteResults.value
|
||||
val existingIds = current.map { it.id }.toSet()
|
||||
val newEvents = events.filter { it.id !in existingIds }
|
||||
if (newEvents.isNotEmpty()) {
|
||||
_noteResults.value = (current + newEvents).sortedByDescending { it.createdAt }.toImmutableList()
|
||||
if (events.isNotEmpty()) {
|
||||
val current = _noteResults.value
|
||||
_noteResults.value = (current + events).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 }
|
||||
}
|
||||
Reference in New Issue
Block a user