fix(desktop-cache): scope consumeContactList to active user + fan out to SharedFlow

The old implementation tracked kind-3 replaceability with a single global
`lastContactListCreatedAt` scalar and unconditionally overwrote
`_followedUsers` on every accepted event. Once *any* subsystem starts
fetching other users' kind-3 events (WoT scoring, mutual-follow lookups,
etc.), a newer-createdAt kind-3 from a follower silently hijacks the
active user's follow-set state, cascading into feed filters, mute logic,
and sidebar counts.

Fix by tracking newest-per-author (`ConcurrentHashMap<HexKey, Long>`) and
guarding writes to `_followedUsers` / `lastContactListEvent` on
`event.pubKey == accountPubkey`. `accountPubkey` is bound from `Main.kt`
on login.

Also expose `contactListEvents: SharedFlow<ContactListEvent>` (buffer 64,
DROP_OLDEST) so downstream consumers (the incoming WoT service) can
observe every accepted kind-3 without adding a bespoke listener API.
This commit is contained in:
nrobi144
2026-07-02 18:03:12 +03:00
parent e6728f8393
commit 741c377f80
@@ -66,6 +66,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
@@ -91,6 +92,27 @@ class DesktopLocalCache : ICacheProvider {
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
/**
* Active user's pubkey (hex). Set from Main.kt on login. When set, only
* kind-3 events from this pubkey update [_followedUsers] and
* [lastContactListEvent]. Other users' kind-3 events still flow through
* [contactListEvents] for consumers like the WoT service.
*/
@Volatile
var accountPubkey: HexKey? = null
/**
* Fires for every accepted kind-3 event (both the active user's and
* other users' — filtered downstream). Buffered so slow consumers don't
* block the consume path.
*/
private val _contactListEvents =
MutableSharedFlow<ContactListEvent>(
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val contactListEvents: SharedFlow<ContactListEvent> = _contactListEvents.asSharedFlow()
/** Increments on each metadata update — observe to recompose when user names change. */
private val _metadataVersion = MutableStateFlow(0L)
val metadataVersion: StateFlow<Long> = _metadataVersion.asStateFlow()
@@ -472,19 +494,28 @@ class DesktopLocalCache : ICacheProvider {
/**
* Consumes a kind 3 contact list event (replaceable).
* Updates the cached followedUsers set.
*
* Tracks the newest kind-3 per author (not a single global scalar) so
* ingesting other users' follow lists (e.g. for WoT scoring) doesn't
* corrupt the active user's state. Only the active user's kind-3 updates
* [_followedUsers] / [lastContactListEvent]. Every accepted event fans
* out on [_contactListEvents] for downstream consumers.
*/
private var lastContactListCreatedAt = 0L
private val lastContactListByAuthor = ConcurrentHashMap<HexKey, Long>()
var lastContactListEvent: ContactListEvent? = null
private set
private fun consumeContactList(event: ContactListEvent): Boolean {
// Replaceable event — only accept newer contact lists
if (event.createdAt <= lastContactListCreatedAt) return false
lastContactListCreatedAt = event.createdAt
lastContactListEvent = event
_followedUsers.value = event.verifiedFollowKeySet()
// Replaceable event — only accept newer contact lists per author
val prev = lastContactListByAuthor[event.pubKey] ?: 0L
if (event.createdAt <= prev) return false
lastContactListByAuthor[event.pubKey] = event.createdAt
if (event.pubKey == accountPubkey) {
lastContactListEvent = event
_followedUsers.value = event.verifiedFollowKeySet()
}
// Store in addressableNotes too — Kind3FollowListState.getFollowListEvent
// reads from getOrCreateAddressableNote(...) and would otherwise see a
@@ -493,6 +524,8 @@ class DesktopLocalCache : ICacheProvider {
val addressableNote = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
addressableNote.loadEvent(event, author, emptyList())
_contactListEvents.tryEmit(event)
return true
}
@@ -786,7 +819,9 @@ class DesktopLocalCache : ICacheProvider {
followerCounts.clear()
followingCounts.clear()
notesByAuthor.clear()
lastContactListCreatedAt = 0L
lastContactListByAuthor.clear()
lastContactListEvent = null
accountPubkey = null
}
}