refactor(nests): lobby chat reuses NestFullScreen list shape

Replace the static Column-based CachedChatList with the same
LazyColumn(reverseLayout = true) the in-room screen uses
(NestChatPanel.NestChatMessageList). Items are keyed by note id,
and the room metadata header / listener count / "Recent chat"
label are stacked above the oldest message so the user can scroll
up through history into the room info.

Wire onSendNewMessage to animateScrollToItem(0) so a freshly sent
message snaps the list back to the newest entry — same behaviour
the in-room composer has.
This commit is contained in:
Claude
2026-04-30 20:12:45 +00:00
parent 02d34ed74f
commit 8f7c0eac47
@@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
@@ -50,6 +50,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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
@@ -90,6 +91,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* In-app navigation lobby for a NIP-53 audio room (kind-30312). Sits
@@ -101,11 +104,12 @@ import com.vitorpamplona.quartz.utils.TimeUtils
*
* The lobby keeps the room's relay subscription
* ([NestRoomFilterAssemblerSubscription]) warm so cached chat / presence
* stay fresh, and exposes an active kind-1311 chat composer (same widget
* the in-room screen uses) so users can chime in without ever joining
* the audio plane. It does NOT open a [com.vitorpamplona.amethyst.commons
* .viewmodels.NestViewModel], does NOT publish kind-10312 presence,
* and does NOT touch the audio pipeline.
* stay fresh, renders the kind-1311 transcript with the same
* `LazyColumn(reverseLayout = true)` list the in-room screen uses, and
* exposes the same composer (kind-1311 chat) — so users can chime in
* without ever joining the audio plane. It does NOT open a
* [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel], does
* NOT publish kind-10312 presence, and does NOT touch the audio pipeline.
*/
@Composable
fun NestLobbyScreen(
@@ -142,6 +146,21 @@ private fun NestLobbyContent(
nestScreenModel.init(accountViewModel)
nestScreenModel.load(event)
val messages by produceState(initialValue = emptyList<Note>(), roomATag) {
val filter =
Filter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOf(roomATag)),
)
LocalCache.observeNotes(filter).collect { notes ->
value = notes.sortedByDescending { it.createdAt() ?: 0L }.take(LOBBY_CHAT_LIMIT)
}
}
val routeForLastRead = remember(roomATag) { "NestLobbyChat/$roomATag" }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
@@ -177,15 +196,41 @@ private fun NestLobbyContent(
.consumeWindowInsets(padding)
.imePadding(),
) {
// Same chat list shape as NestFullScreen: a reverse-layout
// LazyColumn keyed by note id. The lobby uses cached
// LocalCache notes instead of an active NestViewModel.chat,
// and stacks the room metadata (header, listener count,
// "Recent chat" label) above the oldest message so the
// user can scroll up through history into the room info.
LazyColumn(
modifier = Modifier.weight(1f),
state = rememberLazyListState(),
modifier = Modifier.weight(1f).fillMaxSize(),
state = listState,
reverseLayout = true,
) {
item("header") {
RoomHeader(event, accountViewModel, nav)
items(
items = messages,
key = { it.idHex },
) { note ->
ChatroomMessageCompose(
baseNote = note,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = nestScreenModel::reply,
onWantsToEditDraft = nestScreenModel::editFromDraft,
)
}
item("listeners") {
CachedListenerCount(roomATag)
if (messages.isEmpty()) {
item("chat-empty") {
Text(
text = stringRes(R.string.nest_chat_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
item("chat-header") {
Text(
@@ -194,7 +239,12 @@ private fun NestLobbyContent(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
cachedChatItems(roomATag, accountViewModel, nav)
item("listeners") {
CachedListenerCount(roomATag)
}
item("header") {
RoomHeader(event, accountViewModel, nav)
}
}
// The composer sits above the 3-button nav when the keyboard
@@ -209,7 +259,12 @@ private fun NestLobbyContent(
NestEditFieldRow(
nestScreenModel = nestScreenModel,
accountViewModel = accountViewModel,
onSendNewMessage = {},
onSendNewMessage = {
scope.launch {
delay(100)
listState.animateScrollToItem(0)
}
},
nav = nav,
)
}
@@ -401,61 +456,6 @@ private fun CachedListenerCount(roomATag: String) {
)
}
private fun LazyListScope.cachedChatItems(
roomATag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
item("chat-list") {
CachedChatList(roomATag, accountViewModel, nav)
}
}
@Composable
private fun CachedChatList(
roomATag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val messages by produceState(initialValue = emptyList<Note>(), roomATag) {
val filter =
Filter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOf(roomATag)),
)
LocalCache.observeNotes(filter).collect { notes ->
value = notes.sortedByDescending { it.createdAt() ?: 0L }.take(LOBBY_CHAT_LIMIT)
}
}
val routeForLastRead = remember(roomATag) { "NestLobbyChat/$roomATag" }
if (messages.isEmpty()) {
Text(
text = stringRes(R.string.nest_chat_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
return
}
Column(modifier = Modifier.fillMaxWidth()) {
messages.forEach { note ->
ChatroomMessageCompose(
baseNote = note,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = {},
onWantsToEditDraft = {},
)
}
}
}
private const val LOBBY_CHAT_LIMIT = 50
// 6 minutes — matches PRESENCE_STALE_THRESHOLD_SEC in NestRoomEventCollectors.