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
|
||||
|
||||
Reference in New Issue
Block a user