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())
|
private val _presences = MutableStateFlow<Map<String, RoomPresence>>(emptyMap())
|
||||||
val presences: StateFlow<Map<String, RoomPresence>> = _presences.asStateFlow()
|
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 listener: NestsListener? = null
|
||||||
private var connectJob: Job? = null
|
private var connectJob: Job? = null
|
||||||
private var stateObserverJob: Job? = null
|
private var stateObserverJob: Job? = null
|
||||||
@@ -251,6 +266,19 @@ class AudioRoomViewModel(
|
|||||||
_presences.value = presenceAgg.evictOlderThan(olderThanSec)
|
_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
|
* Whether this VM was constructed with capture + encoder factories. The
|
||||||
* UI uses this to decide whether to render the talk button at all —
|
* 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)
|
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
|
@Test
|
||||||
fun publishingNowDerivesFromBroadcastStateAndMute() {
|
fun publishingNowDerivesFromBroadcastStateAndMute() {
|
||||||
// Idle / connecting / failed: never publishing.
|
// Idle / connecting / failed: never publishing.
|
||||||
|
|||||||
Reference in New Issue
Block a user