From 9541c9975873e5a5cd6879f90345d62acf47d8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 23:45:41 +0000 Subject: [PATCH] feat(audio-rooms): LazyHorizontalGrid participant renderer (T2 #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two LazyRow stage/audience sections with a single ParticipantsGrid composable backed by buildParticipantGrid in commons. The grid: * derives on-stage from kind-30312 role + kind-10312 onstage flag * renders absent members (promoted but never emitted presence) at 50 % alpha — matches nostrnests' grey-out for "promoted but never joined" * uses LazyHorizontalGrid with a 1-row Fixed cell so adding a second row (e.g. names below avatars) is a one-line change Audience derivation moves from AudioRoomActivityContent into the projection function — the activity-side filter for "neither host nor speaker" was only used by the now-removed StagePeopleRow call, so deleting it shrinks the activity composable and keeps both audience and absence logic in commons. --- .../room/AudioRoomActivityContent.kt | 12 +- .../audiorooms/room/AudioRoomFullScreen.kt | 44 +++-- .../audiorooms/room/ParticipantsGrid.kt | 162 ++++++++++++++++++ 3 files changed, 186 insertions(+), 32 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantsGrid.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt index b77d55ae0..487f20d8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt @@ -117,13 +117,10 @@ private fun AudioRoomActivityBody( val participants = remember(event) { event.participants() } val hosts = remember(participants) { participants.filter { it.role.equals(ROLE.HOST.code, true) } } val speakers = remember(participants) { participants.filter { it.role.equals(ROLE.SPEAKER.code, true) } } - val audience = - remember(participants) { - participants.filter { - !it.role.equals(ROLE.HOST.code, true) && - !it.role.equals(ROLE.SPEAKER.code, true) - } - } + // The grid renderer derives audience from `event.participants()` + + // the kind-10312 presence aggregator (see ParticipantsGrid / + // buildParticipantGrid). This composable only needs the on-stage + // subset for the talk-row gate + PIP renderer. val onStage = remember(hosts, speakers) { hosts + speakers } val onStageKeys = remember(onStage) { onStage.map { it.pubKey }.toSet() } @@ -350,7 +347,6 @@ private fun AudioRoomActivityBody( event = event, roomNote = roomNote, onStage = onStage, - audience = audience, viewModel = viewModel, ui = ui, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt index 1c7071307..7aa52082b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt @@ -61,8 +61,6 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme 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.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip19Bech32.toNAddr import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -82,7 +80,6 @@ internal fun AudioRoomFullScreen( event: MeetingSpaceEvent, roomNote: com.vitorpamplona.amethyst.model.AddressableNote, onStage: List, - audience: List, viewModel: AudioRoomViewModel, ui: AudioRoomUiState, accountViewModel: AccountViewModel, @@ -191,27 +188,26 @@ internal fun AudioRoomFullScreen( val onLongPressParticipant: ((String) -> Unit) = { target -> if (target != accountViewModel.account.signer.pubKey) hostMenuTarget = target } - if (onStage.isNotEmpty()) { - StagePeopleRow( - label = stringRes(R.string.audio_room_stage), - people = onStage, - avatarSize = Size40dp, - speakingNow = ui.speakingNow, - accountViewModel = accountViewModel, - reactionsByPubkey = reactionsByPubkey, - onLongPressParticipant = onLongPressParticipant, - ) - } - if (audience.isNotEmpty()) { - StagePeopleRow( - label = stringRes(R.string.audio_room_audience), - people = audience, - avatarSize = Size35dp, - speakingNow = kotlinx.collections.immutable.persistentSetOf(), - accountViewModel = accountViewModel, - onLongPressParticipant = onLongPressParticipant, - ) - } + // Tier-2 #1: replace the two LazyRow sections with a single + // pure-projection ParticipantGrid. The `absent` flag (member + // promoted in the kind-30312 but never emitted a kind-10312) + // greys out at 50 % alpha, matching nostrnests' web client. + val participantGrid = + androidx.compose.runtime.remember(event, presences) { + com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid( + participants = event.participants(), + presences = presences, + ) + } + ParticipantsGrid( + grid = participantGrid, + speakingNow = ui.speakingNow, + accountViewModel = accountViewModel, + onStageLabel = stringRes(R.string.audio_room_stage), + audienceLabel = stringRes(R.string.audio_room_audience), + reactionsByPubkey = reactionsByPubkey, + onLongPressParticipant = onLongPressParticipant, + ) hostMenuTarget?.let { target -> ParticipantHostActionsSheet( target = target, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantsGrid.kt new file mode 100644 index 000000000..88bb1da3c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantsGrid.kt @@ -0,0 +1,162 @@ +/* + * 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.audiorooms.room + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import kotlinx.collections.immutable.ImmutableSet + +/** + * 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. + * + * 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". + */ +@Composable +internal fun ParticipantsGrid( + grid: ParticipantGrid, + speakingNow: ImmutableSet, + accountViewModel: AccountViewModel, + onStageLabel: String, + audienceLabel: String, + modifier: Modifier = Modifier, + reactionsByPubkey: Map> = emptyMap(), + onLongPressParticipant: ((String) -> Unit)? = null, +) { + Column(modifier = modifier.fillMaxWidth()) { + if (grid.onStage.isNotEmpty()) { + ParticipantsSection( + title = onStageLabel, + members = grid.onStage, + avatarSize = Size40dp, + speakingNow = speakingNow, + accountViewModel = accountViewModel, + reactionsByPubkey = reactionsByPubkey, + onLongPressParticipant = onLongPressParticipant, + ) + } + 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 — only + // members with a live broadcast track do. Pass an + // empty set rather than thread a per-section bool. + speakingNow = kotlinx.collections.immutable.persistentSetOf(), + accountViewModel = accountViewModel, + reactionsByPubkey = emptyMap(), + onLongPressParticipant = onLongPressParticipant, + ) + } + } +} + +@Composable +private fun ParticipantsSection( + title: String, + members: List, + avatarSize: androidx.compose.ui.unit.Dp, + speakingNow: ImmutableSet, + accountViewModel: AccountViewModel, + reactionsByPubkey: Map>, + onLongPressParticipant: ((String) -> Unit)?, +) { + val ringColor = MaterialTheme.colorScheme.primary + Column(modifier = Modifier.padding(top = 8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Single-row horizontal grid — visually identical to the + // existing LazyRow layout but uses LazyHorizontalGrid so + // adding a second row (e.g. names below avatars) is a + // one-line change later. Cell size matches avatarSize + + // padding for the reaction overlay below. + LazyHorizontalGrid( + rows = GridCells.Fixed(1), + modifier = + Modifier + .fillMaxWidth() + .height(avatarSize + 32.dp) + .padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(items = members, key = { it.pubkey }) { member -> + val isSpeaking = member.pubkey in speakingNow + val avatarModifier = + Modifier + .let { if (isSpeaking) it.border(2.dp, ringColor, CircleShape) else it } + .let { if (member.absent) it.alpha(0.5f) else it } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ClickableUserPicture( + baseUserHex = member.pubkey, + size = avatarSize, + accountViewModel = accountViewModel, + modifier = avatarModifier, + onLongClick = onLongPressParticipant?.let { cb -> { hex -> cb(hex) } }, + ) + val reactions = reactionsByPubkey[member.pubkey].orEmpty() + if (reactions.isNotEmpty()) { + SpeakerReactionOverlay( + reactions = reactions, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + } +}