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:
+407
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+308
-406
@@ -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,
|
||||
|
||||
+169
-162
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user