feat(nests): bigger avatars + clearer mute/speaking state in participant grid

100dp avatars across stage and audience tabs, names centered under each
cell, and the avatar border now distinguishes actively speaking (green)
from publishing-but-muted (red) at a glance — the existing mic-pill
keeps its semantics underneath.

UsernameDisplay gains an optional textAlign so the grid can opt in to
centered labels without disturbing other call sites.
This commit is contained in:
Claude
2026-04-28 02:44:48 +00:00
parent 2027f6ad37
commit 666a93f549
2 changed files with 33 additions and 17 deletions
@@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -100,6 +101,7 @@ fun UsernameDisplay(
weight: Modifier = Modifier,
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified,
textAlign: TextAlign? = null,
accountViewModel: AccountViewModel,
) {
val userMetadata by observeUserInfo(baseUser, accountViewModel)
@@ -107,9 +109,9 @@ fun UsernameDisplay(
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) {
val name = it?.info?.bestName()
if (name != null) {
UserDisplay(name, it.tags, weight, fontWeight, textColor)
UserDisplay(name, it.tags, weight, fontWeight, textColor, textAlign)
} else {
NPubDisplay(baseUser, weight, fontWeight, textColor)
NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign)
}
}
}
@@ -120,6 +122,7 @@ private fun NPubDisplay(
modifier: Modifier,
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified,
textAlign: TextAlign? = null,
) {
Text(
text = remember { user.pubkeyDisplayHex() },
@@ -128,6 +131,7 @@ private fun NPubDisplay(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor,
textAlign = textAlign,
)
}
@@ -138,11 +142,13 @@ private fun UserDisplay(
modifier: Modifier,
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified,
textAlign: TextAlign? = null,
) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = fontWeight,
textAlign = textAlign,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
@@ -51,6 +51,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -62,21 +63,22 @@ 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.Size40dp
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 STAGE_CELL_MIN = 112.dp
private val STAGE_AVATAR = 100.dp
private val AUDIENCE_CELL_MIN = 112.dp
private val AUDIENCE_AVATAR = 100.dp
private val GRID_SPACING = 6.dp
private val AVATAR_RING_WIDTH = 3.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
// reaction headroom; with a 100dp avatar one row lifts to ~140dp, so
// two rows + label/padding lands near 320dp — tall enough to show a
// handful of speakers without scrolling but small enough to leave the
// chat / audience tab the majority of the viewport.
private val STAGE_MAX_HEIGHT = 320.dp
/**
* Vertical adaptive grid for the on-stage section. Used as the
@@ -194,14 +196,21 @@ private fun MemberCell(
accountViewModel: AccountViewModel,
onLongPressParticipant: ((String) -> Unit)?,
) {
val ringColor = MaterialTheme.colorScheme.primary
val avatarModifier =
val mutedRingColor = MaterialTheme.colorScheme.error
// Tri-state ring around the avatar so a glance distinguishes:
// - green ring: actively speaking right now
// - red ring: on stage and broadcasting but mic flagged muted
// - no ring: idle, audience, or unknown mute state
val ringColor =
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
isSpeaking -> NEST_SPEAKING_COLOR
showMicBadge && member.publishing && member.muted == true -> mutedRingColor
else -> null
}
val avatarModifier =
Modifier
.let { if (ringColor != null) it.border(AVATAR_RING_WIDTH, ringColor, CircleShape) else it }
.let { if (member.absent) it.alpha(0.5f) else it }
val user =
remember(member.pubkey) {
com.vitorpamplona.amethyst.model.LocalCache
@@ -251,6 +260,7 @@ private fun MemberCell(
UsernameDisplay(
baseUser = user,
weight = Modifier.fillMaxWidth().padding(top = 4.dp),
textAlign = TextAlign.Center,
accountViewModel = accountViewModel,
)
if (reactions.isNotEmpty()) {