From 052fdce655759d5c0fbf6149280c4450afb9391a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 21:47:57 +0000 Subject: [PATCH] feat(audio-rooms): expose presences StateFlow on AudioRoomViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the listener-side fan-in API the participant grid + listener counter consume: AudioRoomViewModel.presences: StateFlow> AudioRoomViewModel.onPresenceEvent(MeetingRoomPresenceEvent) AudioRoomViewModel.evictStalePresences(olderThanSec: Long) The platform layer (amethyst, next commit) observes LocalCache for `kinds=[10312], #a=[roomATag]` and pipes events through onPresenceEvent; that keeps the data source platform-specific and lets commons stay free of Android-only LocalCache references. evictStalePresences runs on a periodic tick driven by the platform layer. Bug-fix while wiring this: the initial RoomPresence had a custom pubkey-only equals() (intended to make Set dedup straightforwardly). That broke Map.equals on a heartbeat-only update — old.equals(new) returned true even when handRaised / publishing flipped, so StateFlow suppressed the emission and the UI never saw the change. Reverted to the data-class all-fields equals; the Map is keyed by the pubkey String anyway, so the custom equals never bought us anything. Test `roomPresenceEqualityIsAllFields` pins the contract so it can't drift back. Tests: * onPresenceEventPopulatesPresencesMapAndDedupesByPubkey * evictStalePresencesDropsOldPeers --- .../commons/viewmodels/AudioRoomViewModel.kt | 38 +++++++++++ .../commons/viewmodels/RoomPresenceState.kt | 11 ++-- .../viewmodels/AudioRoomViewModelTest.kt | 63 +++++++++++++++++++ .../viewmodels/RoomPresenceStateTest.kt | 12 ++-- 4 files changed, 113 insertions(+), 11 deletions(-) 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 eb75ea94c..bd04f317d 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 @@ -119,6 +119,21 @@ class AudioRoomViewModel( private val _uiState = MutableStateFlow(AudioRoomUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** + * Listener-side aggregation of every peer's most recent kind-10312 + * presence in this room. Populated by the platform layer + * (amethyst observes [LocalCache] for `kinds=[10312], #a=[roomATag]` + * and pipes events through [onPresenceEvent]) — keeping the data + * source platform-specific lets commons stay free of + * Android-only references. + * + * The participant grid (Tier 2 #1), hand-raise queue (T1 #5), and + * listener counter (T1 #8) all subscribe to this StateFlow. + */ + private val presenceAgg = RoomPresenceAggregator() + private val _presences = MutableStateFlow>(emptyMap()) + val presences: StateFlow> = _presences.asStateFlow() + private var listener: NestsListener? = null private var connectJob: Job? = null private var stateObserverJob: Job? = null @@ -213,6 +228,29 @@ class AudioRoomViewModel( _uiState.update { it.copy(onStageNow = onStage) } } + /** + * Apply one kind-10312 presence event to the in-memory aggregator. + * Caller owns the LocalCache → VM plumbing (the platform layer + * filters events by the current room's `a`-tag before invoking + * this). Out-of-order arrivals can't downgrade fresher state — + * see [RoomPresenceAggregator.apply]. + */ + fun onPresenceEvent(event: com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent) { + if (closed) return + _presences.value = presenceAgg.apply(event) + } + + /** + * Drop peers whose last heartbeat is older than [olderThanSec]. + * The platform layer drives this on a periodic tick (typically + * every 60 s with `now - 6 * 60` so a peer that's missed one + * heartbeat plus a 5-min "still here" window gets evicted). + */ + fun evictStalePresences(olderThanSec: Long) { + if (closed) return + _presences.value = presenceAgg.evictOlderThan(olderThanSec) + } + /** * Whether this VM was constructed with capture + encoder factories. The * UI uses this to decide whether to render the talk button at all — 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 index ae56c5bee..95c20a576 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt @@ -29,8 +29,11 @@ import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresence * 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. + * Standard data-class equality (all fields). A pubkey-only equals + * was tempting but breaks `Map.equals(Map)` change detection on a + * heartbeat-only update — `StateFlow` would suppress the emission + * because old.equals(new) returns true even though + * handRaised / publishing flipped. */ @Immutable data class RoomPresence( @@ -41,10 +44,6 @@ data class RoomPresence( 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 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 8a219370b..1a6a2e2e2 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 @@ -151,6 +151,69 @@ class AudioRoomViewModelTest { assertTrue(vm.uiState.value.onStageNow) } + @Test + fun onPresenceEventPopulatesPresencesMapAndDedupesByPubkey() = + runTest { + val vm = newViewModel { FakeNestsListener() } + + val alice = "a".repeat(64) + val bob = "b".repeat(64) + + fun ev( + pk: String, + createdAt: Long, + handRaised: Boolean, + ) = com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent( + id = "0".repeat(64), + pubKey = pk, + createdAt = createdAt, + tags = arrayOf(arrayOf("a", "30312:host:room"), arrayOf("hand", if (handRaised) "1" else "0")), + content = "", + sig = "0".repeat(128), + ) + + vm.onPresenceEvent(ev(alice, 100L, handRaised = false)) + vm.onPresenceEvent(ev(bob, 100L, handRaised = false)) + vm.onPresenceEvent(ev(alice, 200L, handRaised = true)) + + val snap = vm.presences.value + assertEquals(setOf(alice, bob), snap.keys) + assertTrue(snap[alice]!!.handRaised) + assertEquals(200L, snap[alice]!!.updatedAtSec) + } + + @Test + fun evictStalePresencesDropsOldPeers() = + runTest { + val vm = newViewModel { FakeNestsListener() } + val alice = "a".repeat(64) + val bob = "b".repeat(64) + + vm.onPresenceEvent( + com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent( + id = "0".repeat(64), + pubKey = alice, + createdAt = 100L, + tags = arrayOf(arrayOf("a", "30312:host:room")), + content = "", + sig = "0".repeat(128), + ), + ) + vm.onPresenceEvent( + com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent( + id = "0".repeat(64), + pubKey = bob, + createdAt = 500L, + tags = arrayOf(arrayOf("a", "30312:host:room")), + content = "", + sig = "0".repeat(128), + ), + ) + + vm.evictStalePresences(olderThanSec = 400L) + assertEquals(setOf(bob), vm.presences.value.keys) + } + @Test fun publishingNowDerivesFromBroadcastStateAndMute() { // Idle / connecting / failed: never publishing. 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 index 2f76dfa59..8039707ab 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceStateTest.kt @@ -116,12 +116,14 @@ class RoomPresenceStateTest { } @Test - fun roomPresenceEqualityIsPubkeyOnly() { - // Two snapshots of the same peer at different times are - // "equal" so a Set deduplicates correctly. + fun roomPresenceEqualityIsAllFields() { + // Two snapshots of the same peer at different timestamps are + // NOT equal — Map.equals(Map) reaches + // value.equals(value); a pubkey-only equals would suppress + // StateFlow emissions on heartbeat-only updates because + // old.equals(new) would return true. 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()) + assertEquals(false, a1 == a2) } }