From 25a8c460aa7efa044035dfddd22bdb0795600966 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 22:05:48 +0000 Subject: [PATCH] feat(audio-rooms): chat ledger on AudioRoomViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the listener-side state for the live-chat panel (T1 #2): AudioRoomViewModel.chat: StateFlow> 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. --- .../commons/viewmodels/AudioRoomViewModel.kt | 28 ++++++++++ .../viewmodels/AudioRoomViewModelTest.kt | 53 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt index bd04f317d..d317c3718 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -134,6 +134,21 @@ class AudioRoomViewModel( private val _presences = MutableStateFlow>(emptyMap()) val presences: StateFlow> = _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() + private val _chat = + MutableStateFlow>(emptyList()) + val chat: StateFlow> = _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 — diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt index 1a6a2e2e2..d893f5eec 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt @@ -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.