nests room: stable reactions overlay + repeatable kind-7 + speaker controls

Reaction-side rework after extended testing on 2026-05-13:

* Promoted users weren't seeing speaker controls in their own
  Amethyst — `StageControlsBar` gates on `isOnStage && ui.onStageNow`
  and `ui.onStageNow` was stuck at false (never reset back to true
  after some earlier path flipped it). Added a LaunchedEffect in
  NestActivityContent that mirrors `isOnStageMe → ui.onStageNow`
  whenever it becomes true, symmetric to the auto-stop effect.

* Reactions overlay sat inside AvatarAndBadges' wrap-content Box,
  so adding/removing the chip shifted the inner Box's centre and
  the role badges drifted. Lifted the overlay out to be a sibling
  of AvatarAndBadges inside the outer fixed-size Box; badges now
  stay anchored regardless of chip presence or animation state.

* Reaction grouping was by `targetPubkey` (NIP-25 p-tag) — emojis
  showed on the speaker being reacted to, not the reactor. Switched
  RoomReactionsAggregator to group by `sourcePubkey`. AudienceGrid
  threads `reactionsByPubkey` so an audience reactor sees their own
  emoji float from their audience-tab avatar too.

* Reaction chip animation: progress was an `Animatable.value` that
  Compose wasn't refreshing in the layout-consuming layer (visible
  bug: chip "blinked" with no movement). Replaced with a manual
  `withFrameNanos` loop writing to a `MutableFloatState` read inside
  a `graphicsLayer { … }` lambda — frame-clock animation that works.
  Each kind-7 is its own chip keyed by event-id (no more
  groupBy-content collapsing same-emoji bursts into one shared chip
  that restarts on every arrival). Chips stack at a fixed-size 30 dp
  Box with the emoji centred, so the X position is invariant under
  glyph width. Multiple concurrent chips overlap at the right-bottom
  corner in a `Box(BottomEnd)` (newest on top) rather than sliding
  leftward in a `Row`.

* Bottom-drawer `RoomReactionPickerSheet` (hard-coded 6 emojis, no
  NIP-30) replaced by a forked `RoomReactionPopup` that reuses
  `ReactionChoicePopupContent` (same NIP-30 custom-emoji support,
  same user-configured reaction set) but with two semantic deviations
  for live audio rooms: empty `toRemove` so all buttons stay
  "fresh", and the click handler signs+broadcasts a fresh kind-7
  template directly instead of going through `Account.reactTo` —
  which delegates to `ReactionAction.reactTo(note, …)` and
  short-circuits on `note.hasReacted(by, reaction)`. The bypass
  lets the user fire the same emoji repeatedly during a moment.

* Chip rendering matches NoteCompose: `RenderReactionContent`
  handles `:shortcode:url` via `InLineIconRenderer` + `CustomEmoji`,
  `"+"`→❤️, `"-"`→👎, anything else as Text.

Tests updated for sender-grouped aggregator semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-13 21:06:27 -04:00
parent d21eb0e226
commit 78be534df5
10 changed files with 445 additions and 240 deletions
@@ -290,6 +290,19 @@ private fun NestActivityBody(
} }
} }
// Re-sync the optimistic local intent flag with the role-derived
// on-stage state whenever the user becomes on stage. Symmetric to
// the auto-stop above: when `isOnStageMe` flips to true (initial
// promotion or a re-promotion after a previous voluntary leave),
// also flip `ui.onStageNow` back to true so [StageControlsBar]'s
// `!ui.onStageNow` gate doesn't hide the talk button when their
// role says they CAN talk. Without this, a promoted speaker
// appeared on the stage grid but saw no speaker controls
// — confirmed during testing on 2026-05-13.
LaunchedEffect(isOnStageMe) {
if (isOnStageMe) viewModel.setOnStage(true)
}
// Single REQ per relay covering chat, presence, reactions, admin // Single REQ per relay covering chat, presence, reactions, admin
// commands. See NestRoomFilterAssembler for the filter shape. // commands. See NestRoomFilterAssembler for the filter shape.
NestRoomFilterAssemblerSubscription(roomNote, accountViewModel) NestRoomFilterAssemblerSubscription(roomNote, accountViewModel)
@@ -1,104 +0,0 @@
/*
* 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.nests.room.reactions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Quick-pick emoji sheet for the room. Six default reactions cover
* the common audio-room interactions (clap, fire, laugh, eyes,
* heart, lightning); the parent invokes [onPick] with the chosen
* emoji which is then sent as a kind-7 reaction tagged for the
* current room (and optional speaker target).
*
* Custom-emoji-pack favourites (NIP-30) are intentionally deferred —
* the v1 sheet is a plain hard-coded set so the dependency surface
* stays tight; later passes can read the user's
* `EmojiPackEvent`s and merge them in.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun RoomReactionPickerSheet(
onPick: (String) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
text = stringRes(R.string.nest_reactions_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
DEFAULT_REACTIONS.forEach { emoji ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
Modifier
.size(48.dp)
.clickable {
onPick(emoji)
onDismiss()
},
) {
Text(
text = emoji,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
}
}
}
}
}
private val DEFAULT_REACTIONS = listOf("👏", "🔥", "😂", "👀", "❤️", "")
@@ -0,0 +1,131 @@
/*
* 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.nests.room.reactions
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.note.ReactionChoicePopupContent
import com.vitorpamplona.amethyst.ui.note.popupAnimationEnter
import com.vitorpamplona.amethyst.ui.note.popupAnimationExit
import com.vitorpamplona.amethyst.ui.note.rememberVisibilityState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import kotlinx.collections.immutable.persistentSetOf
/**
* Room-scoped reaction picker. Visually identical to
* [com.vitorpamplona.amethyst.ui.note.ReactionChoicePopup] — same
* pop-up animation, same user-configured reaction set (including
* NIP-30 custom emojis), same "change reactions" affordance — but
* with two semantic deviations specific to live audio rooms:
*
* 1. **Repeatable.** A note's heart `ReactionChoicePopup` calls
* `reactToOrDelete`, which toggles a `(note, emoji)` pair: a
* second tap of the same emoji DELETES the first. For an audio
* room every tap is an ephemeral cheer — the user should be
* able to fire `🔥` ten times during a great moment without the
* second tap nuking the first. We bypass `reactToOrDelete` and
* call `account.reactTo` directly, which always emits a fresh
* kind-7.
*
* 2. **No "already reacted" gate.** The standard popup passes a
* `toRemove` set so already-reacted buttons render with a
* destructive hint (next tap = delete). For us every button is
* always "fresh", so `toRemove` is empty.
*
* Anchoring + animation mirror the original popup so the affordance
* feels the same when the user enters/exits the room.
*/
@Composable
fun RoomReactionPopup(
roomNote: AddressableNote,
iconSize: Dp,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
) {
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
val visibilityState = rememberVisibilityState(onDismiss)
val reactions by accountViewModel.reactionChoicesFlow().collectAsState()
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, iconSizePx),
onDismissRequest = { visibilityState.targetState = false },
properties = PopupProperties(focusable = true),
) {
AnimatedVisibility(
visibleState = visibilityState,
enter = popupAnimationEnter,
exit = popupAnimationExit,
) {
ReactionChoicePopupContent(
listOfReactions = reactions,
// Empty set — never mark a reaction as "already
// emitted" so the user can fire the same emoji
// repeatedly throughout the room.
toRemove = persistentSetOf(),
onClick = { reactionType ->
accountViewModel.launchSigner {
// Bypass `Account.reactTo` (which delegates to
// `ReactionAction.reactTo(note, …)` and
// short-circuits on `note.hasReacted(by, reaction)`
// — line 181 in ReactionAction.kt). For a live
// audio room we WANT the second tap of the same
// emoji to fire another kind-7. Build the
// template directly and hand it to
// `signAndComputeBroadcast`, which signs +
// publishes + writes to LocalCache without the
// duplicate gate.
val eventHint = roomNote.toEventHint<Event>()
if (eventHint != null) {
val template =
if (reactionType.startsWith(":")) {
val emojiUrl = EmojiUrlTag.decode(reactionType)
if (emojiUrl != null) {
ReactionEvent.build(emojiUrl, eventHint)
} else {
ReactionEvent.build(reactionType, eventHint)
}
} else {
ReactionEvent.build(reactionType, eventHint)
}
accountViewModel.account.signAndComputeBroadcast(template)
}
}
visibilityState.targetState = false
},
onChangeAmount = onChangeAmount,
)
}
}
}
@@ -67,6 +67,9 @@ import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPopup
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
/** /**
@@ -96,7 +99,8 @@ internal fun NestActionBar(
isOnStage: Boolean, isOnStage: Boolean,
handRaised: Boolean, handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit, onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit, roomNote: AddressableNote,
accountViewModel: AccountViewModel,
onLeave: () -> Unit, onLeave: () -> Unit,
) { ) {
Surface( Surface(
@@ -121,7 +125,8 @@ internal fun NestActionBar(
isConnected = ui.connection is ConnectionUiState.Connected, isConnected = ui.connection is ConnectionUiState.Connected,
handRaised = handRaised, handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange, onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = onShowReactionPicker, roomNote = roomNote,
accountViewModel = accountViewModel,
onLeave = onLeave, onLeave = onLeave,
) )
} }
@@ -352,7 +357,8 @@ private fun EndCluster(
isConnected: Boolean, isConnected: Boolean,
handRaised: Boolean, handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit, onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit, roomNote: AddressableNote,
accountViewModel: AccountViewModel,
onLeave: () -> Unit, onLeave: () -> Unit,
) { ) {
Row( Row(
@@ -365,12 +371,31 @@ private fun EndCluster(
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange) HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
} }
// React works in any state — even disconnected users can react // React works in any state — even disconnected users can react
// via the room note. // via the room note. Reuses the same ReactionChoicePopup
FilledTonalIconButton(onClick = onShowReactionPicker) { // NoteCompose's heart button drives (NIP-30 custom-emoji
// support, user-configured reaction set). Earlier hand-rolled
// bottom-sheet picker is gone — it only emitted a fixed set
// of six unicode emojis with no path to custom packs.
var wantsToReact by rememberSaveable { mutableStateOf(false) }
FilledTonalIconButton(onClick = { wantsToReact = true }) {
Icon( Icon(
symbol = MaterialSymbols.EmojiEmotions, symbol = MaterialSymbols.EmojiEmotions,
contentDescription = stringRes(R.string.nest_reactions_button), contentDescription = stringRes(R.string.nest_reactions_button),
) )
if (wantsToReact) {
RoomReactionPopup(
roomNote = roomNote,
iconSize = 24.dp,
accountViewModel = accountViewModel,
onDismiss = { wantsToReact = false },
// No `Route.UpdateReactionType` from inside the
// standalone NestActivity (no NavController). The
// "change reactions" button still dismisses the
// popup; users edit their reaction set from the
// main app's settings.
onChangeAmount = { wantsToReact = false },
)
}
} }
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
LeaveRoomButton(onClick = onLeave) LeaveRoomButton(onClick = onLeave)
@@ -70,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestChatPan
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestSheet import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestSheet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.ParticipantHostActionsSheet import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.ParticipantHostActionsSheet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPickerSheet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.AudienceGrid import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.AudienceGrid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.HandRaiseQueueSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.HandRaiseQueueSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.StageGrid import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.StageGrid
@@ -120,7 +119,6 @@ internal fun NestFullScreen(
var showEditSheet by rememberSaveable { mutableStateOf(false) } var showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) } var showHostMenu by rememberSaveable { mutableStateOf(false) }
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) } var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) } var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
// Summary is collapsed by default; tapping the top-bar title // Summary is collapsed by default; tapping the top-bar title
// toggles it so the user can preview the room description without // toggles it so the user can preview the room description without
@@ -268,7 +266,8 @@ internal fun NestFullScreen(
isOnStage = isOnStageMe, isOnStage = isOnStageMe,
handRaised = handRaised, handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange, onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = { showReactionPicker = true }, roomNote = roomNote,
accountViewModel = accountViewModel,
onLeave = { onLeave = {
if (isHost) { if (isHost) {
showHostLeaveConfirm = true showHostLeaveConfirm = true
@@ -310,6 +309,7 @@ internal fun NestFullScreen(
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
onTapParticipant = onLongPressParticipant, onTapParticipant = onLongPressParticipant,
myPubkey = myPubkey, myPubkey = myPubkey,
reactionsByPubkey = reactionsByPubkey,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
@@ -349,13 +349,6 @@ internal fun NestFullScreen(
) )
} }
if (showReactionPicker) {
RoomReactionPickerSheet(
onPick = { emoji -> accountViewModel.reactToOrDelete(roomNote, emoji) },
onDismiss = { showReactionPicker = false },
)
}
if (showHostLeaveConfirm) { if (showHostLeaveConfirm) {
AlertDialog( AlertDialog(
onDismissRequest = { showHostLeaveConfirm = false }, onDismissRequest = { showHostLeaveConfirm = false },
@@ -303,6 +303,7 @@ internal fun AudienceGrid(
onLongPressParticipant: ((String) -> Unit)? = null, onLongPressParticipant: ((String) -> Unit)? = null,
onTapParticipant: ((String) -> Unit)? = null, onTapParticipant: ((String) -> Unit)? = null,
myPubkey: String? = null, myPubkey: String? = null,
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
) { ) {
if (members.isEmpty()) { if (members.isEmpty()) {
Box( Box(
@@ -341,7 +342,7 @@ internal fun AudienceGrid(
audioLevel = 0f, audioLevel = 0f,
isConnecting = false, isConnecting = false,
showMicBadge = false, showMicBadge = false,
reactions = emptyList(), reactions = reactionsByPubkey[member.pubkey].orEmpty(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
onTapParticipant = onTapParticipant, onTapParticipant = onTapParticipant,
@@ -524,8 +525,36 @@ private fun MemberCell(
isConnecting = isConnecting, isConnecting = isConnecting,
showMicBadge = showMicBadge, showMicBadge = showMicBadge,
isSpeaking = isSpeaking, isSpeaking = isSpeaking,
reactions = reactions,
) )
// Reactions overlay sits as a SIBLING of AvatarAndBadges
// inside this fixed-size outer Box, NOT inside
// AvatarAndBadges itself. AvatarAndBadges' inner Box
// wraps content, so any change to the overlay's measured
// size (chip appears / animates / disappears) would shift
// the inner Box's centre and the badges aligned to its
// corners would drift with it. The outer Box has an
// explicit `.size(outerBoxSize)`, so adding the overlay
// here can't change layout dimensions.
//
// Anchor the chip's bottom-RIGHT to the avatar's
// bottom-right corner (small inward nudge from the outer
// Box edge so it clears the ring/glow padding). The chip
// extends LEFTWARD inside the cell as content widens
// (single emoji vs `×N` count). Earlier `BottomStart`
// experiment anchored the chip's left edge at the same
// corner — looked great for a fixed-width chip but bled
// straight into the next column once a real emoji was
// rendered, because the cell was only ~100 dp wide and
// the chip extended out 40+ dp to the right.
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions,
modifier =
Modifier
.align(Alignment.BottomEnd)
.offset(x = -ringPadding + 6.dp, y = -ringPadding + 6.dp),
)
}
} }
UsernameDisplay( UsernameDisplay(
baseUser = user, baseUser = user,
@@ -548,7 +577,6 @@ private fun AvatarAndBadges(
isConnecting: Boolean, isConnecting: Boolean,
showMicBadge: Boolean, showMicBadge: Boolean,
isSpeaking: Boolean, isSpeaking: Boolean,
reactions: List<RoomReaction>,
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
ClickableUserPicture( ClickableUserPicture(
@@ -591,19 +619,6 @@ private fun AvatarAndBadges(
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),
)
}
} }
} }
@@ -21,38 +21,50 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.persistentListOf
/** /**
* Floating-emoji overlay drawn under a speaker's avatar. Each chip * Floating-emoji overlay drawn under a speaker's avatar. Each
* is keyed by emoji content; bursts of the same emoji collapse into * incoming `kind-7` becomes its own independent chip two
* a single `🔥 ×3` chip whose count tracks live as new reactions land. * reactions arriving in quick succession produce two concurrent
* rises, not one shared chip that restarts. The previous
* implementation grouped by content and used `youngestSec` as a
* `remember` key, which meant a second `🔥` from the same speaker
* interrupted the first chip's animation and showed `×2` instead
* of two distinct floating emojis.
* *
* Reactions are about what the speaker is saying RIGHT NOW, so each * Reactions live for [REACTION_WINDOW_SEC] seconds in the
* chip lives for [REACTION_WINDOW_SEC] seconds: the moment its * aggregator; each chip animates over [REACTION_RISE_MS] and then
* youngest reaction arrives the chip fades+scales in, and over that * stays invisible (alpha=0) until the aggregator evicts it. We cap
* window it slowly drifts upward and fades out before the eviction * the visible-chip count at [MAX_VISIBLE_CHIPS] so a flurry of
* tick removes it from the underlying list. * reactions doesn't bleed the Row into the next column.
*/ */
@Composable @Composable
internal fun SpeakerReactionOverlay( internal fun SpeakerReactionOverlay(
@@ -65,24 +77,37 @@ internal fun SpeakerReactionOverlay(
exit = fadeOut() + scaleOut(targetScale = 0.6f), exit = fadeOut() + scaleOut(targetScale = 0.6f),
modifier = modifier, modifier = modifier,
) { ) {
// Group by content so duplicate emojis collapse into a single // Most-recent first; capped so the layered Box doesn't grow
// "🔥 ×N" chip. Order by most recent so a fresh reaction lands // an unbounded set of overlapping chips. Older reactions drop
// at the front of the row. // off as new ones arrive (the aggregator still tracks them
val byContent = reactions.groupBy { it.content } // for eviction but they're not drawn once past the cap).
val ordered = val visible =
byContent.toList().sortedByDescending { (_, list) -> remember(reactions) {
list.maxOf { it.createdAtSec } reactions
.sortedByDescending { it.createdAtSec }
.take(MAX_VISIBLE_CHIPS)
} }
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { // Box, not Row — every chip stacks at the same right-anchored
ordered.forEach { (content, list) -> // spot so the X position stays fixed throughout the chip's
// Newest createdAt drives the chip's lifecycle — // lifecycle. The previous Row layout slid older chips
// a fresh reaction restarts the upward-drift+fade. // leftward each time a new reaction arrived (sortedByDescending
val youngestSec = list.maxOf { it.createdAtSec } // put the newest at index 0). With overlapping chips, each
ReactionChip( // animates independently in place; the newest is drawn on
content = content, // top by virtue of being last in the iteration.
count = list.size, Box(contentAlignment = androidx.compose.ui.Alignment.BottomEnd) {
youngestSec = youngestSec, // Iterate in reverse (oldest first) so the newest reaction
) // ends up last in the Box and therefore on top of the
// paint stack.
visible.asReversed().forEach { reaction ->
// Per-event-id key — independent animation lifecycle
// per reaction. Same-emoji bursts no longer collide
// on a shared chip.
key(reaction.eventId) {
ReactionChip(
content = reaction.content,
youngestSec = reaction.createdAtSec,
)
}
} }
} }
} }
@@ -91,68 +116,161 @@ internal fun SpeakerReactionOverlay(
@Composable @Composable
private fun ReactionChip( private fun ReactionChip(
content: String, content: String,
count: Int,
youngestSec: Long, youngestSec: Long,
) { ) {
// One-shot rise + fade keyed on [youngestSec], so a fresh // Manual frame-clock animation. Earlier passes using
// reaction in the same emoji burst restarts the motion. The // `Animatable.animateTo(...)` and `animateFloatAsState(...)` never
// previous implementation re-derived `progress` on a 100 ms // produced visible motion in this site — five iterations of
// `delay` loop over the 10 s eviction window — drift came out // tuning didn't help. Driving the progress State directly from
// to ~0.16 dp per tick (visibly stepped) and the rise barely // `withFrameNanos` is the lowest-level Compose animation pattern
// moved over the full lifetime. Using `Animatable` drives the // there is: each frame, we read the elapsed time, compute a 0→1
// value on Compose's frame clock at full refresh rate, and the // fraction, and write it into a MutableFloatState. The
// shorter [REACTION_RISE_MS] duration produces an actual // `graphicsLayer` lambda below reads that State, so its block
// pop-and-rise instead of a slow creep. // re-invokes per frame without going through the standard
val rise = remember(youngestSec) { Animatable(0f) } // animation framework. Keyed on [youngestSec] so a fresh
// reaction in the same emoji burst restarts the animation
// cleanly.
var progress by remember(youngestSec) { mutableFloatStateOf(0f) }
LaunchedEffect(youngestSec) { LaunchedEffect(youngestSec) {
// Wall-clock seed so a chip already mid-window when the val startNanos = withFrameNanos { it }
// composable first mounts (e.g. recompose after rotation) while (true) {
// picks up where it left off rather than restarting. val nowNanos = withFrameNanos { it }
val ageMs = ((System.currentTimeMillis() / 1000L) - youngestSec).coerceAtLeast(0L) * 1000L val elapsedMs = (nowNanos - startNanos) / 1_000_000f
val startProgress = (ageMs.toFloat() / REACTION_RISE_MS.toFloat()).coerceIn(0f, 1f) val raw = (elapsedMs / REACTION_RISE_MS).coerceIn(0f, 1f)
rise.snapTo(startProgress) // FastOutSlowInEasing — quick start, slow tail. Pulled
val remainingMs = (REACTION_RISE_MS - ageMs).toInt().coerceAtLeast(0) // by hand so we don't import the animation-core easing
if (remainingMs > 0) { // for one call site.
rise.animateTo( progress = FastOutSlowInEasing.transform(raw)
targetValue = 1f, if (raw >= 1f) break
animationSpec = tween(durationMillis = remainingMs, easing = LinearOutSlowInEasing),
)
} }
} }
val progress = rise.value // The chip drifts up, grows, and fades out — three concurrent
// Final rise distance has to land within the participant cell's // tracks driven by the same `progress` value, all set inside
// headroom — much past 24 dp clips into the avatar above on a // the graphicsLayer lambda so the read tracks at draw time
// 75 dp grid cell. // (per frame, no full recomposition needed):
val driftDp = (-DRIFT_MAX_DP * progress).dp // * translationY: 0 → -DRIFT_MAX_DP. Capped within the cell's
// Solid for the first half, then fades linearly over the second // headroom (otherwise the chip clips the avatar above on a
// half so the reaction reads as "popping in, then dissipating". // 75 dp grid cell).
val alpha = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f) // * scale: 1 → SCALE_MAX. Mirrors a "rising bubble" — the
// emoji looks closer/larger as it lifts.
Surface( // * alpha: 1 → 1 → 0. Solid for the first ALPHA_HOLD_FRAC of
// progress; fades over the remainder so the chip dissipates
// well before the 10 s aggregator-eviction tick.
// Fixed-size Box so each chip occupies the same footprint
// regardless of which emoji is rendered — "🔥" and "👍" have
// different intrinsic glyph widths, and without a fixed box the
// Row's right-anchored layout slid the visible content left/right
// by a few dp on every new reaction. Centring the Text inside a
// [CHIP_SIZE]-square Box pins the emoji's centre to a stable
// point. No Surface / background — transparent over the avatar.
Box(
modifier = modifier =
Modifier Modifier
.offset(y = driftDp) .size(CHIP_SIZE)
.alpha(alpha), .graphicsLayer {
shape = MaterialTheme.shapes.small, val p = progress
tonalElevation = 2.dp, translationY = -DRIFT_MAX_DP.dp.toPx() * p
shadowElevation = 1.dp, scaleX = 1f + (SCALE_MAX - 1f) * p
scaleY = scaleX
alpha =
(
1f -
(
(p - ALPHA_HOLD_FRAC).coerceAtLeast(0f) /
(1f - ALPHA_HOLD_FRAC)
)
).coerceIn(0f, 1f)
},
contentAlignment = Alignment.Center,
) { ) {
val label = if (count > 1) "$content ×$count" else content RenderReactionContent(content)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
} }
} }
// Rise + fade plays out well inside the 10 s eviction window so the /**
// chip is fully transparent before the aggregator drops it from the * Render a single reaction's content the same way
// list (avoids a hard pop on removal). 6 s keeps the chip solidly * `ReactionsRow.RenderReactionType` does, so a kind-7 with a NIP-30
// visible for ~3 s (alpha=1 holds for the first half) before the * custom emoji shows the image (not the `:shortcode:url` string)
// fade begins — earlier 1.5 s and 4 s passes still read as a * and special "+" / "-" tokens fall back to the standard heart / 👎
// "blink" relative to nostrnests' web reaction lifetime. * conventions.
private const val REACTION_RISE_MS = 6000L *
* - `":<shortcode>:<url>"` image rendered inline via
* [InLineIconRenderer] using [CustomEmoji.ImageUrlType].
* - `"+"` (the legacy NIP-25 "like" symbol; the heart icon
* vector lives in `ReactionsRow.kt` as a private composable so
* here we render the unicode heart at the same size).
* - `"-"` 👎.
* - Anything else rendered verbatim as Text (the common case
* a single unicode emoji).
*/
@Composable
private fun RenderReactionContent(content: String) {
if (content.isNotEmpty() && content[0] == ':') {
val renderable =
remember(content) {
persistentListOf(
CustomEmoji.ImageUrlType(content.removePrefix(":").substringAfter(":")),
)
}
InLineIconRenderer(
wordsInOrder = renderable,
style = SpanStyle(color = Color.Unspecified),
fontSize = EMOJI_FONT_SIZE,
maxLines = 1,
)
return
}
val display =
when (content) {
"+" -> "❤️"
"-" -> "👎"
else -> content
}
Text(
text = display,
style = MaterialTheme.typography.titleLarge.copy(fontSize = EMOJI_FONT_SIZE),
color = MaterialTheme.colorScheme.onSurface,
)
}
// 3 s pop-and-drift. Earlier 1.5 s, 4 s, and 6 s passes (when the
// animation was broken upstream) didn't read well; 3 s with the
// working frame-clock animation lands as a brisk rise — fast enough
// to feel like a reaction, slow enough to track visually — and
// still finishes inside the 10 s aggregator-eviction window.
private const val REACTION_RISE_MS = 3000L
// Final rise distance has to clear the avatar without crashing into
// the cell above. 28 dp lands the chip near the top edge of a 75 dp
// avatar — readable, no clip.
private const val DRIFT_MAX_DP = 28f private const val DRIFT_MAX_DP = 28f
// 1.6× peak scale gives a clearly visible "growing as it rises"
// effect. Applied via graphicsLayer so it's drawing-only and
// doesn't reflow neighbours each frame.
private const val SCALE_MAX = 1.6f
// Alpha holds at 1 for the first 50 % of progress, then ramps to 0
// over the remaining 50 %. Keeps the reaction readable in its prime
// without abrupt removal.
private const val ALPHA_HOLD_FRAC = 0.5f
// Base font for the emoji glyph. labelSmall (default Material caption
// ~11 sp) was too small to read at avatar-grid scale; 22 sp gets the
// emoji to roughly the same visual size as a stage-grid avatar badge.
private val EMOJI_FONT_SIZE = 22.sp
// Fixed chip footprint. Slightly larger than the emoji glyph so the
// glyph (which varies in intrinsic width across emojis) is centred
// in a consistent box. With this, the Row's right-anchored layout
// produces a perfectly stable left edge per chip count, no matter
// which emoji.
private val CHIP_SIZE = 30.dp
// Cap on concurrently-rendered chips per sender. The aggregator
// keeps reactions for 10 s; without a cap, a burst of 8+ reactions
// would stretch the Row past the cell and into the next column even
// after the older chips have faded to alpha=0 (still occupy layout).
// 3 reads as "the user is reacting a lot" without overflowing.
private const val MAX_VISIBLE_CHIPS = 3
-1
View File
@@ -659,7 +659,6 @@
<string name="nest_chat_send">Send</string> <string name="nest_chat_send">Send</string>
<string name="nest_chat_placeholder">Say something…</string> <string name="nest_chat_placeholder">Say something…</string>
<string name="nest_chat_empty">No messages yet. Be the first to chat.</string> <string name="nest_chat_empty">No messages yet. Be the first to chat.</string>
<string name="nest_reactions_title">React</string>
<string name="nest_reactions_button">React</string> <string name="nest_reactions_button">React</string>
<string name="nest_edit_title">Edit room</string> <string name="nest_edit_title">Edit room</string>
<string name="nest_edit_save">Save</string> <string name="nest_edit_save">Save</string>
@@ -78,9 +78,6 @@ class RoomReactionsAggregator {
// event id so the same kind-7 from two relays only counts once. // event id so the same kind-7 from two relays only counts once.
private val byEventId = LinkedHashMap<String, RoomReaction>() private val byEventId = LinkedHashMap<String, RoomReaction>()
/** Stable key for room-wide reactions in the returned map. */
private val roomWideKey = ""
/** Apply one reaction and return the post-evict snapshot. */ /** Apply one reaction and return the post-evict snapshot. */
fun apply( fun apply(
event: ReactionEvent, event: ReactionEvent,
@@ -102,13 +99,24 @@ class RoomReactionsAggregator {
* cadence (typically every second so the floating-up animation * cadence (typically every second so the floating-up animation
* frame rate is set by the eviction tick rather than by a * frame rate is set by the eviction tick rather than by a
* per-Composable timer). * per-Composable timer).
*
* Reactions are grouped by [RoomReaction.sourcePubkey] the
* user who SENT the reaction. The chip floats up from the
* reactor's own avatar (matching nostrnests' UX) rather than
* from the target speaker's avatar (which would surface the
* NIP-25 `p`-tag's "originalAuthor" semantics useful for
* comment threads but confusing in a live audio room, where the
* audience expects to see who's reacting, not who's being
* reacted to). Room-wide reactions (no `p`-tag at all) still
* land under their sender's key, so they show up on the
* reactor's avatar just like targeted ones.
*/ */
fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> { fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> {
val it = byEventId.entries.iterator() val it = byEventId.entries.iterator()
while (it.hasNext()) { while (it.hasNext()) {
if (it.next().value.createdAtSec < olderThanSec) it.remove() if (it.next().value.createdAtSec < olderThanSec) it.remove()
} }
return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey } return byEventId.values.groupBy { it.sourcePubkey }
} }
/** Whether the aggregator currently holds any unevicted reactions. */ /** Whether the aggregator currently holds any unevicted reactions. */
@@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue
class RoomReactionsStateTest { class RoomReactionsStateTest {
private val alice = "a".repeat(64) private val alice = "a".repeat(64)
@@ -71,14 +70,19 @@ class RoomReactionsStateTest {
} }
@Test @Test
fun aggregatorGroupsBySpeaker() { fun aggregatorGroupsBySender() {
// Two reactions sent BY alice and charlie, both targeting bob.
// Group by sender so the floating chip rises from the
// reactor's avatar rather than the target speaker's.
val agg = RoomReactionsAggregator() val agg = RoomReactionsAggregator()
agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L) agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L)
val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L) val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L)
// Two reactions on bob. assertEquals(setOf(alice, charlie), snap.keys)
assertEquals(setOf(bob), snap.keys) assertEquals(1, snap[alice]!!.size)
assertEquals(2, snap[bob]!!.size) assertEquals("🔥", snap[alice]!![0].content)
assertEquals(1, snap[charlie]!!.size)
assertEquals("👏", snap[charlie]!![0].content)
} }
@Test @Test
@@ -89,20 +93,22 @@ class RoomReactionsStateTest {
// Fresh reaction (T=105) — inside the window. // Fresh reaction (T=105) — inside the window.
val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L) val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L)
// bob has only the fresh one left. // alice's reaction is evicted, charlie's stays.
assertEquals(1, snap[bob]!!.size) assertNull(snap[alice])
assertEquals("👏", snap[bob]!![0].content) assertEquals(1, snap[charlie]!!.size)
assertEquals("👏", snap[charlie]!![0].content)
} }
@Test @Test
fun aggregatorRoomWideReactionsKeyedByEmptyString() { fun aggregatorRoomWideReactionsKeyedBySender() {
val agg = RoomReactionsAggregator() val agg = RoomReactionsAggregator()
val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L) val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L)
// Room-wide reactions land under the empty-string key so the // A reaction with no `p`-tag (room-wide) still groups under
// map's value-type stays uniform; the UI can split them on render. // the sender's key, so it floats from the reactor's avatar
assertTrue(snap.containsKey("")) // exactly the same way a speaker-targeted reaction does.
assertEquals(1, snap[""]!!.size) assertEquals(setOf(alice), snap.keys)
assertEquals(1, snap[alice]!!.size)
} }
@Test @Test
@@ -129,6 +135,7 @@ class RoomReactionsStateTest {
agg.apply(replay, nowSec = 100L, windowSec = 30L) agg.apply(replay, nowSec = 100L, windowSec = 30L)
val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L) val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L)
assertEquals(1, snap[bob]!!.size) // Sender-grouped: only one entry, under the sender (alice).
assertEquals(1, snap[alice]!!.size)
} }
} }