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 —
@@ -214,6 +214,59 @@ class AudioRoomViewModelTest {
assertEquals(setOf(bob), vm.presences.value.keys)
}
@Test
fun onChatEventAccumulatesMessagesSortedByCreatedAt() =
runTest {
val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64)
fun chat(
id: String,
createdAt: Long,
body: String,
) = com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent(
id = id,
pubKey = alice,
createdAt = createdAt,
tags = arrayOf(arrayOf("a", "30312:host:room")),
content = body,
sig = "0".repeat(128),
)
// Out-of-order arrival on the relay must still produce a
// chronological transcript on screen.
vm.onChatEvent(chat(id = "1".repeat(64), createdAt = 200L, body = "second"))
vm.onChatEvent(chat(id = "2".repeat(64), createdAt = 100L, body = "first"))
val messages = vm.chat.value
assertEquals(2, messages.size)
assertEquals("first", messages[0].content)
assertEquals("second", messages[1].content)
}
@Test
fun onChatEventDedupesByEventId() =
runTest {
val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64)
val msg =
com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent(
id = "1".repeat(64),
pubKey = alice,
createdAt = 100L,
tags = arrayOf(arrayOf("a", "30312:host:room")),
content = "hello",
sig = "0".repeat(128),
)
// Same id re-emitted by the relay on reconnect must not
// produce a duplicate row.
vm.onChatEvent(msg)
vm.onChatEvent(msg)
assertEquals(1, vm.chat.value.size)
}
@Test
fun publishingNowDerivesFromBroadcastStateAndMute() {
// Idle / connecting / failed: never publishing.