feat(audio-rooms): recentReactions StateFlow on AudioRoomViewModel

Adds the listener-side fan-in for the speaker-avatar reaction
overlay (T1 #3):

  recentReactions: StateFlow<Map<String, List<RoomReaction>>>
  onReactionEvent(event, nowSec, windowSec = 30)
  evictReactions(olderThanSec)

The platform layer is the source — amethyst observes LocalCache for
kind=7 with #a=[roomATag] and pipes events here. The 30-s window
constant lives next to SPEAKING_TIMEOUT_MS so the staleness
configuration is one place. Caller-driven tick (no internal timer
inside the VM) keeps the lifecycle aligned with the Composable.

Test:
  * onReactionEventGroupsByTargetAndEvictsOnTick — two reactions on
    bob arrive within the window; tick advances past the window;
    overlay clears.
This commit is contained in:
Claude
2026-04-26 22:14:37 +00:00
parent 87a54bf479
commit aa4c24b974
2 changed files with 74 additions and 0 deletions
@@ -267,6 +267,37 @@ class AudioRoomViewModelTest {
assertEquals(1, vm.chat.value.size)
}
@Test
fun onReactionEventGroupsByTargetAndEvictsOnTick() =
runTest {
val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64)
val bob = "b".repeat(64)
fun rxn(
from: String,
to: String,
content: String,
createdAt: Long,
) = com.vitorpamplona.quartz.nip25Reactions.ReactionEvent(
id = "0".repeat(64),
pubKey = from,
createdAt = createdAt,
tags = arrayOf(arrayOf("a", "30312:host:room"), arrayOf("p", to)),
content = content,
sig = "0".repeat(128),
)
// Two reactions land within the 30-s window.
vm.onReactionEvent(rxn(alice, bob, "🔥", 100L), nowSec = 100L)
vm.onReactionEvent(rxn(alice, bob, "👏", 105L), nowSec = 105L)
assertEquals(2, vm.recentReactions.value[bob]!!.size)
// Tick advances past the window — both reactions evicted.
vm.evictReactions(olderThanSec = 200L)
assertEquals(emptyMap(), vm.recentReactions.value)
}
@Test
fun publishingNowDerivesFromBroadcastStateAndMute() {
// Idle / connecting / failed: never publishing.