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:
+7
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -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>,
|
||||||
|
|||||||
+11
@@ -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)
|
||||||
|
|||||||
+35
-6
@@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user