fix(nests): evict author from prior room when presence moves to a new room

kind-10312 is replaceable per author, but the room a presence points to
(`a`-tag) can change when a speaker hops between rooms. The replaceable
cache only swaps the addressable's content -- it doesn't know which channel
the old version was attached to, so the prior room kept the stale entry in
both `notes` and the new `presenceNotes` index. NestsFeedFilter would then
falsely surface the prior room as "live" via that author until the entry
dropped out of the freshness window.

Capture the prior room from the existing addressable before consumeBaseReplaceable
swaps it. When the new event is a true replacement (createdAt > prior) and
the room differs, drop the author from the prior channel's presenceNotes
and remove the prior version note from its main notes index.
This commit is contained in:
Claude
2026-04-30 00:56:05 +00:00
parent d87fc36d21
commit 7845072f20
2 changed files with 34 additions and 0 deletions
@@ -1560,12 +1560,27 @@ object LocalCache : ILocalCache, ICacheProvider {
* Also indexed under [LiveActivitiesChannel.presenceNotes] keyed * Also indexed under [LiveActivitiesChannel.presenceNotes] keyed
* by author so the Nests feed can answer "are there speakers on * by author so the Nests feed can answer "are there speakers on
* stage?" without scanning the chat-dominated `notes` map. * stage?" without scanning the chat-dominated `notes` map.
*
* Cross-room move handling: kind-10312 is replaceable per author,
* but the room a presence points to (`a`-tag) can change when a
* speaker hops between rooms. The replaceable cache only swaps the
* addressable's content — it has no notion of which channel the
* old version was attached to. Without explicit eviction the old
* room would keep surfacing as "live" via the stale entry until it
* dropped out of the freshness window. Capture the prior room
* before replacement and, when it differs, drop the author from
* the old channel's presence index and the old version note from
* its main `notes` index.
*/ */
fun consume( fun consume(
event: MeetingRoomPresenceEvent, event: MeetingRoomPresenceEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
wasVerified: Boolean, wasVerified: Boolean,
): Boolean { ): Boolean {
val priorVersion = getAddressableNoteIfExists(event.address())?.event as? MeetingRoomPresenceEvent
val priorRoomAddress = priorVersion?.interactiveRoom()?.address
val isReplacement = priorVersion != null && event.createdAt > priorVersion.createdAt
val new = consumeBaseReplaceable(event, relay, wasVerified) val new = consumeBaseReplaceable(event, relay, wasVerified)
// The replaceable cache keys this on the AUTHOR's address // The replaceable cache keys this on the AUTHOR's address
@@ -1574,6 +1589,14 @@ object LocalCache : ILocalCache, ICacheProvider {
// note to the room's channel keyed by its kind-30312 address. // note to the room's channel keyed by its kind-30312 address.
val roomAddress = event.interactiveRoom()?.address ?: return new val roomAddress = event.interactiveRoom()?.address ?: return new
if (roomAddress.kind != MeetingSpaceEvent.KIND) return new if (roomAddress.kind != MeetingSpaceEvent.KIND) return new
if (isReplacement && priorRoomAddress != null && priorRoomAddress != roomAddress) {
getLiveActivityChannelIfExists(priorRoomAddress)?.let { priorChannel ->
priorChannel.removePresenceNote(event.pubKey)
getNoteIfExists(priorVersion.id)?.let { priorChannel.removeNote(it) }
}
}
val channel = getOrCreateLiveChannel(roomAddress) val channel = getOrCreateLiveChannel(roomAddress)
val versionNote = getOrCreateNote(event.id) val versionNote = getOrCreateNote(event.id)
channel.addNote(versionNote, relay) channel.addNote(versionNote, relay)
@@ -60,6 +60,17 @@ class LiveActivitiesChannel(
presenceNotes.put(author, note) presenceNotes.put(author, note)
} }
/**
* Drop an author's presence entry. Called by `LocalCache` when a
* replaceable kind-10312 from this author lands in a *different*
* room without this eviction, the old room would keep surfacing
* as "live" via stale presence until it drops out of the
* freshness window.
*/
fun removePresenceNote(author: HexKey) {
presenceNotes.remove(author)
}
fun address() = address fun address() = address
override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays() override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays()