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:
+28
@@ -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 —
|
||||
|
||||
+53
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user