Merge pull request #2669 from vitorpamplona/claude/update-nestlobby-chat-layout-JimqW

Add chat composer to nest lobby screen
This commit is contained in:
Vitor Pamplona
2026-04-30 16:04:02 -04:00
committed by GitHub
3 changed files with 122 additions and 82 deletions
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby
import android.R.attr.textColor
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -42,7 +41,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@@ -60,8 +58,6 @@ import com.vitorpamplona.amethyst.ui.note.types.MeetingSpaceOpenFlag
import com.vitorpamplona.amethyst.ui.note.types.MeetingSpacePlannedFlag import com.vitorpamplona.amethyst.ui.note.types.MeetingSpacePlannedFlag
import com.vitorpamplona.amethyst.ui.note.types.MeetingSpacePrivateFlag import com.vitorpamplona.amethyst.ui.note.types.MeetingSpacePrivateFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
@@ -271,11 +267,11 @@ private fun NestJoinCardContent(
/** /**
* "Join nest" entry button — navigates to [NestLobbyScreen] rather * "Join nest" entry button — navigates to [NestLobbyScreen] rather
* than launching the audio activity directly. The lobby is read-only * than launching the audio activity directly. The lobby exposes the
* (cached chat, host, status), so a user who's just coming back to * cached chat plus an active composer, so a user who's just coming
* check on an old room doesn't trigger a MoQ handshake or the host's * back to read or chime in doesn't trigger a MoQ handshake or the
* kind-30312 republish path. The actual room launch lives behind * host's kind-30312 republish path. The actual room launch lives
* [OpenNestRoomButton] inside the lobby. * behind the lobby's top-bar "Open" action.
* *
* Renders nothing for events without a service / endpoint / d-tag — * Renders nothing for events without a service / endpoint / d-tag —
* those rooms can't be joined on the audio plane. * those rooms can't be joined on the audio plane.
@@ -312,35 +308,3 @@ fun JoinNestButton(
Text(stringRes(R.string.nest_join)) Text(stringRes(R.string.nest_join))
} }
} }
/**
* Lobby's primary CTA. This is the only place that actually launches
* [NestActivity] — meaning a host re-entering an old room only
* triggers the audio session (and any rejoin-time event refresh)
* when they explicitly opt in here.
*/
@Composable
fun OpenNestRoomButton(
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val serviceBase = event.service()
val endpoint = event.endpoint()
val roomId = event.address().dTag
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
val context = LocalContext.current
Button(
onClick = {
NestBridge.set(accountViewModel)
NestActivity.launch(
context = context,
addressValue = event.address().toValue(),
)
},
modifier = modifier,
) {
Text(stringRes(R.string.nest_lobby_open_room))
}
}
@@ -20,23 +20,30 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -47,15 +54,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -66,6 +76,10 @@ import com.vitorpamplona.amethyst.ui.note.types.MeetingSpacePrivateFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestEditFieldRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestNewMessageViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -82,12 +96,14 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* between the feed / share-link entry points and [com.vitorpamplona.amethyst * between the feed / share-link entry points and [com.vitorpamplona.amethyst
* .ui.screen.loggedIn.nests.room.activity.NestActivity], so a user who's * .ui.screen.loggedIn.nests.room.activity.NestActivity], so a user who's
* just coming back to read the chat or check who's hosting doesn't * just coming back to read the chat or check who's hosting doesn't
* trigger a MoQ handshake or a host-side kind-30312 republish. Only the * trigger a MoQ handshake or a host-side kind-30312 republish. The
* "Open room" button on this screen launches the audio activity. * top-bar "Open" action launches the audio activity.
* *
* The lobby is read-only: it mounts the room's relay subscription * The lobby keeps the room's relay subscription
* ([NestRoomFilterAssemblerSubscription]) so cached chat / presence * ([NestRoomFilterAssemblerSubscription]) warm so cached chat / presence
* stay fresh, but it does NOT open a [com.vitorpamplona.amethyst.commons * 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, * .viewmodels.NestViewModel], does NOT publish kind-10312 presence,
* and does NOT touch the audio pipeline. * and does NOT touch the audio pipeline.
*/ */
@@ -105,6 +121,7 @@ fun NestLobbyScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun NestLobbyContent( private fun NestLobbyContent(
event: MeetingSpaceEvent, event: MeetingSpaceEvent,
@@ -114,57 +131,116 @@ private fun NestLobbyContent(
val roomATag = remember(event) { event.address().toValue() } val roomATag = remember(event) { event.address().toValue() }
// Keep cached chat / presence warm while the user is on the lobby. // Keep cached chat / presence warm while the user is on the lobby.
// No MoQ session is opened — that's gated behind the Open Room button. // No MoQ session is opened — that's gated behind the Open action.
NestRoomFilterAssemblerSubscription(roomATag, accountViewModel) NestRoomFilterAssemblerSubscription(roomATag, accountViewModel)
// Composer for kind-1311 chat. Same widget the in-room screen uses,
// so the user gets @-mention picker, file uploads, draft auto-save,
// emoji suggestions, and reply preview without joining audio.
val nestScreenModel: NestNewMessageViewModel =
viewModel(key = "NestLobby/$roomATag")
nestScreenModel.init(accountViewModel)
nestScreenModel.load(event)
Scaffold( Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = { topBar = {
TopBarWithBackButton( ShorterTopAppBar(
caption = event.room().orEmpty(), title = {
nav = nav, Text(
text = event.room().orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
if (nav.canPop()) {
IconButton(nav::popBack) {
ArrowBackIcon()
}
}
},
actions = {
OpenNestRoomAction(
event = event,
accountViewModel = accountViewModel,
)
},
) )
}, },
bottomBar = { ) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.imePadding(),
) {
LazyColumn(
modifier = Modifier.weight(1f),
state = rememberLazyListState(),
) {
item("header") {
RoomHeader(event, accountViewModel, nav)
}
item("listeners") {
CachedListenerCount(roomATag)
}
item("chat-header") {
Text(
text = stringRes(R.string.nest_lobby_recent_chat),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
cachedChatItems(roomATag, accountViewModel, nav)
}
// The composer sits above the 3-button nav when the keyboard
// is closed and above the keyboard when it's open. The chat
// list above is free to scroll behind the navigation bar.
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) .windowInsetsPadding(WindowInsets.navigationBars),
.padding(16.dp),
) { ) {
OpenNestRoomButton( NestEditFieldRow(
event = event, nestScreenModel = nestScreenModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = Modifier.fillMaxWidth(), onSendNewMessage = {},
nav = nav,
) )
} }
},
) { padding ->
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.padding(padding),
state = rememberLazyListState(),
) {
item("header") {
RoomHeader(event, accountViewModel, nav)
}
item("listeners") {
CachedListenerCount(roomATag)
}
item("chat-header") {
Text(
text = stringRes(R.string.nest_lobby_recent_chat),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
cachedChatItems(roomATag, accountViewModel, nav)
} }
} }
} }
@Composable
private fun OpenNestRoomAction(
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
) {
val serviceBase = event.service()
val endpoint = event.endpoint()
val roomId = event.address().dTag
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
val context = LocalContext.current
TextButton(
onClick = {
NestBridge.set(accountViewModel)
NestActivity.launch(
context = context,
addressValue = event.address().toValue(),
)
},
) {
Text(stringRes(R.string.nest_lobby_open_action))
}
}
@Composable @Composable
private fun RoomHeader( private fun RoomHeader(
event: MeetingSpaceEvent, event: MeetingSpaceEvent,
+1 -1
View File
@@ -533,7 +533,7 @@
<string name="nest_notification_stop">Stop</string> <string name="nest_notification_stop">Stop</string>
<string name="nest_join">Join nest</string> <string name="nest_join">Join nest</string>
<string name="nest_lobby_host_label">Host</string> <string name="nest_lobby_host_label">Host</string>
<string name="nest_lobby_open_room">Open room</string> <string name="nest_lobby_open_action">Open</string>
<string name="nest_lobby_recent_chat">Recent chat</string> <string name="nest_lobby_recent_chat">Recent chat</string>
<string name="nest_lobby_no_listeners">No one is in the room right now.</string> <string name="nest_lobby_no_listeners">No one is in the room right now.</string>
<string name="nest_lobby_listeners_count">%1$d listening</string> <string name="nest_lobby_listeners_count">%1$d listening</string>