87a54bf479
Data + dedup logic for the speaker-avatar reaction overlay (T1 #3): RoomReaction — one ephemeral kind-7 reaction. Carries the source pubkey, the target pubkey (null for room-wide), the emoji/content, and the createdAt second. Standard data-class equality so the StateFlow can suppress no-op tick re-emits via map.equals. RoomReactionsAggregator — `apply(event, nowSec, windowSec)` records a fresh reaction and returns the post-evict Map<targetPubkey, List<RoomReaction>>. `evictAndSnapshot(olderThanSec)` is the per-tick sweep — caller drives the cadence (typically every second so the floating-up animation frame rate is set by the eviction tick rather than per-component timers). Room-wide reactions (no `p` tag) land under the empty-string key so the value-type stays uniform. Tests cover: * Tag projection (with + without `p` target) * Multi-source grouping by target speaker * Window-edge eviction (a reaction at T=70 is gone at now=110, window=30; a reaction at T=105 stays) * Empty-string key for room-wide reactions * Idempotent eviction snapshot — same input twice produces equal Maps so the UI doesn't recompose on no-op ticks Zap reactions (kind 9735) carry an amount + zapper that aren't in the v1 RoomReaction shape; that's a follow-up if/when the UI grows a "satoshi rain" treatment. The ledger stays generic on `content` so adding it later is additive.