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:
+20
-12
@@ -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
|
||||
|
||||
+22
-4
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+27
-9
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user