feat(audio-rooms): RoomReaction + sliding-window aggregator

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.
This commit is contained in:
Claude
2026-04-26 22:13:24 +00:00
parent a5cc594416
commit 87a54bf479
2 changed files with 213 additions and 0 deletions
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.viewmodels
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
/**
* One in-flight reaction to render as a floating overlay on a
* speaker's avatar. Ephemeral by design — the aggregator drops
* entries older than the staleness window so the overlay clears
* itself without per-component animation bookkeeping.
*
* `targetPubkey == null` means the reaction targets the room itself
* (no `p` tag in the event); the UI can render those as a
* room-wide pulse instead of attaching to a single avatar.
*/
@Immutable
data class RoomReaction(
/** Who reacted. */
val sourcePubkey: String,
/** Speaker the reaction is aimed at, or `null` for room-wide. */
val targetPubkey: String?,
/** Emoji / "+" / shortcode body. */
val content: String,
val createdAtSec: Long,
) {
companion object {
/**
* Project a kind-7 [ReactionEvent] into a [RoomReaction]. The
* target pubkey is the FIRST `p` tag — nostrnests reactions
* carry one when the user picked a speaker; an empty list
* means a room-wide reaction.
*/
fun from(event: ReactionEvent): RoomReaction =
RoomReaction(
sourcePubkey = event.pubKey,
targetPubkey = event.originalAuthor().firstOrNull(),
content = event.content,
createdAtSec = event.createdAt,
)
}
}
/**
* 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.
*
* Not thread-safe — call from the VM's single coroutine.
*/
class RoomReactionsAggregator {
private val all = mutableListOf<RoomReaction>()
/** Stable key for room-wide reactions in the returned map. */
private val roomWideKey = ""
/** Apply one reaction and return the post-evict snapshot. */
fun apply(
event: ReactionEvent,
nowSec: Long,
windowSec: Long,
): Map<String, List<RoomReaction>> {
all += RoomReaction.from(event)
return evictAndSnapshot(nowSec - windowSec)
}
/**
* Drop reactions older than [olderThanSec]. Caller drives the
* cadence (typically every second so the floating-up animation
* frame rate is set by the eviction tick rather than by a
* per-Composable timer).
*/
fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> {
all.removeAll { it.createdAtSec < olderThanSec }
return all.groupBy { it.targetPubkey ?: roomWideKey }
}
}