diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt index d317c3718..38555fbcb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -149,6 +149,16 @@ class AudioRoomViewModel( MutableStateFlow>(emptyList()) val chat: StateFlow> = _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>>(emptyMap()) + val recentReactions: StateFlow>> = _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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt index d893f5eec..a604d7dcb 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt @@ -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.