From 87dd8ee2ecd52caaaf751f22b7b8976231c8199b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 21:42:11 +0000 Subject: [PATCH] feat(audio-rooms): RoomPresence + aggregator for listener-side fan-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the data model + in-memory dedup/eviction logic the participant grid (Tier 2 #1), hand-raise queue (T1 #5), and listener counter (T1 #8) all consume. RoomPresence — one peer's most recent kind-10312 snapshot. Equality is by pubkey alone so a Set or Map swap on heartbeat update doesn't double-count when only the timestamp changes. `RoomPresence.from(event)` projects every presence tag, with conservative defaults for absent ones (handRaised/publishing default false; muted stays null to distinguish "didn't say" from "explicitly false"; onstage defaults TRUE so pre-onstage clients still render as speakers). RoomPresenceAggregator — `apply(event)` dedupes by pubkey, keeps the most recent createdAt (out-of-order events can't overwrite newer state). `evictOlderThan` is the staleness sweep — caller drives the cadence (typically a 60 s tick passing `now - 6*60` to evict on >6 min of silence). Tests cover: tag projection, the absent-tag defaults, dedup on same pubkey, out-of-order non-overwrite, multi-pubkey isolation, eviction window, and the pubkey-only equality contract. The amethyst-side LocalCache wiring + listener-counter UI come next in their own commit. --- .../commons/viewmodels/RoomPresenceState.kt | 112 +++++++++++++++ .../viewmodels/RoomPresenceStateTest.kt | 127 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceStateTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt new file mode 100644 index 000000000..ae56c5bee --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt @@ -0,0 +1,112 @@ +/* + * 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.nip53LiveActivities.presence.MeetingRoomPresenceEvent + +/** + * One peer's most recent kind-10312 presence in a room — the + * aggregator keeps a `Map` populated from + * the relay subscription, and the participant grid + listener + * counter render off it. + * + * Equality is by pubkey alone so a `Map` swap + * on update doesn't double-count when only the timestamp changes. + */ +@Immutable +data class RoomPresence( + val pubkey: String, + val handRaised: Boolean, + val muted: Boolean?, + val publishing: Boolean, + val onstage: Boolean, + val updatedAtSec: Long, +) { + override fun equals(other: Any?): Boolean = other is RoomPresence && other.pubkey == pubkey + + override fun hashCode(): Int = pubkey.hashCode() + + companion object { + /** + * Project a kind-10312 event into a [RoomPresence]. Tags missing + * from the wire default to a "not advertising" state: + * - `handRaised` and `publishing` default to `false` + * - `muted` stays `null` so the UI can render "unknown" vs + * "explicitly false" + * - `onstage` defaults to `true` (a peer publishing kind-10312 + * for a room is on the stage by definition unless they say + * otherwise; old clients that never emit `onstage` still + * show up as speakers) + */ + fun from(event: MeetingRoomPresenceEvent): RoomPresence = + RoomPresence( + pubkey = event.pubKey, + handRaised = event.handRaised() == true, + muted = event.muted(), + publishing = event.publishing() == true, + onstage = event.onstage() ?: true, + updatedAtSec = event.createdAt, + ) + } +} + +/** + * In-memory aggregator for `Map`. Tests and the + * VM both call this directly; the input is a stream of presence events + * from `LocalCache` (filtered by `#a` tag matching the current room). + * + * Dedupes by pubkey, keeping the most recent `createdAt`. Staleness + * eviction is the caller's job — call [evictOlderThan] on a tick. + */ +class RoomPresenceAggregator { + private val byPubkey = mutableMapOf() + + /** Apply one presence event. Returns the updated snapshot. */ + fun apply(event: MeetingRoomPresenceEvent): Map { + val incoming = RoomPresence.from(event) + val current = byPubkey[incoming.pubkey] + if (current == null || current.updatedAtSec < incoming.updatedAtSec) { + byPubkey[incoming.pubkey] = incoming + } + return snapshot() + } + + /** + * Drop any peer whose last heartbeat is older than [olderThanSec]. + * Returns the post-eviction snapshot. nostrnests heartbeats every + * 30 s; pass `now - 6 * 60` to evict a peer that's been silent for + * 6 minutes (one missed heartbeat + a 5 min "still here" window). + */ + fun evictOlderThan(olderThanSec: Long): Map { + val it = byPubkey.iterator() + while (it.hasNext()) { + val entry = it.next() + if (entry.value.updatedAtSec < olderThanSec) { + it.remove() + } + } + return snapshot() + } + + /** Read-only copy of the current state. */ + fun snapshot(): Map = byPubkey.toMap() +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceStateTest.kt new file mode 100644 index 000000000..2f76dfa59 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceStateTest.kt @@ -0,0 +1,127 @@ +/* + * 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.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RoomPresenceStateTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val roomA = "30312:${"c".repeat(64)}:my-room" + + private fun event( + pubkey: String, + createdAt: Long, + handRaised: Boolean? = null, + muted: Boolean? = null, + publishing: Boolean? = null, + onstage: Boolean? = null, + ): MeetingRoomPresenceEvent { + val tags = + buildList> { + add(arrayOf("a", roomA)) + handRaised?.let { add(arrayOf("hand", if (it) "1" else "0")) } + muted?.let { add(arrayOf("muted", if (it) "1" else "0")) } + publishing?.let { add(arrayOf("publishing", if (it) "1" else "0")) } + onstage?.let { add(arrayOf("onstage", if (it) "1" else "0")) } + }.toTypedArray() + return MeetingRoomPresenceEvent( + id = "0".repeat(64), + pubKey = pubkey, + createdAt = createdAt, + tags = tags, + content = "", + sig = "0".repeat(128), + ) + } + + @Test + fun fromEventProjectsAllTags() { + val rp = RoomPresence.from(event(alice, 100L, handRaised = true, muted = false, publishing = true, onstage = true)) + assertEquals(alice, rp.pubkey) + assertTrue(rp.handRaised) + assertEquals(false, rp.muted) + assertTrue(rp.publishing) + assertTrue(rp.onstage) + assertEquals(100L, rp.updatedAtSec) + } + + @Test + fun fromEventDefaultsAbsentTagsConservatively() { + val rp = RoomPresence.from(event(alice, 100L)) + assertFalse(rp.handRaised) + assertNull(rp.muted) + assertFalse(rp.publishing) + // onstage defaults to TRUE for backwards compatibility with + // pre-onstage clients — they all emit kind 10312 implicitly + // as speakers. + assertTrue(rp.onstage) + } + + @Test + fun aggregatorDedupesByPubkeyKeepingLatestCreatedAt() { + val agg = RoomPresenceAggregator() + agg.apply(event(alice, 100L, handRaised = false)) + agg.apply(event(alice, 200L, handRaised = true)) + // Out-of-order arrival: older event must NOT overwrite newer. + val snapshot = agg.apply(event(alice, 150L, handRaised = false)) + + assertEquals(1, snapshot.size) + assertTrue(snapshot[alice]!!.handRaised) + assertEquals(200L, snapshot[alice]!!.updatedAtSec) + } + + @Test + fun aggregatorTracksMultiplePubkeysIndependently() { + val agg = RoomPresenceAggregator() + agg.apply(event(alice, 100L, publishing = true)) + val snapshot = agg.apply(event(bob, 100L, publishing = false)) + + assertEquals(2, snapshot.size) + assertTrue(snapshot[alice]!!.publishing) + assertFalse(snapshot[bob]!!.publishing) + } + + @Test + fun evictOlderThanDropsStalePeersAndKeepsFresh() { + val agg = RoomPresenceAggregator() + agg.apply(event(alice, 100L)) // stale + agg.apply(event(bob, 500L)) // fresh + + val snapshot = agg.evictOlderThan(olderThanSec = 400L) + assertEquals(setOf(bob), snapshot.keys) + } + + @Test + fun roomPresenceEqualityIsPubkeyOnly() { + // Two snapshots of the same peer at different times are + // "equal" so a Set deduplicates correctly. + val a1 = RoomPresence.from(event(alice, 100L, handRaised = false)) + val a2 = RoomPresence.from(event(alice, 200L, handRaised = true)) + assertEquals(a1, a2) + assertEquals(a1.hashCode(), a2.hashCode()) + } +}