diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index 2915b686c7..8c76c30b40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -302,7 +302,14 @@ internal fun ParticipantHostActionsSheet( text = stringRes(R.string.nest_kick_action), 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(RoomParticipantActions.removeParticipant(event, target)) onDismiss() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt index 99d152b53e..964a0f5c89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt @@ -90,6 +90,28 @@ internal object RoomParticipantActions { targetPubkey: String, ): EventTemplate? = 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? { + 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( original: MeetingSpaceEvent, participants: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 4a7b15739b..49e11db44e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -223,6 +223,14 @@ internal fun NestFullScreen( summary = event.summary(), 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( members = participantGrid.onStage, speakingNow = ui.speakingNow, @@ -231,6 +239,8 @@ internal fun NestFullScreen( reactionsByPubkey = reactionsByPubkey, connectingSpeakers = ui.connectingSpeakers, onLongPressParticipant = onLongPressParticipant, + myPubkey = myPubkey, + onTapSelf = onTapSelf, modifier = Modifier.padding(horizontal = 16.dp), ) NestTabRow( @@ -258,6 +268,7 @@ internal fun NestFullScreen( members = participantGrid.audience, accountViewModel = accountViewModel, onLongPressParticipant = onLongPressParticipant, + myPubkey = myPubkey, modifier = Modifier .weight(1f) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index 11cdb7c7b8..4d49083b71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -119,6 +119,8 @@ internal fun StageGrid( reactionsByPubkey: Map> = emptyMap(), connectingSpeakers: ImmutableSet = persistentSetOf(), onLongPressParticipant: ((String) -> Unit)? = null, + myPubkey: String? = null, + onTapSelf: (() -> Unit)? = null, ) { // Float currently-speaking members to the top so the listener can // see who they're hearing without scrolling. sortedBy is stable in @@ -160,6 +162,7 @@ internal fun StageGrid( verticalArrangement = Arrangement.spacedBy(GRID_SPACING), ) { items(items = sortedMembers, key = { it.pubkey }) { member -> + val isSelf = myPubkey != null && member.pubkey == myPubkey MemberCell( member = member, avatarSize = STAGE_AVATAR, @@ -170,6 +173,8 @@ internal fun StageGrid( reactions = reactionsByPubkey[member.pubkey].orEmpty(), accountViewModel = accountViewModel, onLongPressParticipant = onLongPressParticipant, + isSelf = isSelf, + onTapSelf = if (isSelf) onTapSelf else null, modifier = Modifier.animateItem(), ) } @@ -194,6 +199,7 @@ internal fun AudienceGrid( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, onLongPressParticipant: ((String) -> Unit)? = null, + myPubkey: String? = null, ) { if (members.isEmpty()) { Box( @@ -235,6 +241,7 @@ internal fun AudienceGrid( reactions = emptyList(), accountViewModel = accountViewModel, onLongPressParticipant = onLongPressParticipant, + isSelf = myPubkey != null && member.pubkey == myPubkey, modifier = Modifier.animateItem(), ) } @@ -252,6 +259,8 @@ private fun MemberCell( reactions: List, accountViewModel: AccountViewModel, onLongPressParticipant: ((String) -> Unit)?, + isSelf: Boolean = false, + onTapSelf: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val mutedRingColor = MaterialTheme.colorScheme.error @@ -292,6 +301,17 @@ private fun MemberCell( remember(member.pubkey, onLongPressParticipant) { 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( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), @@ -302,6 +322,7 @@ private fun MemberCell( size = avatarSize, accountViewModel = accountViewModel, modifier = avatarModifier, + onClick = onClick, onLongClick = onLongClick, ) if (isConnecting) { @@ -330,19 +351,27 @@ private fun MemberCell( 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( baseUser = user, weight = Modifier.fillMaxWidth().padding(top = 4.dp), + textColor = selfTint, textAlign = TextAlign.Center, accountViewModel = accountViewModel, ) - if (reactions.isNotEmpty()) { - SpeakerReactionOverlay( - reactions = reactions, - modifier = Modifier.padding(top = 2.dp), - ) - } } }