Merge pull request #2614 from vitorpamplona/claude/fix-nest-activity-layout-zziPP

Refactor NestFullScreen layout: tabs, action bar, and stage grid
This commit is contained in:
Vitor Pamplona
2026-04-27 21:04:35 -04:00
committed by GitHub
4 changed files with 889 additions and 568 deletions
@@ -0,0 +1,407 @@
/*
* 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.nests.room
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Sticky bottom action bar for the room screen. Replaces the three
* separate rows (`ConnectionRow`, `TalkRow`, hand/react/leave) that
* used to scroll away with the rest of the metadata.
*
* Layout adapts to connection / broadcast / on-stage state:
* - Disconnected → `[Connect]` (start) · `[Leave]` (end)
* - Connecting / Reconnecting → state chip · `[Leave]`
* - Connected, audience → `[Listen mute toggle]` · `[Hand] [React] [Leave]`
* - Connected, on-stage idle → `[Talk] [Leave stage]` · `[React] [Leave]`
* - Connected, on-stage live → `[Mic mute] [Stop] [Leave stage]` · `[React] [Leave]`
* - Failed (connection or broadcast) → status strip above the bar
* plus a retry button in the start cluster.
*
* The Hand button hides when the user is already on stage — the
* action it represents ("I want to speak") doesn't apply once you can.
*/
@Composable
internal fun NestActionBar(
viewModel: NestViewModel,
ui: NestUiState,
isOnStage: Boolean,
canBroadcast: Boolean,
speakerPubkeyHex: String,
handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
onLeave: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 3.dp,
) {
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
ActionBarStatusStrip(ui = ui)
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(modifier = Modifier.weight(1f, fill = true)) {
StartCluster(
viewModel = viewModel,
ui = ui,
isOnStage = isOnStage,
canBroadcast = canBroadcast,
speakerPubkeyHex = speakerPubkeyHex,
)
}
EndCluster(
isOnStage = isOnStage,
handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = onShowReactionPicker,
onLeave = onLeave,
)
}
}
}
}
@Composable
private fun ActionBarStatusStrip(ui: NestUiState) {
val text =
when (val connection = ui.connection) {
is ConnectionUiState.Failed -> {
stringRes(R.string.nest_audio_failed, connection.reason)
}
else -> {
when (val broadcast = ui.broadcast) {
is BroadcastUiState.Failed -> {
stringRes(R.string.nest_broadcast_failed, broadcast.reason)
}
else -> {
null
}
}
}
}
if (text == null) return
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
)
}
@Composable
private fun StartCluster(
viewModel: NestViewModel,
ui: NestUiState,
isOnStage: Boolean,
canBroadcast: Boolean,
speakerPubkeyHex: String,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
when (val connection = ui.connection) {
is ConnectionUiState.Idle, is ConnectionUiState.Closed -> {
Button(onClick = { viewModel.connect() }) {
Text(stringRes(R.string.nest_connect))
}
}
is ConnectionUiState.Connecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(connectingLabel(connection)) },
)
}
is ConnectionUiState.Reconnecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_reconnecting)) },
)
}
is ConnectionUiState.Connected -> {
if (canBroadcast && isOnStage) {
OnStageControls(
viewModel = viewModel,
ui = ui,
speakerPubkeyHex = speakerPubkeyHex,
)
} else {
// Audience: a single mute toggle for the inbound
// listener stream. Mirrors the old ConnectionRow.
FilledTonalIconToggleButton(
checked = ui.isMuted,
onCheckedChange = { viewModel.setMuted(it) },
) {
Icon(
symbol =
if (ui.isMuted) {
MaterialSymbols.AutoMirrored.VolumeOff
} else {
MaterialSymbols.AutoMirrored.VolumeUp
},
contentDescription =
stringRes(
if (ui.isMuted) R.string.nest_unmute else R.string.nest_mute,
),
)
}
}
}
is ConnectionUiState.Failed -> {
Button(onClick = { viewModel.connect() }) {
Text(stringRes(R.string.nest_connect))
}
}
}
}
}
@Composable
private fun OnStageControls(
viewModel: NestViewModel,
ui: NestUiState,
speakerPubkeyHex: String,
) {
val context = LocalContext.current
var permissionDenied by rememberSaveable { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
permissionDenied = false
viewModel.startBroadcast(speakerPubkeyHex)
} else {
permissionDenied = true
}
}
// If the user grants RECORD_AUDIO via the system Settings deep-link
// and returns to the activity, the launcher callback never fires —
// recompute every time so the warning auto-clears.
val showDenialWarning =
permissionDenied &&
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
when (val broadcast = ui.broadcast) {
BroadcastUiState.Idle -> {
Button(onClick = {
val granted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
viewModel.startBroadcast(speakerPubkeyHex)
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}) {
Text(stringRes(R.string.nest_talk))
}
OutlinedButton(onClick = { viewModel.setOnStage(false) }) {
Text(stringRes(R.string.nest_leave_stage))
}
if (showDenialWarning) {
// After "Don't ask again" the permission launcher
// silently returns false; surface a Settings deep-link.
OutlinedButton(onClick = {
runCatching {
context.startActivity(
android.content
.Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
android.net.Uri.fromParts("package", context.packageName, null),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}) {
Text(stringRes(R.string.nest_open_settings))
}
}
}
BroadcastUiState.Connecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_broadcast_connecting)) },
)
}
is BroadcastUiState.Broadcasting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_broadcasting)) },
colors =
AssistChipDefaults.assistChipColors(
disabledLabelColor = MaterialTheme.colorScheme.error,
),
)
FilledTonalIconToggleButton(
checked = broadcast.isMuted,
onCheckedChange = { viewModel.setMicMuted(it) },
) {
Icon(
symbol =
if (broadcast.isMuted) {
MaterialSymbols.AutoMirrored.VolumeOff
} else {
MaterialSymbols.AutoMirrored.VolumeUp
},
contentDescription =
stringRes(
if (broadcast.isMuted) R.string.nest_mic_unmute else R.string.nest_mic_mute,
),
)
}
OutlinedButton(onClick = { viewModel.stopBroadcast() }) {
Text(stringRes(R.string.nest_stop_talking))
}
OutlinedButton(onClick = {
viewModel.stopBroadcast()
viewModel.setOnStage(false)
}) {
Text(stringRes(R.string.nest_leave_stage))
}
}
is BroadcastUiState.Failed -> {
Button(onClick = { viewModel.startBroadcast(speakerPubkeyHex) }) {
Text(stringRes(R.string.nest_talk))
}
}
}
}
@Composable
private fun EndCluster(
isOnStage: Boolean,
handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
onLeave: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Hand-raise only makes sense for audience: if you're already
// on stage, you don't need to ask. Hidden when on-stage.
if (!isOnStage) {
FilledTonalIconToggleButton(
checked = handRaised,
onCheckedChange = onHandRaisedChange,
) {
Icon(
symbol = MaterialSymbols.PanTool,
contentDescription =
stringRes(
if (handRaised) R.string.nest_lower_hand else R.string.nest_raise_hand,
),
)
}
}
// React is always visible — even disconnected users can
// (un)react via the reactions picker on the room note.
FilledTonalIconButton(onClick = onShowReactionPicker) {
Icon(
symbol = MaterialSymbols.EmojiEmotions,
contentDescription = stringRes(R.string.nest_reactions_button),
)
}
Spacer(Modifier.width(4.dp))
OutlinedButton(
onClick = onLeave,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Logout,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(6.dp))
Text(stringRes(R.string.nest_leave))
}
}
}
@@ -20,10 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,21 +27,20 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
@@ -53,6 +48,7 @@ import androidx.compose.runtime.Composable
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -60,14 +56,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid
@@ -82,10 +76,21 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTa
import kotlinx.coroutines.launch
/**
* Full-screen layout for [NestActivity]. Renders title + summary,
* host/speaker/audience rows (with active-speaker rings), connection chip
* + mute, talk row (when allowed), hand-raise, and a Leave button that
* finishes the activity.
* Full-screen layout for [NestActivity]. Vertically split into:
*
* 1. TopAppBar — room title + overflow menu (Share, host's Edit).
* 2. Header strip — LIVE chip, listener count, optional 1-line summary.
* 3. Stage — vertical adaptive grid of host/speakers (height-bounded
* so a 30-speaker room scrolls inside the strip and never pushes
* the chat below the fold).
* 4. Tabs — `Chat | Audience · N | Hands · N` (Hands host-only,
* shown only while there's at least one raised hand).
* 5. Tab content — fills the remaining vertical space:
* - Chat: the kind-1311 transcript + composer
* - Audience: lazy vertical grid (handles 1,000+ listeners)
* - Hands: host's promote-to-speaker queue
* 6. Sticky action bar — connection / talk / hand / react / leave
* controls; never scrolls.
*
* The PIP variant lives in [NestPipScreen]; the activity flips
* between them based on `isInPipMode`.
@@ -105,16 +110,61 @@ internal fun NestFullScreen(
var showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) }
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
// Tab selection survives configuration changes and PIP transitions.
// Stored as ordinal so rememberSaveable can persist it without a
// custom Saver.
var selectedTabIndex by rememberSaveable { mutableStateOf(0) }
val isHost = accountViewModel.account.signer.pubKey == event.pubKey
val myPubkey = accountViewModel.account.signer.pubKey
val isOnStageMe = remember(onStage, myPubkey) { onStage.any { it.pubKey == myPubkey } }
val leaveScope = rememberCoroutineScope()
val topBarContext = LocalContext.current
// Scaffold owns safeDrawing insets via its `contentWindowInsets`
// default, so we don't manage them manually anymore. The
// container is transparent because NestThemedScope's outer
// Surface already paints the themed background (and overlays
// the optional `bg` image on top of it); painting again here
// would double up.
val presences by viewModel.presences.collectAsState()
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
val onStageKeys = remember(onStage) { onStage.map { it.pubKey }.toSet() }
val participantGrid =
remember(event, presences) {
buildParticipantGrid(
participants = event.participants(),
presences = presences,
)
}
// Same logic HandRaiseQueueSection uses internally — duplicated
// here so the tab label can show a count without coupling the
// section to the screen.
val handsCount =
remember(presences, onStageKeys) {
presences.values.count { it.handRaised && it.pubkey !in onStageKeys }
}
val showHandsTab = isHost && handsCount > 0
// Tab roster changes when the Hands tab appears/disappears.
// If the user was on Hands and the queue empties, fall back to
// Chat — kept as a stable default rather than Audience because
// chat is the room's primary engagement surface.
val tabs =
remember(showHandsTab) {
buildList {
add(NestTab.Chat)
add(NestTab.Audience)
if (showHandsTab) add(NestTab.Hands)
}
}
val effectiveTab = tabs.getOrNull(selectedTabIndex) ?: NestTab.Chat
// Long-press on any participant opens the host-actions sheet
// (T2 #2). The sheet itself gates which rows render based on
// host status. Skip self.
val onLongPressParticipant: ((String) -> Unit) = { target ->
if (target != myPubkey) hostMenuTarget = target
}
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent,
@@ -135,185 +185,153 @@ internal fun NestFullScreen(
},
)
},
bottomBar = {
NestActionBar(
viewModel = viewModel,
ui = ui,
isOnStage = isOnStageMe,
canBroadcast = viewModel.canBroadcast,
speakerPubkeyHex = myPubkey,
handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = { showReactionPicker = true },
onLeave = {
if (isHost) {
showHostLeaveConfirm = true
} else {
onLeave()
}
},
)
},
) { padding ->
// Inner column is just the two weighted siblings — top
// metadata (scrolls internally if overflow) and the chat
// panel (takes the rest). Title and overflow menu live in
// the TopAppBar above.
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding),
) {
Column(
modifier =
Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
event.summary()?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// 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 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) {
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,
RoomHeaderStrip(
summary = event.summary(),
listenerCount = presences.size,
)
StageGrid(
members = participantGrid.onStage,
speakingNow = ui.speakingNow,
accountViewModel = accountViewModel,
reactionsByPubkey = reactionsByPubkey,
connectingSpeakers = ui.connectingSpeakers,
onLongPressParticipant = onLongPressParticipant,
modifier = Modifier.padding(horizontal = 16.dp),
)
NestTabRow(
tabs = tabs,
selectedTab = effectiveTab,
audienceCount = participantGrid.audience.size,
handsCount = handsCount,
onSelect = { tab -> selectedTabIndex = tabs.indexOf(tab).coerceAtLeast(0) },
modifier = Modifier.padding(top = 8.dp),
)
when (effectiveTab) {
NestTab.Chat -> {
NestChatPanel(
event = event,
viewModel = viewModel,
accountViewModel = accountViewModel,
onDismiss = { hostMenuTarget = null },
catalog = speakerCatalogs[target],
modifier =
Modifier
.weight(1f)
.padding(horizontal = 16.dp),
)
}
if (isHost) {
NestTab.Audience -> {
AudienceGrid(
members = participantGrid.audience,
accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant,
modifier =
Modifier
.weight(1f)
.padding(horizontal = 12.dp),
)
}
NestTab.Hands -> {
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))
}
},
modifier =
Modifier
.weight(1f)
.padding(horizontal = 16.dp),
)
}
}
NestChatPanel(
event = event,
viewModel = viewModel,
accountViewModel = accountViewModel,
modifier = Modifier.weight(1f),
)
}
}
// EditNestSheet renders as a ModalBottomSheet — placement in
// the tree doesn't affect layout, but keeping it adjacent to
// the Scaffold makes the dialog/sheet boundary obvious.
// Sheets and dialogs render alongside the Scaffold so they
// cover the room content (including the action bar) when
// open. ParticipantHostActionsSheet stays outside the Column
// for the same reason — modal bottom sheets are not affected
// by parent layout.
hostMenuTarget?.let { target ->
ParticipantHostActionsSheet(
target = target,
event = event,
accountViewModel = accountViewModel,
onDismiss = { hostMenuTarget = null },
catalog = speakerCatalogs[target],
)
}
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))
}
},
)
}
if (showEditSheet) {
EditNestSheet(
accountViewModel = accountViewModel,
@@ -323,15 +341,121 @@ internal fun NestFullScreen(
}
}
/**
* Header strip rendered between the TopAppBar and the stage grid.
* Carries the LIVE chip + listener count and (when present) a single-
* line ellipsised summary. Lives at the screen level rather than in
* the TopAppBar so the chip + count have room to breathe and the
* summary's typography matches the body, not the title.
*/
@Composable
private fun RoomHeaderStrip(
summary: String?,
listenerCount: Int,
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LiveChip()
Text(
text = pluralStringResource(R.plurals.nest_listener_count, listenerCount, listenerCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!summary.isNullOrBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Composable
private fun LiveChip() {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
Text(
text = stringRes(R.string.nest_live_chip),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
/**
* Tab-bar between the stage and the tab content. Audience / Hands
* tabs carry a count badge so the user knows whether switching tabs
* is worthwhile (e.g. "Hands · 3" tells the host they have a queue
* to attend to without ever leaving the chat).
*/
@Composable
private fun NestTabRow(
tabs: List<NestTab>,
selectedTab: NestTab,
audienceCount: Int,
handsCount: Int,
onSelect: (NestTab) -> Unit,
modifier: Modifier = Modifier,
) {
val selectedIndex = tabs.indexOf(selectedTab).coerceAtLeast(0)
PrimaryTabRow(
selectedTabIndex = selectedIndex,
modifier = modifier.fillMaxWidth(),
containerColor = Color.Transparent,
) {
tabs.forEach { tab ->
val (label, count) =
when (tab) {
NestTab.Chat -> stringRes(R.string.nest_tab_chat) to 0
NestTab.Audience -> stringRes(R.string.nest_tab_audience) to audienceCount
NestTab.Hands -> stringRes(R.string.nest_tab_hands) to handsCount
}
Tab(
selected = tab == selectedTab,
onClick = { onSelect(tab) },
text = {
if (count > 0) {
BadgedBox(
badge = {
Badge { Text(text = count.toString()) }
},
) {
Text(text = label)
}
} else {
Text(text = label)
}
},
)
}
}
}
private enum class NestTab {
Chat,
Audience,
Hands,
}
/**
* TopAppBar for the room screen — hosts the room name and the
* overflow menu (Share for everyone, Edit for the host). The menu
* state lives at the screen level so the EditNestSheet can be
* triggered from here and rendered alongside the Scaffold.
*
* Container color is transparent so the themed background painted
* by [NestThemedScope]'s Surface (and any optional `bg` image) shows
* through cleanly.
* Container color is transparent so the surface beneath the
* Scaffold (set by the activity / parent theme) shows through.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -384,228 +508,6 @@ private fun NestTopAppBar(
)
}
@Composable
private fun ConnectionRow(
viewModel: NestViewModel,
ui: NestUiState,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
when (val connection = ui.connection) {
is ConnectionUiState.Idle, is ConnectionUiState.Closed -> {
Button(onClick = { viewModel.connect() }) {
Text(stringRes(R.string.nest_connect))
}
}
is ConnectionUiState.Connecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(connectingLabel(connection)) },
)
}
is ConnectionUiState.Reconnecting -> {
// The wrapper retries on its own; the user typically
// doesn't need to do anything. Keep the mute toggle
// hidden during this window — there's no live session
// to apply mute against, and showing it implies a
// healthier connection than we have.
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_reconnecting)) },
)
}
is ConnectionUiState.Connected -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_connected)) },
colors =
AssistChipDefaults.assistChipColors(
disabledLabelColor = MaterialTheme.colorScheme.primary,
),
)
FilledTonalIconToggleButton(
checked = ui.isMuted,
onCheckedChange = { viewModel.setMuted(it) },
) {
Icon(
symbol = if (ui.isMuted) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp,
contentDescription =
stringRes(
if (ui.isMuted) R.string.nest_unmute else R.string.nest_mute,
),
)
}
}
is ConnectionUiState.Failed -> {
Text(
text = stringRes(R.string.nest_audio_failed, connection.reason),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f, fill = false),
)
Button(onClick = { viewModel.connect() }) {
Text(stringRes(R.string.nest_connect))
}
}
}
}
}
@Composable
private fun TalkRow(
viewModel: NestViewModel,
ui: NestUiState,
speakerPubkeyHex: String,
) {
// Render whenever the user can act on broadcast state. The
// speaker session is independent of the listener: a transient
// listener Reconnecting must NOT hide the mic-mute / Stop
// controls if the broadcast is in flight, otherwise the user
// can't pause their own mic during a network blip.
val canAct =
ui.connection is ConnectionUiState.Connected ||
ui.broadcast is BroadcastUiState.Broadcasting ||
ui.broadcast is BroadcastUiState.Connecting
if (!canAct) return
val context = LocalContext.current
var permissionDenied by rememberSaveable { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
permissionDenied = false
viewModel.startBroadcast(speakerPubkeyHex)
} else {
permissionDenied = true
}
}
// If the user grants RECORD_AUDIO via the system Settings deep-link
// and returns to the activity, the permissionLauncher callback never
// fires and `permissionDenied` would otherwise stay true. Recompute
// every time the permission state could have changed (audit round-2
// Android #12).
val showDenialWarning =
permissionDenied &&
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
when (val broadcast = ui.broadcast) {
BroadcastUiState.Idle -> {
Button(onClick = {
val granted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
viewModel.startBroadcast(speakerPubkeyHex)
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}) {
Text(stringRes(R.string.nest_talk))
}
OutlinedButton(onClick = { viewModel.setOnStage(false) }) {
Text(stringRes(R.string.nest_leave_stage))
}
if (showDenialWarning) {
Text(
text = stringRes(R.string.nest_record_permission_required),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
// After "Don't ask again" the permission launcher
// silently returns false. Give the user a path to
// toggle the permission in system settings (audit
// Android #14).
OutlinedButton(onClick = {
runCatching {
context.startActivity(
android.content
.Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
android.net.Uri.fromParts("package", context.packageName, null),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}) {
Text(stringRes(R.string.nest_open_settings))
}
}
}
BroadcastUiState.Connecting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_broadcast_connecting)) },
)
}
is BroadcastUiState.Broadcasting -> {
AssistChip(
onClick = {},
enabled = false,
label = { Text(stringRes(R.string.nest_broadcasting)) },
colors =
AssistChipDefaults.assistChipColors(
disabledLabelColor = MaterialTheme.colorScheme.error,
),
)
FilledTonalIconToggleButton(
checked = broadcast.isMuted,
onCheckedChange = { viewModel.setMicMuted(it) },
) {
Icon(
symbol = if (broadcast.isMuted) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp,
contentDescription =
stringRes(
if (broadcast.isMuted) R.string.nest_mic_unmute else R.string.nest_mic_mute,
),
)
}
OutlinedButton(onClick = { viewModel.stopBroadcast() }) {
Text(stringRes(R.string.nest_stop_talking))
}
OutlinedButton(onClick = {
viewModel.stopBroadcast()
viewModel.setOnStage(false)
}) {
Text(stringRes(R.string.nest_leave_stage))
}
}
is BroadcastUiState.Failed -> {
Text(
text = stringRes(R.string.nest_broadcast_failed, broadcast.reason),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f, fill = false),
)
Button(onClick = { viewModel.startBroadcast(speakerPubkeyHex) }) {
Text(stringRes(R.string.nest_talk))
}
}
}
}
}
/**
* Re-publish the host's [event] with `status="closed"`, preserving every
* other field verbatim (room name, summary, image, service, endpoint,
@@ -28,19 +28,20 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -55,75 +56,127 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.viewmodels.ParticipantGrid
import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
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.amethyst.ui.theme.SpacedBy10dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
private val STAGE_CELL_MIN = 88.dp
private val STAGE_AVATAR = 48.dp
private val AUDIENCE_CELL_MIN = 76.dp
private val AUDIENCE_AVATAR = Size40dp
private val GRID_SPACING = 6.dp
// Two rows visible by default. Each cell is roughly avatar + name +
// reaction headroom, so this lifts to ~200dp on most phones — tall
// enough to show 6-8 speakers without scrolling but small enough to
// leave the chat / audience tab the majority of the viewport.
private val STAGE_MAX_HEIGHT = 220.dp
/**
* Material3 grid for the room participants — replaces the
* horizontal LazyRow layout for rooms with many speakers /
* audience members. On-stage and audience render as separate
* fixed-height horizontal grids (one row per group, scrolls
* horizontally) so the chat panel below stays at a predictable
* vertical position even when the room fills up.
* Vertical adaptive grid for the on-stage section. Used as the
* always-visible header strip in the room layout: speakers flow into
* as many columns as the screen width allows, wrapping to a new row
* once full. Capped at [STAGE_MAX_HEIGHT] so a 30-speaker room can
* scroll inside the strip without pushing the tabs / chat below the
* fold.
*
* Falls open: when [grid] is empty (room with no presence yet),
* neither section renders.
*
* Absent members (kind-30312 `p`-tag with no kind-10312 presence)
* render at 50% alpha — matches nostrnests' grey-out for
* "promoted but never joined".
* Falls open: when [members] is empty, renders nothing.
*/
@Composable
internal fun ParticipantsGrid(
grid: ParticipantGrid,
internal fun StageGrid(
members: List<RoomMember>,
speakingNow: ImmutableSet<String>,
accountViewModel: AccountViewModel,
onStageLabel: String,
audienceLabel: String,
modifier: Modifier = Modifier,
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
connectingSpeakers: ImmutableSet<String> = kotlinx.collections.immutable.persistentSetOf(),
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
onLongPressParticipant: ((String) -> Unit)? = null,
) {
Column(modifier = modifier.fillMaxWidth()) {
if (grid.onStage.isNotEmpty()) {
ParticipantsSection(
title = onStageLabel,
members = grid.onStage,
avatarSize = Size40dp,
speakingNow = speakingNow,
connectingSpeakers = connectingSpeakers,
showMicBadge = true,
accountViewModel = accountViewModel,
reactionsByPubkey = reactionsByPubkey,
onLongPressParticipant = onLongPressParticipant,
if (members.isEmpty()) return
Column(modifier = modifier.fillMaxWidth().padding(top = 8.dp)) {
Text(
text = stringRes(R.string.nest_stage),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp),
)
LazyVerticalGrid(
columns = GridCells.Adaptive(STAGE_CELL_MIN),
modifier = Modifier.fillMaxWidth().heightIn(max = STAGE_MAX_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
items(items = members, key = { it.pubkey }) { member ->
MemberCell(
member = member,
avatarSize = STAGE_AVATAR,
isSpeaking = member.pubkey in speakingNow,
isConnecting = member.pubkey in connectingSpeakers,
showMicBadge = true,
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant,
)
}
}
}
}
/**
* Vertical adaptive grid for the audience tab. Sized to fill the
* remaining vertical space its parent gives it (`Modifier.weight(1f)`)
* so a room with 1,000 listeners scrolls efficiently — the lazy grid
* only composes the rows currently visible.
*
* Audience cells deliberately omit the speaking ring, mic badge and
* connecting spinner: only members with a live broadcast subscription
* earn those, and audience by definition aren't broadcasting. Hand
* badge still renders because the audience is the hand-raise queue.
*/
@Composable
internal fun AudienceGrid(
members: List<RoomMember>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
onLongPressParticipant: ((String) -> Unit)? = null,
) {
if (members.isEmpty()) {
Box(
modifier = modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.nest_audience_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (grid.audience.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
ParticipantsSection(
title = audienceLabel,
members = grid.audience,
avatarSize = Size35dp,
// Audience rows don't get the speaking-ring, mic-state
// pill, or buffering overlay — only members with a
// live broadcast subscription do. Hand-raise badge
// still renders (audience is the queue).
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
connectingSpeakers = kotlinx.collections.immutable.persistentSetOf(),
return
}
LazyVerticalGrid(
columns = GridCells.Adaptive(AUDIENCE_CELL_MIN),
modifier = modifier.fillMaxSize().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
contentPadding =
androidx.compose.foundation.layout
.PaddingValues(vertical = 8.dp),
) {
items(items = members, key = { it.pubkey }) { member ->
MemberCell(
member = member,
avatarSize = AUDIENCE_AVATAR,
isSpeaking = false,
isConnecting = false,
showMicBadge = false,
reactions = emptyList(),
accountViewModel = accountViewModel,
reactionsByPubkey = emptyMap(),
onLongPressParticipant = onLongPressParticipant,
)
}
@@ -131,126 +184,80 @@ internal fun ParticipantsGrid(
}
@Composable
private fun ParticipantsSection(
title: String,
members: List<RoomMember>,
private fun MemberCell(
member: RoomMember,
avatarSize: Dp,
speakingNow: ImmutableSet<String>,
connectingSpeakers: ImmutableSet<String>,
isSpeaking: Boolean,
isConnecting: Boolean,
showMicBadge: Boolean,
reactions: List<RoomReaction>,
accountViewModel: AccountViewModel,
reactionsByPubkey: Map<String, List<RoomReaction>>,
onLongPressParticipant: ((String) -> Unit)?,
) {
val ringColor = MaterialTheme.colorScheme.primary
// Hoist the size/spacing modifiers — same Dp values for every
// cell, so allocating them once per section beats allocating
// per-cell-recompose with N speakers.
val gridModifier =
remember(avatarSize) {
Modifier
.fillMaxWidth()
.height(avatarSize + 48.dp)
.padding(top = 4.dp)
val avatarModifier =
when {
isSpeaking && member.absent -> Modifier.border(2.dp, ringColor, CircleShape).then(Modifier.alpha(0.5f))
isSpeaking -> Modifier.border(2.dp, ringColor, CircleShape)
member.absent -> Modifier.alpha(0.5f)
else -> Modifier
}
val cellWidthModifier = remember(avatarSize) { Modifier.width(avatarSize + 16.dp) }
val absentAlphaModifier = remember { Modifier.alpha(0.5f) }
val speakingBorderModifier = remember(ringColor) { Modifier.border(2.dp, ringColor, CircleShape) }
val spinnerModifier = remember(avatarSize) { Modifier.size(avatarSize - 8.dp) }
Column(modifier = Modifier.padding(top = 8.dp), verticalArrangement = SpacedBy5dp) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Single-row horizontal grid. Cell height = avatar +
// reaction overlay headroom + a username line below; the
// grid auto-fits the avatar + name + (optional) reaction
// bubble vertically without clipping.
LazyHorizontalGrid(
rows = GridCells.Fixed(1),
modifier = gridModifier,
horizontalArrangement = SpacedBy5dp,
) {
items(items = members, key = { it.pubkey }) { member ->
val isSpeaking = member.pubkey in speakingNow
val avatarModifier =
when {
isSpeaking && member.absent -> speakingBorderModifier.then(absentAlphaModifier)
isSpeaking -> speakingBorderModifier
member.absent -> absentAlphaModifier
else -> Modifier
}
val user =
remember(member.pubkey) {
com.vitorpamplona.amethyst.model.LocalCache
.getOrCreateUser(member.pubkey)
}
// Cache the long-click adapter per (pubkey, callback)
// tuple — `onLongPressParticipant?.let { cb -> { hex -> cb(hex) } }`
// would otherwise allocate a fresh lambda on every
// recompose, which adds up across N members during a
// connectingSpeakers / speakingNow flip.
val onLongClick =
remember(member.pubkey, onLongPressParticipant) {
onLongPressParticipant?.let { cb -> { hex: String -> cb(hex) } }
}
val isConnecting = member.pubkey in connectingSpeakers
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = SpacedBy10dp,
modifier = cellWidthModifier,
) {
Box(contentAlignment = Alignment.Center) {
ClickableUserPicture(
baseUserHex = member.pubkey,
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
onLongClick = onLongClick,
)
if (isConnecting) {
// Pre-roll buffering overlay — visible
// between SUBSCRIBE_OK and the first
// decoded frame (typically 0.5-2 s on a
// fresh subscription). Sized smaller than
// the avatar so the user picture stays
// recognisable underneath.
androidx.compose.material3.CircularProgressIndicator(
modifier = spinnerModifier,
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
if (member.handRaised) {
HandRaiseBadge(
avatarSize = avatarSize,
modifier = Modifier.align(Alignment.TopEnd),
)
}
if (showMicBadge && member.publishing) {
MicStateBadge(
isSpeaking = isSpeaking,
isMuted = member.muted == true,
avatarSize = avatarSize,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
UsernameDisplay(
baseUser = user,
weight = Modifier.fillMaxWidth(),
accountViewModel = accountViewModel,
)
val reactions = reactionsByPubkey[member.pubkey].orEmpty()
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions,
modifier = Modifier.padding(top = 2.dp),
)
}
}
val user =
remember(member.pubkey) {
com.vitorpamplona.amethyst.model.LocalCache
.getOrCreateUser(member.pubkey)
}
// Cache the long-click adapter per (pubkey, callback) tuple so a
// recompose during a connectingSpeakers / speakingNow flip doesn't
// allocate a fresh lambda for every cell.
val onLongClick =
remember(member.pubkey, onLongPressParticipant) {
onLongPressParticipant?.let { cb -> { hex: String -> cb(hex) } }
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Box(contentAlignment = Alignment.Center) {
ClickableUserPicture(
baseUserHex = member.pubkey,
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
onLongClick = onLongClick,
)
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(avatarSize - 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
if (member.handRaised) {
HandRaiseBadge(
avatarSize = avatarSize,
modifier = Modifier.align(Alignment.TopEnd),
)
}
if (showMicBadge && member.publishing) {
MicStateBadge(
isSpeaking = isSpeaking,
isMuted = member.muted == true,
avatarSize = avatarSize,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
UsernameDisplay(
baseUser = user,
weight = Modifier.fillMaxWidth().padding(top = 4.dp),
accountViewModel = accountViewModel,
)
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
+5
View File
@@ -547,6 +547,11 @@
<item quantity="one">%1$d listener</item>
<item quantity="other">%1$d listeners</item>
</plurals>
<string name="nest_live_chip">LIVE</string>
<string name="nest_tab_chat">Chat</string>
<string name="nest_tab_audience">Audience</string>
<string name="nest_tab_hands">Hands</string>
<string name="nest_audience_empty">No one in the audience yet.</string>
<string name="nest_chat_send">Send</string>
<string name="nest_chat_placeholder">Say something…</string>
<string name="nest_chat_empty">No messages yet. Be the first to chat.</string>