From 87a54bf4793400b2738aa0ed31615ef9e19c9b18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 22:13:24 +0000 Subject: [PATCH] feat(audio-rooms): RoomReaction + sliding-window aggregator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>. `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. --- .../commons/viewmodels/RoomReactionsState.kt | 98 +++++++++++++++ .../viewmodels/RoomReactionsStateTest.kt | 115 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt new file mode 100644 index 000000000..192095bb8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt @@ -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() + + /** 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> { + 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> { + all.removeAll { it.createdAtSec < olderThanSec } + return all.groupBy { it.targetPubkey ?: roomWideKey } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt new file mode 100644 index 000000000..820091ff0 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt @@ -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> { + 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) + } +}