From 9381a7731defabb449d53c6a3c919a80eabf05c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 00:37:32 +0000 Subject: [PATCH] feat(nests): redesign room layout with stage grid, tabs, sticky action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single scrolling Column (title → speakers row → audience row → host queue → connection chip → talk row → buttons → chat) with a Scaffold-bounded layout that scales to large rooms: - Header strip with LIVE chip + listener count (uses the existing nest_listener_count plural for the first time) and a 1-line ellipsised summary. Topbar stays slim. - StageGrid: vertical adaptive LazyVerticalGrid capped at 220dp, so a 30-speaker room scrolls inside the strip without pushing the chat below the fold. - Tab bar (Chat / Audience · N / Hands · N) with badge counts. Hands tab is host-only and only shown while the queue is non-empty. - AudienceGrid: vertical adaptive LazyVerticalGrid that fills the selected tab — handles 1,000+ listeners without performance issues (the old single-row LazyHorizontalGrid only let you see ~5 at a time). - NestActionBar: sticky bottom bar consolidating the old ConnectionRow, TalkRow, and hand/react/leave row. Layout adapts to connection / broadcast / on-stage state; failure messages get a thin status strip above the bar instead of stealing button slots. - Hand-raise control hides when the user is on stage (the action it represents — "I want to speak" — no longer applies). The PIP screen and chat panel internals are untouched. --- .../loggedIn/nests/room/NestActionBar.kt | 407 ++++++++++ .../loggedIn/nests/room/NestFullScreen.kt | 711 ++++++++---------- .../loggedIn/nests/room/ParticipantsGrid.kt | 331 ++++---- amethyst/src/main/res/values/strings.xml | 5 + 4 files changed, 888 insertions(+), 566 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestActionBar.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestActionBar.kt new file mode 100644 index 000000000..d5ae44d70 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestActionBar.kt @@ -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)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestFullScreen.kt index c5356a2c7..c7c85ee41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestFullScreen.kt @@ -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.RoomTheme @@ -83,10 +77,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`. @@ -103,21 +108,66 @@ internal fun NestFullScreen( onHandRaisedChange: (Boolean) -> Unit, onLeave: () -> Unit, ) { - val roomTheme = androidx.compose.runtime.remember(event) { RoomTheme.from(event) } + val roomTheme = remember(event) { RoomTheme.from(event) } NestThemedScope(theme = roomTheme, accountViewModel = accountViewModel) { 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(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, @@ -138,185 +188,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(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, @@ -327,6 +345,113 @@ 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, + 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 @@ -388,228 +513,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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/ParticipantsGrid.kt index e789d798c..1cc597a41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/ParticipantsGrid.kt @@ -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, speakingNow: ImmutableSet, accountViewModel: AccountViewModel, - onStageLabel: String, - audienceLabel: String, modifier: Modifier = Modifier, reactionsByPubkey: Map> = emptyMap(), - connectingSpeakers: ImmutableSet = kotlinx.collections.immutable.persistentSetOf(), + connectingSpeakers: ImmutableSet = 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, + 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, +private fun MemberCell( + member: RoomMember, avatarSize: Dp, - speakingNow: ImmutableSet, - connectingSpeakers: ImmutableSet, + isSpeaking: Boolean, + isConnecting: Boolean, showMicBadge: Boolean, + reactions: List, accountViewModel: AccountViewModel, - reactionsByPubkey: Map>, 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), + ) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1807156d0..6e657b75d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -547,6 +547,11 @@ %1$d listener %1$d listeners + LIVE + Chat + Audience + Hands + No one in the audience yet. Send Say something… No messages yet. Be the first to chat.