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:
+38
@@ -119,6 +119,21 @@ class AudioRoomViewModel(
|
||||
private val _uiState = MutableStateFlow(AudioRoomUiState())
|
||||
val uiState: StateFlow<AudioRoomUiState> = _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<Map<String, RoomPresence>>(emptyMap())
|
||||
val presences: StateFlow<Map<String, RoomPresence>> = _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 —
|
||||
|
||||
+5
-6
@@ -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<String, RoomPresence>` 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
|
||||
|
||||
+63
@@ -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.
|
||||
|
||||
+7
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user