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
@@ -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 —
@@ -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