fix(nests): NestChatPanel takes weight(1) so chat fills the screen

Previously the room screen wrapped EVERYTHING — title, summary,
participants, talk row, action row, AND chat — in a single
verticalScroll Column. The chat panel sat at the bottom with a fixed
NEST_CHAT_PANEL_HEIGHT (420dp), which meant on tall phones the chat
left blank space below it and on small phones the chat was cramped.

Restructure NestFullScreen's body:
  - Outer Column.fillMaxSize, no scroll, owns safeDrawing inset.
  - Top metadata: Column with weight(1f, fill=false), internal
    verticalScroll, horizontal+top padding. Caps at half the screen
    so an over-tall participants list scrolls internally instead of
    pushing the chat off-screen.
  - NestChatPanel: Column-scoped weight(1f, fill=true), horizontal
    padding + bottom inset. Always takes its full allocation —
    chat dominates the screen, fixed-height fallback gone.

NestChatPanel is now a `ColumnScope.NestChatPanel` extension so its
modifier can use `weight()` from the caller. Inside the panel, the
message list Box also uses weight(1f, fill=true) of the panel's own
Column, so the LazyColumn fills everything except the composer.

NEST_CHAT_PANEL_HEIGHT constant removed — it was the source of the
fixed-height behavior the user explicitly didn't want.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
This commit is contained in:
Claude
2026-04-27 20:31:08 +00:00
parent 7f48b18319
commit 84712f48f1
2 changed files with 225 additions and 212 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
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.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -97,7 +98,7 @@ import kotlinx.coroutines.launch
* which are pinned to the channel-VM model — out of scope here. * which are pinned to the channel-VM model — out of scope here.
*/ */
@Composable @Composable
internal fun NestChatPanel( internal fun ColumnScope.NestChatPanel(
event: MeetingSpaceEvent, event: MeetingSpaceEvent,
viewModel: NestViewModel, viewModel: NestViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -120,7 +121,7 @@ internal fun NestChatPanel(
val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" } val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" }
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth().height(NEST_CHAT_PANEL_HEIGHT)) { Box(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) {
if (messages.isEmpty()) { if (messages.isEmpty()) {
Text( Text(
text = stringRes(R.string.nest_chat_empty), text = stringRes(R.string.nest_chat_empty),
@@ -266,6 +267,4 @@ private fun NestChatComposer(
} }
} }
private val NEST_CHAT_PANEL_HEIGHT = 420.dp
private val NEST_CHAT_NO_OP_NOTE: (com.vitorpamplona.amethyst.model.Note) -> Unit = {} private val NEST_CHAT_NO_OP_NOTE: (com.vitorpamplona.amethyst.model.Note) -> Unit = {}
@@ -98,19 +98,20 @@ internal fun NestFullScreen(
) { ) {
val roomTheme = androidx.compose.runtime.remember(event) { RoomTheme.from(event) } val roomTheme = androidx.compose.runtime.remember(event) { RoomTheme.from(event) }
NestThemedScope(theme = roomTheme, accountViewModel = accountViewModel) { NestThemedScope(theme = roomTheme, accountViewModel = accountViewModel) {
// Inset the room content inside the system bars so the title // Outer column owns the safeDrawing inset; the top metadata
// doesn't slide under the status bar and the Leave / chat // section and the chat panel are weighted siblings.
// composer don't sit beneath the gesture / nav bar. The //
// themed background color and `bg` image still paint // The top section uses `weight(1f, fill = false)` so it never
// edge-to-edge — they live on NestThemedScope's outer Box, // exceeds half the screen and scrolls internally if it would.
// which is intentionally outside this padding. // The chat uses `weight(1f, fill = true)` so it always takes
// its full allocation — the user explicitly wanted chat to
// dominate the screen rather than ride a fixed-height box at
// the bottom of the metadata scroll.
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing),
.padding(16.dp)
.verticalScroll(rememberScrollState()),
) { ) {
var showEditSheet by rememberSaveable { mutableStateOf(false) } var showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) } var showHostMenu by rememberSaveable { mutableStateOf(false) }
@@ -118,226 +119,239 @@ internal fun NestFullScreen(
val isHost = accountViewModel.account.signer.pubKey == event.pubKey val isHost = accountViewModel.account.signer.pubKey == event.pubKey
val leaveScope = rememberCoroutineScope() val leaveScope = rememberCoroutineScope()
Row( Column(
modifier = Modifier.fillMaxWidth(), modifier =
horizontalArrangement = Arrangement.SpaceBetween, Modifier
verticalAlignment = Alignment.Top, .weight(1f, fill = false)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(top = 16.dp),
) { ) {
event.room()?.let { Row(
Text( modifier = Modifier.fillMaxWidth(),
text = it, horizontalArrangement = Arrangement.SpaceBetween,
style = MaterialTheme.typography.headlineSmall, verticalAlignment = Alignment.Top,
modifier = Modifier.weight(1f), ) {
) event.room()?.let {
} Text(
// Overflow menu — visible to everyone (Share); host-only text = it,
// rows (Edit) are gated inside. style = MaterialTheme.typography.headlineSmall,
Box { modifier = Modifier.weight(1f),
androidx.compose.material3.IconButton(onClick = { showHostMenu = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.nest_overflow_menu),
) )
} }
val context = androidx.compose.ui.platform.LocalContext.current // Overflow menu — visible to everyone (Share); host-only
androidx.compose.material3.DropdownMenu( // rows (Edit) are gated inside.
expanded = showHostMenu, Box {
onDismissRequest = { showHostMenu = false }, androidx.compose.material3.IconButton(onClick = { showHostMenu = true }) {
) { Icon(
androidx.compose.material3.DropdownMenuItem( symbol = MaterialSymbols.MoreVert,
text = { Text(stringRes(R.string.nest_share_action)) }, contentDescription = stringRes(R.string.nest_overflow_menu),
onClick = {
showHostMenu = false
shareRoomNaddr(context, event)
},
)
if (isHost) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(stringRes(R.string.nest_edit_title)) },
onClick = {
showHostMenu = false
showEditSheet = true
},
) )
} }
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.material3.DropdownMenu(
expanded = showHostMenu,
onDismissRequest = { showHostMenu = false },
) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(stringRes(R.string.nest_share_action)) },
onClick = {
showHostMenu = false
shareRoomNaddr(context, event)
},
)
if (isHost) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(stringRes(R.string.nest_edit_title)) },
onClick = {
showHostMenu = false
showEditSheet = true
},
)
}
}
} }
} }
} if (showEditSheet) {
if (showEditSheet) { EditNestSheet(
EditNestSheet( accountViewModel = accountViewModel,
accountViewModel = accountViewModel, event = event,
event = event, onDismiss = { showEditSheet = false },
onDismiss = { showEditSheet = false }, )
) }
} event.summary()?.let {
event.summary()?.let { Text(
Text( text = it,
text = it, style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp),
modifier = Modifier.padding(top = 4.dp),
)
}
// Listener counter — counts every active kind-10312 presence
// in the room. Hidden until the aggregator has at least one
// entry so the placeholder doesn't flash on entry.
val presences by viewModel.presences.collectAsState()
val listenerCount = presences.size
if (listenerCount > 0) {
Text(
text =
androidx.compose.ui.res.pluralStringResource(
R.plurals.nest_listener_count,
listenerCount,
listenerCount,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
// Long-press opens the participant context sheet for ANYONE
// (T2 #2). The sheet's own gating decides which rows to show
// (follow/mute always; promote/demote/kick host-only).
val onLongPressParticipant: ((String) -> Unit) = { target ->
if (target != accountViewModel.account.signer.pubKey) hostMenuTarget = target
}
// Tier-2 #1: replace the two LazyRow sections with a single
// pure-projection ParticipantGrid. The `absent` flag (member
// promoted in the kind-30312 but never emitted a kind-10312)
// greys out at 50 % alpha, matching nostrnests' web client.
val participantGrid =
androidx.compose.runtime.remember(event, presences) {
com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid(
participants = event.participants(),
presences = presences,
) )
} }
ParticipantsGrid(
grid = participantGrid,
speakingNow = ui.speakingNow,
accountViewModel = accountViewModel,
onStageLabel = stringRes(R.string.nest_stage),
audienceLabel = stringRes(R.string.nest_audience),
reactionsByPubkey = reactionsByPubkey,
connectingSpeakers = ui.connectingSpeakers,
onLongPressParticipant = onLongPressParticipant,
)
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
hostMenuTarget?.let { target ->
ParticipantHostActionsSheet(
target = target,
event = event,
accountViewModel = accountViewModel,
onDismiss = { hostMenuTarget = null },
catalog = speakerCatalogs[target],
)
}
if (isHost) { // Listener counter — counts every active kind-10312 presence
HandRaiseQueueSection( // in the room. Hidden until the aggregator has at least one
event = event, // entry so the placeholder doesn't flash on entry.
viewModel = viewModel, val presences by viewModel.presences.collectAsState()
accountViewModel = accountViewModel, val listenerCount = presences.size
) if (listenerCount > 0) {
} Text(
text =
ConnectionRow(viewModel = viewModel, ui = ui) androidx.compose.ui.res.pluralStringResource(
R.plurals.nest_listener_count,
val myPubkey = accountViewModel.account.signer.pubKey listenerCount,
if (viewModel.canBroadcast && onStage.any { it.pubKey == myPubkey }) { listenerCount,
TalkRow(viewModel = viewModel, ui = ui, speakerPubkeyHex = myPubkey)
}
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconToggleButton(
checked = handRaised,
onCheckedChange = onHandRaisedChange,
) {
Icon(
symbol = MaterialSymbols.PanTool,
contentDescription =
stringRes(
if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand,
), ),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
) )
} }
OutlinedButton(onClick = { showReactionPicker = true }) {
Text(stringRes(R.string.nest_reactions_button))
}
OutlinedButton(
onClick = {
if (isHost) {
showHostLeaveConfirm = true
} else {
onLeave()
}
},
) {
Text(stringRes(R.string.nest_leave))
}
}
if (showReactionPicker) {
RoomReactionPickerSheet(
onPick = { emoji ->
accountViewModel.reactToOrDelete(roomNote, emoji)
},
onDismiss = { showReactionPicker = false },
)
}
if (showHostLeaveConfirm) { val reactionsByPubkey by viewModel.recentReactions.collectAsState()
AlertDialog( var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
onDismissRequest = { showHostLeaveConfirm = false }, // Long-press opens the participant context sheet for ANYONE
title = { Text(stringRes(R.string.nest_leave_host_title)) }, // (T2 #2). The sheet's own gating decides which rows to show
text = { Text(stringRes(R.string.nest_leave_host_body)) }, // (follow/mute always; promote/demote/kick host-only).
confirmButton = { val onLongPressParticipant: ((String) -> Unit) = { target ->
TextButton( if (target != accountViewModel.account.signer.pubKey) hostMenuTarget = target
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), }
onClick = { // Tier-2 #1: replace the two LazyRow sections with a single
showHostLeaveConfirm = false // pure-projection ParticipantGrid. The `absent` flag (member
leaveScope.launch { // promoted in the kind-30312 but never emitted a kind-10312)
val ok = closeMeetingSpace(accountViewModel, event) // greys out at 50 % alpha, matching nostrnests' web client.
if (!ok) { val participantGrid =
accountViewModel.toastManager.toast( androidx.compose.runtime.remember(event, presences) {
R.string.nests, com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid(
R.string.nest_leave_host_close_failed, participants = event.participants(),
) presences = presences,
} )
onLeave() }
} ParticipantsGrid(
}, grid = participantGrid,
) { speakingNow = ui.speakingNow,
Text(stringRes(R.string.nest_leave_host_close)) accountViewModel = accountViewModel,
} onStageLabel = stringRes(R.string.nest_stage),
}, audienceLabel = stringRes(R.string.nest_audience),
dismissButton = { reactionsByPubkey = reactionsByPubkey,
TextButton( connectingSpeakers = ui.connectingSpeakers,
onClick = { onLongPressParticipant = onLongPressParticipant,
showHostLeaveConfirm = false
onLeave()
},
) {
Text(stringRes(R.string.nest_leave_host_just_leave))
}
},
) )
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
hostMenuTarget?.let { target ->
ParticipantHostActionsSheet(
target = target,
event = event,
accountViewModel = accountViewModel,
onDismiss = { hostMenuTarget = null },
catalog = speakerCatalogs[target],
)
}
if (isHost) {
HandRaiseQueueSection(
event = event,
viewModel = viewModel,
accountViewModel = accountViewModel,
)
}
ConnectionRow(viewModel = viewModel, ui = ui)
val myPubkey = accountViewModel.account.signer.pubKey
if (viewModel.canBroadcast && onStage.any { it.pubKey == myPubkey }) {
TalkRow(viewModel = viewModel, ui = ui, speakerPubkeyHex = myPubkey)
}
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconToggleButton(
checked = handRaised,
onCheckedChange = onHandRaisedChange,
) {
Icon(
symbol = MaterialSymbols.PanTool,
contentDescription =
stringRes(
if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand,
),
)
}
OutlinedButton(onClick = { showReactionPicker = true }) {
Text(stringRes(R.string.nest_reactions_button))
}
OutlinedButton(
onClick = {
if (isHost) {
showHostLeaveConfirm = true
} else {
onLeave()
}
},
) {
Text(stringRes(R.string.nest_leave))
}
}
if (showReactionPicker) {
RoomReactionPickerSheet(
onPick = { emoji ->
accountViewModel.reactToOrDelete(roomNote, emoji)
},
onDismiss = { showReactionPicker = false },
)
}
if (showHostLeaveConfirm) {
AlertDialog(
onDismissRequest = { showHostLeaveConfirm = false },
title = { Text(stringRes(R.string.nest_leave_host_title)) },
text = { Text(stringRes(R.string.nest_leave_host_body)) },
confirmButton = {
TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
onClick = {
showHostLeaveConfirm = false
leaveScope.launch {
val ok = closeMeetingSpace(accountViewModel, event)
if (!ok) {
accountViewModel.toastManager.toast(
R.string.nests,
R.string.nest_leave_host_close_failed,
)
}
onLeave()
}
},
) {
Text(stringRes(R.string.nest_leave_host_close))
}
},
dismissButton = {
TextButton(
onClick = {
showHostLeaveConfirm = false
onLeave()
},
) {
Text(stringRes(R.string.nest_leave_host_just_leave))
}
},
)
}
} }
NestChatPanel( NestChatPanel(
event = event, event = event,
viewModel = viewModel, viewModel = viewModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = Modifier.padding(top = 12.dp), modifier =
Modifier
.weight(1f, fill = true)
.padding(horizontal = 16.dp)
.padding(top = 12.dp, bottom = 16.dp),
) )
} }
} }