mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
+17
@@ -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
|
||||
|
||||
+182
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user