feat(audio-rooms): chat ledger on AudioRoomViewModel

Adds the listener-side state for the live-chat panel (T1 #2):

  AudioRoomViewModel.chat: StateFlow<List<LiveActivitiesChatMessageEvent>>
  AudioRoomViewModel.onChatEvent(event)

Same precedent as `presences` — the platform layer is the source
(amethyst observes LocalCache for kind=1311 + #a=[roomATag] and
pipes events here). The list is `created_at`-ascending so the chat
panel auto-scrolls newest at the bottom. Dedupes by event id so a
relay re-emit on reconnect doesn't double up the transcript.

Tests:
  * onChatEventAccumulatesMessagesSortedByCreatedAt — out-of-order
    arrivals end up in the right place on screen
  * onChatEventDedupesByEventId — same event from two relays /
    one reconnect produces ONE row

The amethyst-side wire sub + chat panel UI come next.
This commit is contained in:
Claude
2026-04-26 22:05:48 +00:00
parent 2c41d989ac
commit 25a8c460aa
2 changed files with 81 additions and 0 deletions
@@ -134,6 +134,21 @@ class AudioRoomViewModel(
private val _presences = MutableStateFlow<Map<String, RoomPresence>>(emptyMap())
val presences: StateFlow<Map<String, RoomPresence>> = _presences.asStateFlow()
/**
* Chat ledger for the live-activities chat panel (#1) — every
* kind-1311 ([com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent])
* tagged with this room's `a`-pointer, ordered by `created_at`
* ascending so the newest message is at the end (the panel
* auto-scrolls to it).
*
* Dedupes by event id — a relay re-emit on reconnect can't
* produce a duplicate row.
*/
private val chatById = LinkedHashMap<String, com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent>()
private val _chat =
MutableStateFlow<List<com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent>>(emptyList())
val chat: StateFlow<List<com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent>> = _chat.asStateFlow()
private var listener: NestsListener? = null
private var connectJob: Job? = null
private var stateObserverJob: Job? = null
@@ -251,6 +266,19 @@ class AudioRoomViewModel(
_presences.value = presenceAgg.evictOlderThan(olderThanSec)
}
/**
* Apply one kind-1311 chat event to the room ledger. The platform
* layer is the source — it filters by `a`-tag matching the current
* room before invoking this. Same-id re-emits are deduped; the
* resulting list is sorted by `created_at` ascending.
*/
fun onChatEvent(event: com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent) {
if (closed) return
if (chatById.containsKey(event.id)) return
chatById[event.id] = event
_chat.value = chatById.values.sortedBy { it.createdAt }
}
/**
* Whether this VM was constructed with capture + encoder factories. The
* UI uses this to decide whether to render the talk button at all —