feat(audio-rooms): live chat panel on the room screen

Wires up the kind-1311 chat end-to-end (T1 #2):

  AudioRoomActivityContent — opens RoomChatFilterAssemblerSubscription
                             on enter, observes LocalCache for kind=1311
                             + #a=[roomATag], pipes events into the
                             VM's chat ledger via onChatEvent.

  AudioRoomChatPanel       — new Composable. LazyColumn of messages
                             (auto-scroll-to-bottom on new arrival),
                             empty placeholder, OutlinedTextField +
                             Send button. Each row shows a
                             ClickableUserPicture by pubkey hex,
                             a truncated-pubkey label, and the
                             content. Rich author-name lookup is
                             intentionally deferred to keep the v1
                             panel's dependency surface tight.

  Send path                — `account.signAndComputeBroadcast(
                                LiveActivitiesChatMessageEvent.message(
                                    post = trimmed,
                                    activity = ATag(30312, host, dTag, null)))`
                             routed directly from the Composable so
                             the VM stays free of platform/signing
                             dependencies.

  AudioRoomFullScreen      — embeds the panel below the existing
                             hand-raise / leave row.

  strings.xml              — `audio_room_chat_send`,
                             `audio_room_chat_placeholder`,
                             `audio_room_chat_empty`.

Sub closes on Composable dispose; relay traffic naturally winds down
when the user leaves the room. Out-of-order arrivals are sorted to
chronological by the VM's ledger (already covered by the
AudioRoomViewModelTest unit tests).
This commit is contained in:
Claude
2026-04-26 22:09:49 +00:00
parent 3a3d53a437
commit a5cc594416
4 changed files with 216 additions and 0 deletions
@@ -39,9 +39,11 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.audiorooms.AudioRoomForegroundService
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomChatFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomPresenceFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
@@ -166,6 +168,21 @@ private fun AudioRoomActivityBody(
}
}
// Per-room kind-1311 live chat: open the wire sub for the duration
// of this Composable, observe matching events from LocalCache, feed
// them into the VM's chat ledger.
RoomChatFilterAssemblerSubscription(roomATag, accountViewModel)
LaunchedEffect(viewModel, roomATag) {
val filter =
Filter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOf(roomATag)),
)
LocalCache.observeEvents<LiveActivitiesChatMessageEvent>(filter).collect { events ->
events.forEach { viewModel.onChatEvent(it) }
}
}
val ui by viewModel.uiState.collectAsState()
// Push mute + connected state up so the Activity can rebuild PIP
@@ -0,0 +1,182 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import kotlinx.coroutines.launch
/**
* Live-chat panel (T1 #2) — renders the kind-1311 transcript for
* the room and provides a send field. Messages flow from
* [AudioRoomViewModel.chat] (populated by the LocalCache observer
* in [AudioRoomActivityContent]); send routes through
* `account.signAndComputeBroadcast` directly so the VM stays free
* of platform / signing dependencies.
*/
@Composable
internal fun AudioRoomChatPanel(
roomATag: ATag,
viewModel: AudioRoomViewModel,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val messages by viewModel.chat.collectAsState()
val listState = rememberLazyListState()
// Auto-scroll to bottom on new message arrival.
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
Column(modifier = modifier.fillMaxWidth()) {
if (messages.isEmpty()) {
Text(
text = stringRes(R.string.audio_room_chat_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 12.dp),
)
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().height(220.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(messages, key = { it.id }) { msg ->
ChatRow(msg, accountViewModel)
}
}
}
Spacer(Modifier.height(8.dp))
ChatComposer(roomATag, accountViewModel)
}
}
@Composable
private fun ChatRow(
msg: LiveActivitiesChatMessageEvent,
accountViewModel: AccountViewModel,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
ClickableUserPicture(
baseUserHex = msg.pubKey,
size = Size35dp,
accountViewModel = accountViewModel,
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.fillMaxWidth()) {
// Truncated pubkey as the inline name placeholder. A future
// pass can resolve to the user's NIP-01 display name; for
// the v1 chat panel we keep the dependency surface tight.
Text(
text = msg.pubKey.take(8),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = msg.content,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun ChatComposer(
roomATag: ATag,
accountViewModel: AccountViewModel,
) {
var draft by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringRes(R.string.audio_room_chat_placeholder)) },
singleLine = false,
maxLines = 4,
)
Spacer(Modifier.width(8.dp))
TextButton(
enabled = draft.isNotBlank(),
onClick = {
val toSend = draft.trim()
if (toSend.isEmpty()) return@TextButton
draft = ""
scope.launch {
runCatching {
accountViewModel.account.signAndComputeBroadcast(
LiveActivitiesChatMessageEvent.message(
post = toSend,
activity = roomATag,
),
)
}
}
},
) {
Text(stringRes(R.string.audio_room_chat_send))
}
}
}
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
@@ -170,6 +171,19 @@ internal fun AudioRoomFullScreen(
Text(stringRes(R.string.audio_room_leave))
}
}
AudioRoomChatPanel(
roomATag =
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
),
viewModel = viewModel,
accountViewModel = accountViewModel,
modifier = Modifier.padding(top = 12.dp),
)
}
}
+3
View File
@@ -525,6 +525,9 @@
<item quantity="one">%1$d listener</item>
<item quantity="other">%1$d listeners</item>
</plurals>
<string name="audio_room_chat_send">Send</string>
<string name="audio_room_chat_placeholder">Say something…</string>
<string name="audio_room_chat_empty">No messages yet. Be the first to chat.</string>
<string name="audio_room_create_fab">Start space</string>
<string name="audio_room_create_title">Start a new audio room</string>
<string name="audio_room_create_field_room">Room name</string>