feat(audio-rooms): LazyHorizontalGrid participant renderer (T2 #1)
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.
This commit is contained in:
+4
-8
@@ -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,
|
||||
|
||||
+20
-24
@@ -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<ParticipantTag>,
|
||||
audience: List<ParticipantTag>,
|
||||
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,
|
||||
|
||||
+162
@@ -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<String>,
|
||||
accountViewModel: AccountViewModel,
|
||||
onStageLabel: String,
|
||||
audienceLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
reactionsByPubkey: Map<String, List<RoomReaction>> = 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<RoomMember>,
|
||||
avatarSize: androidx.compose.ui.unit.Dp,
|
||||
speakingNow: ImmutableSet<String>,
|
||||
accountViewModel: AccountViewModel,
|
||||
reactionsByPubkey: Map<String, List<RoomReaction>>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user