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
@@ -149,6 +149,16 @@ class AudioRoomViewModel(
MutableStateFlow<List<com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent>>(emptyList())
val chat: StateFlow<List<com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent>> = _chat.asStateFlow()
/**
* 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).
*/
private val reactionsAgg = RoomReactionsAggregator()
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
val recentReactions: StateFlow<Map<String, List<RoomReaction>>> = _recentReactions.asStateFlow()
private var listener: NestsListener? = null
private var connectJob: Job? = null
private var stateObserverJob: Job? = null
@@ -279,6 +289,31 @@ class AudioRoomViewModel(
_chat.value = chatById.values.sortedBy { it.createdAt }
}
/**
* 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.
*/
fun onReactionEvent(
event: com.vitorpamplona.quartz.nip25Reactions.ReactionEvent,
nowSec: Long,
windowSec: Long = REACTION_WINDOW_SEC,
) {
if (closed) return
_recentReactions.value = reactionsAgg.apply(event, nowSec, windowSec)
}
/**
* Drop reactions older than the staleness threshold. Platform
* layer drives this on a 1-s tick to update the floating overlay
* without per-component animation timers.
*/
fun evictReactions(olderThanSec: Long) {
if (closed) return
_recentReactions.value = reactionsAgg.evictAndSnapshot(olderThanSec)
}
/**
* Whether this VM was constructed with capture + encoder factories. The
* UI uses this to decide whether to render the talk button at all —
@@ -917,6 +952,14 @@ sealed class BroadcastUiState {
*/
const val SPEAKING_TIMEOUT_MS: Long = 250L
/**
* How long a kind-7 reaction stays in
* [AudioRoomViewModel.recentReactions] before the eviction sweep
* drops it. Matches the duration of the floating-up animation in the
* SpeakerReactionOverlay.
*/
const val REACTION_WINDOW_SEC: Long = 30L
/** Max number of auto-reconnect attempts after a Failed listener state. */
private const val MAX_AUTO_RETRIES = 3
@@ -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.