feat(audio-rooms): expose presences StateFlow on AudioRoomViewModel

Adds the listener-side fan-in API the participant grid + listener
counter consume:

  AudioRoomViewModel.presences: StateFlow<Map<String, RoomPresence>>
  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<RoomPresence> dedup
straightforwardly). That broke Map<String, RoomPresence>.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
This commit is contained in:
Claude
2026-04-26 21:47:57 +00:00
parent 87dd8ee2ec
commit 052fdce655
4 changed files with 113 additions and 11 deletions
@@ -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.
@@ -116,12 +116,14 @@ class RoomPresenceStateTest {
}
@Test
fun roomPresenceEqualityIsPubkeyOnly() {
// Two snapshots of the same peer at different times are
// "equal" so a Set<RoomPresence> deduplicates correctly.
fun roomPresenceEqualityIsAllFields() {
// Two snapshots of the same peer at different timestamps are
// NOT equal — Map<String, RoomPresence>.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)
}
}