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:
+43
@@ -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
|
||||
|
||||
|
||||
+31
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user