fix(nests): reactions dedup, drift-fade animation, 10s window

Audit-driven fixes for the audio-room reactions overlay:

1. RoomReactionsAggregator now dedups by event id. The previous code
   appended every event to a flat list, but LocalCache.observeEvents
   re-emits the full matching list on every cache mutation — so an
   N-reaction window grew quadratically and the overlay rendered the
   same emoji once per replay. Keyed by event id collapses re-emits.

2. RoomPresenceAggregator gains applyOrNull that returns null when an
   incoming heartbeat is older-or-equal to the cached presence; the VM
   skips the StateFlow write in that case. In a 200-peer room, every
   replay used to copy a 200-entry map plus run an O(N) StateFlow
   equality check 200 times per emission. Now it's one-and-skip.

3. ReactionsEvictionTicker only ticks while the aggregator is non-empty.
   Once the last reaction expires the loop self-cancels until the next
   reaction lands — a quiet room costs no scheduled work.

4. REACTION_WINDOW_SEC dropped 30s -> 10s. Reactions are about what
   the speaker is saying right now; a 10s window keeps the overlay
   timely instead of bleeding into the next paragraph.

5. SpeakerReactionOverlay drives a per-chip lifecycle animation: each
   chip drifts upward ~16dp and fades over the 10s window. A fresh
   reaction (same emoji) restarts the chip's animation. The
   AnimatedVisibility outer entry/exit still smooths first-arrival and
   final disappearance.

Tests: added a re-emit dedup case to RoomReactionsStateTest plus an
end-to-end replay assertion in NestViewModelTest. Existing tests
updated to use unique event ids.

https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
This commit is contained in:
Claude
2026-05-01 19:24:48 +00:00
parent c2fd8727e0
commit 3acd84bf76
7 changed files with 196 additions and 46 deletions
@@ -139,10 +139,11 @@ class NestViewModel(
/**
* Chat ledger for the live-activities chat panel (#1) — every
* kind-1311 ([LiveActivitiesChatMessageEvent])
* tagged with this room's `a`-pointer, ordered by `created_at`
* ascending so the newest message is at the end (the panel
* auto-scrolls to it).
* kind-1311 ([LiveActivitiesChatMessageEvent]) tagged with this
* room's `a`-pointer, ordered by [DefaultFeedOrder] (descending
* `created_at`, then by id for stability). The chat panel renders
* with `LazyColumn(reverseLayout = true)`, which puts data index 0
* (the newest message) at the bottom of the viewport.
*
* Dedupes by event id — a relay re-emit on reconnect can't
* produce a duplicate row.
@@ -155,8 +156,8 @@ class NestViewModel(
/**
* Recent kind-7 reactions for the floating speaker-avatar overlay
* (#3). Keyed by target pubkey; room-wide reactions land under the
* empty-string key. Sliding 30 s window driven by the platform
* layer's tick (typically every 1 s).
* empty-string key. Sliding [REACTION_WINDOW_SEC] window driven
* by the platform layer's tick (typically every 1 s).
*/
private val reactionsAgg = RoomReactionsAggregator()
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
@@ -371,7 +372,11 @@ class NestViewModel(
*/
fun onPresenceEvent(event: com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent) {
if (closed) return
_presences.value = presenceAgg.apply(event)
// Skip the StateFlow write when the aggregator says nothing
// changed — saves a snapshot copy + an O(N) map equality check
// per replay in big rooms (LocalCache.observeEvents re-emits
// the full list on every cache mutation).
presenceAgg.applyOrNull(event)?.let { _presences.value = it }
}
/**
@@ -401,8 +406,8 @@ class NestViewModel(
/**
* Apply one kind-7 reaction event to the sliding-window aggregator.
* Caller passes [nowSec] (so tests can be deterministic) and the
* fixed 30-s window. Mirror of [evictReactions] for the per-tick
* cleanup.
* fixed [REACTION_WINDOW_SEC] window. Mirror of [evictReactions]
* for the per-tick cleanup.
*/
fun onReactionEvent(
event: com.vitorpamplona.quartz.nip25Reactions.ReactionEvent,
@@ -1222,10 +1227,13 @@ const val LEVEL_TICK_MS: Long = 100L
/**
* How long a kind-7 reaction stays in
* [NestViewModel.recentReactions] before the eviction sweep
* drops it. Matches the duration of the floating-up animation in the
* SpeakerReactionOverlay.
* drops it. Reactions are about what the speaker is talking about
* RIGHT NOW — a 10 s window keeps the floating-emoji overlay
* timely (a reaction to one sentence shouldn't bleed into the
* next paragraph) and matches the duration of the fade-up
* animation in the SpeakerReactionOverlay.
*/
const val REACTION_WINDOW_SEC: Long = 30L
const val REACTION_WINDOW_SEC: Long = 10L
/**
* Indirection over the top-level `connectNestsListener` so tests can drive
@@ -79,13 +79,31 @@ data class RoomPresence(
class RoomPresenceAggregator {
private val byPubkey = mutableMapOf<String, RoomPresence>()
/** Apply one presence event. Returns the updated snapshot. */
fun apply(event: MeetingRoomPresenceEvent): Map<String, RoomPresence> {
/**
* Apply one presence event. Returns a fresh snapshot when state
* actually changed, or `null` when the event was a no-op (older
* or equal to the cached presence for the same pubkey).
*
* `LocalCache.observeEvents` re-emits the full matching list on
* every cache mutation, so in a 200-peer room the VM forwards
* 200 events for a single new heartbeat. Returning `null` for
* the 199 no-op replays lets the VM skip the
* `_presences.value = ...` write — saves both the snapshot copy
* and the StateFlow equality check (both O(N) per call).
*/
fun applyOrNull(event: MeetingRoomPresenceEvent): Map<String, RoomPresence>? {
val incoming = RoomPresence.from(event)
val current = byPubkey[incoming.pubkey]
if (current == null || current.updatedAtSec < incoming.updatedAtSec) {
byPubkey[incoming.pubkey] = incoming
if (current != null && current.updatedAtSec >= incoming.updatedAtSec) {
return null
}
byPubkey[incoming.pubkey] = incoming
return snapshot()
}
/** Apply-or-no-op variant kept for tests + callers that always want a snapshot. */
fun apply(event: MeetingRoomPresenceEvent): Map<String, RoomPresence> {
applyOrNull(event)
return snapshot()
}
@@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
*/
@Immutable
data class RoomReaction(
/** Source event id — used for dedup across re-emits. */
val eventId: String,
/** Who reacted. */
val sourcePubkey: String,
/** Speaker the reaction is aimed at, or `null` for room-wide. */
@@ -52,6 +54,7 @@ data class RoomReaction(
*/
fun from(event: ReactionEvent): RoomReaction =
RoomReaction(
eventId = event.id,
sourcePubkey = event.pubKey,
targetPubkey = event.originalAuthor().firstOrNull(),
content = event.content,
@@ -61,16 +64,19 @@ data class RoomReaction(
}
/**
* Sliding-window aggregator. Holds reactions keyed by target pubkey
* (with `null` lumped under the empty-string key so the map's
* value-type is uniform); the room screen reads
* `byTarget()` per render and the UI naturally fades as the window
* slides.
* Sliding-window aggregator. Holds reactions keyed by event id (so
* re-emits from `LocalCache.observeEvents` — which republishes the
* full matching list on every cache mutation — collapse into a
* single overlay entry instead of stacking duplicates), and surfaces
* them grouped by target pubkey for the UI (with `null` lumped under
* the empty-string key so the map's value-type is uniform).
*
* Not thread-safe — call from the VM's single coroutine.
*/
class RoomReactionsAggregator {
private val all = mutableListOf<RoomReaction>()
// Insertion-ordered so groupBy preserves arrival order; keyed by
// event id so the same kind-7 from two relays only counts once.
private val byEventId = LinkedHashMap<String, RoomReaction>()
/** Stable key for room-wide reactions in the returned map. */
private val roomWideKey = ""
@@ -81,7 +87,13 @@ class RoomReactionsAggregator {
nowSec: Long,
windowSec: Long,
): Map<String, List<RoomReaction>> {
all += RoomReaction.from(event)
val incoming = RoomReaction.from(event)
// Dedup: a relay re-delivery (or LocalCache.observeEvents's
// full-list re-emit) of the same kind-7 must not stack.
if (byEventId.put(incoming.eventId, incoming) != null) {
// Re-emit of an existing reaction: just return the post-
// evict snapshot without growing the map.
}
return evictAndSnapshot(nowSec - windowSec)
}
@@ -92,7 +104,13 @@ class RoomReactionsAggregator {
* per-Composable timer).
*/
fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> {
all.removeAll { it.createdAtSec < olderThanSec }
return all.groupBy { it.targetPubkey ?: roomWideKey }
val it = byEventId.entries.iterator()
while (it.hasNext()) {
if (it.next().value.createdAtSec < olderThanSec) it.remove()
}
return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey }
}
/** Whether the aggregator currently holds any unevicted reactions. */
fun isEmpty(): Boolean = byEventId.isEmpty()
}