feat(nests): self highlight, floating reactions, kick drops p-tag, tap-self mute

#1 Local user's cell tints the username in colorScheme.primary so you
   can find yourself in a busy stage. Threads myPubkey through StageGrid
   and AudienceGrid; cells receive isSelf and pass MaterialTheme primary
   to UsernameDisplay's textColor.

#2 SpeakerReactionOverlay moves from below the username (which reflowed
   the column when reactions arrived and shoved neighbors down) to
   inside the avatar Box, anchored bottom-end with a small outward
   offset so it overlaps the corner without colliding with the
   bottom-center mic badge. Cell height stays constant whether the
   speaker has 0 or 5 active reactions.

#3 Kick now mirrors nostrnests' two-step path: the existing ephemeral
   kind-4312 ['action','kick'] kicks the target off the audio plane,
   followed by a re-published kind-30312 with their p-tag dropped so
   the participant grid stops rendering them. New
   RoomParticipantActions.removeParticipant builds the second event;
   refuses to drop the host (same protection as setRole demoting host).

#7 Tap-to-mute on your own avatar. Self-cell receives onTapSelf which,
   when broadcasting, toggles BroadcastUiState.Broadcasting.isMuted via
   the existing setMicMuted path. Falls back to no-op when not
   broadcasting so a non-broadcasting tap doesn't surface a useless
   action — the avatar simply isn't tappable.
This commit is contained in:
Claude
2026-04-28 13:21:21 +00:00
parent 138ee12a6a
commit ae2bae13ac
4 changed files with 75 additions and 6 deletions
@@ -302,7 +302,14 @@ internal fun ParticipantHostActionsSheet(
text = stringRes(R.string.nest_kick_action), text = stringRes(R.string.nest_kick_action),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) { ) {
// Two-step kick mirroring nostrnests' ProfileCard.tsx:
// 1. ephemeral kind-4312 ["action","kick"] kicks the
// user off the audio plane
// 2. re-published kind-30312 with the target's p-tag
// dropped removes them from the participant grid
// so future presence events don't re-render them
broadcast(AdminCommandEvent.kick(roomATag, target)) broadcast(AdminCommandEvent.kick(roomATag, target))
broadcast(RoomParticipantActions.removeParticipant(event, target))
onDismiss() onDismiss()
} }
} }
@@ -90,6 +90,28 @@ internal object RoomParticipantActions {
targetPubkey: String, targetPubkey: String,
): EventTemplate<MeetingSpaceEvent>? = setRole(original, targetPubkey, ROLE.PARTICIPANT) ): EventTemplate<MeetingSpaceEvent>? = setRole(original, targetPubkey, ROLE.PARTICIPANT)
/**
* Drop [targetPubkey]'s p-tag from the room event entirely.
* Mirrors nostrnests' kick follow-up (`updateRoomParticipant(_,
* null)` after `kickUser` in `ProfileCard.tsx`): the kind-4312
* boots them off the audio plane, this re-published kind-30312
* keeps them out of the participant grid.
*
* Refuses to drop the host — same reasoning as [setRole]. Returns
* null if the target isn't currently in the participant list (a
* pure-audience kick has no list-mutation to do).
*/
fun removeParticipant(
original: MeetingSpaceEvent,
targetPubkey: String,
): EventTemplate<MeetingSpaceEvent>? {
val all = original.participants()
val target = all.firstOrNull { it.pubKey == targetPubkey } ?: return null
if (target.role.equals(ROLE.HOST.code, ignoreCase = true)) return null
val remaining = all.filterNot { it.pubKey == targetPubkey }
return rebuild(original, remaining, original.status() ?: StatusTag.STATUS.OPEN)
}
private fun rebuild( private fun rebuild(
original: MeetingSpaceEvent, original: MeetingSpaceEvent,
participants: List<ParticipantTag>, participants: List<ParticipantTag>,
@@ -223,6 +223,14 @@ internal fun NestFullScreen(
summary = event.summary(), summary = event.summary(),
listenerCount = presences.size, listenerCount = presences.size,
) )
// Self-cell tap toggles mic-mute when broadcasting; null
// when not broadcasting so the avatar falls back to the
// default no-op tap (rather than offering a button that
// does nothing).
val onTapSelf: (() -> Unit)? =
(ui.broadcast as? com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState.Broadcasting)?.let { state ->
{ viewModel.setMicMuted(!state.isMuted) }
}
StageGrid( StageGrid(
members = participantGrid.onStage, members = participantGrid.onStage,
speakingNow = ui.speakingNow, speakingNow = ui.speakingNow,
@@ -231,6 +239,8 @@ internal fun NestFullScreen(
reactionsByPubkey = reactionsByPubkey, reactionsByPubkey = reactionsByPubkey,
connectingSpeakers = ui.connectingSpeakers, connectingSpeakers = ui.connectingSpeakers,
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
myPubkey = myPubkey,
onTapSelf = onTapSelf,
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
NestTabRow( NestTabRow(
@@ -258,6 +268,7 @@ internal fun NestFullScreen(
members = participantGrid.audience, members = participantGrid.audience,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
myPubkey = myPubkey,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
@@ -119,6 +119,8 @@ internal fun StageGrid(
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(), reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
connectingSpeakers: ImmutableSet<String> = persistentSetOf(), connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
onLongPressParticipant: ((String) -> Unit)? = null, onLongPressParticipant: ((String) -> Unit)? = null,
myPubkey: String? = null,
onTapSelf: (() -> Unit)? = null,
) { ) {
// Float currently-speaking members to the top so the listener can // Float currently-speaking members to the top so the listener can
// see who they're hearing without scrolling. sortedBy is stable in // see who they're hearing without scrolling. sortedBy is stable in
@@ -160,6 +162,7 @@ internal fun StageGrid(
verticalArrangement = Arrangement.spacedBy(GRID_SPACING), verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) { ) {
items(items = sortedMembers, key = { it.pubkey }) { member -> items(items = sortedMembers, key = { it.pubkey }) { member ->
val isSelf = myPubkey != null && member.pubkey == myPubkey
MemberCell( MemberCell(
member = member, member = member,
avatarSize = STAGE_AVATAR, avatarSize = STAGE_AVATAR,
@@ -170,6 +173,8 @@ internal fun StageGrid(
reactions = reactionsByPubkey[member.pubkey].orEmpty(), reactions = reactionsByPubkey[member.pubkey].orEmpty(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
isSelf = isSelf,
onTapSelf = if (isSelf) onTapSelf else null,
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
) )
} }
@@ -194,6 +199,7 @@ internal fun AudienceGrid(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLongPressParticipant: ((String) -> Unit)? = null, onLongPressParticipant: ((String) -> Unit)? = null,
myPubkey: String? = null,
) { ) {
if (members.isEmpty()) { if (members.isEmpty()) {
Box( Box(
@@ -235,6 +241,7 @@ internal fun AudienceGrid(
reactions = emptyList(), reactions = emptyList(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
isSelf = myPubkey != null && member.pubkey == myPubkey,
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
) )
} }
@@ -252,6 +259,8 @@ private fun MemberCell(
reactions: List<RoomReaction>, reactions: List<RoomReaction>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onLongPressParticipant: ((String) -> Unit)?, onLongPressParticipant: ((String) -> Unit)?,
isSelf: Boolean = false,
onTapSelf: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val mutedRingColor = MaterialTheme.colorScheme.error val mutedRingColor = MaterialTheme.colorScheme.error
@@ -292,6 +301,17 @@ private fun MemberCell(
remember(member.pubkey, onLongPressParticipant) { remember(member.pubkey, onLongPressParticipant) {
onLongPressParticipant?.let { cb -> { hex: String -> cb(hex) } } onLongPressParticipant?.let { cb -> { hex: String -> cb(hex) } }
} }
// Self-cell tap shortcut: tap your own avatar to toggle mic-mute
// when broadcasting. For other cells we leave tap unhandled so
// the existing nav-to-profile path stays consistent (and there's
// nothing visually different from a non-clickable avatar).
val onClick =
if (isSelf && onTapSelf != null) {
remember(onTapSelf) { { _: String -> onTapSelf() } }
} else {
null
}
val selfTint = if (isSelf) MaterialTheme.colorScheme.primary else Color.Unspecified
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
@@ -302,6 +322,7 @@ private fun MemberCell(
size = avatarSize, size = avatarSize,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = avatarModifier, modifier = avatarModifier,
onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
) )
if (isConnecting) { if (isConnecting) {
@@ -330,19 +351,27 @@ private fun MemberCell(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
) )
} }
// Reactions float over the avatar's bottom-right corner so
// a 👏 burst no longer pushes the username down and reflows
// neighbouring cells. The mic badge sits at BottomCenter,
// so BottomEnd + a small outward offset keeps them clear.
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions,
modifier =
Modifier
.align(Alignment.BottomEnd)
.offset(x = 6.dp, y = 6.dp),
)
}
} }
UsernameDisplay( UsernameDisplay(
baseUser = user, baseUser = user,
weight = Modifier.fillMaxWidth().padding(top = 4.dp), weight = Modifier.fillMaxWidth().padding(top = 4.dp),
textColor = selfTint,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions,
modifier = Modifier.padding(top = 2.dp),
)
}
} }
} }