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 }
}
}
@@ -0,0 +1,115 @@
/*
* 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 com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RoomReactionsStateTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val charlie = "c".repeat(64)
private fun reaction(
from: String,
to: String?,
content: String,
createdAt: Long,
): ReactionEvent {
val tags =
buildList<Array<String>> {
add(arrayOf("a", "30312:host:room"))
if (to != null) add(arrayOf("p", to))
}.toTypedArray()
return ReactionEvent(
id = "0".repeat(64),
pubKey = from,
createdAt = createdAt,
tags = tags,
content = content,
sig = "0".repeat(128),
)
}
@Test
fun fromEventGroupsByTargetPubkey() {
val rxn = RoomReaction.from(reaction(alice, bob, "🔥", 100L))
assertEquals(alice, rxn.sourcePubkey)
assertEquals(bob, rxn.targetPubkey)
assertEquals("🔥", rxn.content)
assertEquals(100L, rxn.createdAtSec)
}
@Test
fun fromEventNullTargetWhenNoPTag() {
val rxn = RoomReaction.from(reaction(alice, null, "🎉", 100L))
assertNull(rxn.targetPubkey)
}
@Test
fun aggregatorGroupsBySpeaker() {
val agg = RoomReactionsAggregator()
agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L)
val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L)
// Two reactions on bob.
assertEquals(setOf(bob), snap.keys)
assertEquals(2, snap[bob]!!.size)
}
@Test
fun aggregatorEvictsOlderThanWindow() {
val agg = RoomReactionsAggregator()
// Old reaction (T=70) — outside the window when now=110, windowSec=30.
agg.apply(reaction(alice, bob, "🔥", 70L), nowSec = 70L, windowSec = 30L)
// Fresh reaction (T=105) — inside the window.
val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L)
// bob has only the fresh one left.
assertEquals(1, snap[bob]!!.size)
assertEquals("👏", snap[bob]!![0].content)
}
@Test
fun aggregatorRoomWideReactionsKeyedByEmptyString() {
val agg = RoomReactionsAggregator()
val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L)
// Room-wide reactions land under the empty-string key so the
// map's value-type stays uniform; the UI can split them on render.
assertTrue(snap.containsKey(""))
assertEquals(1, snap[""]!!.size)
}
@Test
fun evictAndSnapshotIsIdempotentWhenNothingChanged() {
val agg = RoomReactionsAggregator()
agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L)
val a = agg.evictAndSnapshot(olderThanSec = 90L)
val b = agg.evictAndSnapshot(olderThanSec = 90L)
// Same input → same output, by VALUE (data class equality on
// RoomReaction so the StateFlow doesn't re-emit on no-op ticks).
assertEquals(a, b)
}
}