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:
+13
@@ -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
|
||||
// commands. See NestRoomFilterAssembler for the filter shape.
|
||||
NestRoomFilterAssemblerSubscription(roomNote, accountViewModel)
|
||||
|
||||
-104
@@ -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("👏", "🔥", "😂", "👀", "❤️", "⚡")
|
||||
+131
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-5
@@ -67,6 +67,9 @@ import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -96,7 +99,8 @@ internal fun NestActionBar(
|
||||
isOnStage: Boolean,
|
||||
handRaised: Boolean,
|
||||
onHandRaisedChange: (Boolean) -> Unit,
|
||||
onShowReactionPicker: () -> Unit,
|
||||
roomNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
onLeave: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
@@ -121,7 +125,8 @@ internal fun NestActionBar(
|
||||
isConnected = ui.connection is ConnectionUiState.Connected,
|
||||
handRaised = handRaised,
|
||||
onHandRaisedChange = onHandRaisedChange,
|
||||
onShowReactionPicker = onShowReactionPicker,
|
||||
roomNote = roomNote,
|
||||
accountViewModel = accountViewModel,
|
||||
onLeave = onLeave,
|
||||
)
|
||||
}
|
||||
@@ -352,7 +357,8 @@ private fun EndCluster(
|
||||
isConnected: Boolean,
|
||||
handRaised: Boolean,
|
||||
onHandRaisedChange: (Boolean) -> Unit,
|
||||
onShowReactionPicker: () -> Unit,
|
||||
roomNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
onLeave: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -365,12 +371,31 @@ private fun EndCluster(
|
||||
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
|
||||
}
|
||||
// React works in any state — even disconnected users can react
|
||||
// via the room note.
|
||||
FilledTonalIconButton(onClick = onShowReactionPicker) {
|
||||
// via the room note. Reuses the same ReactionChoicePopup
|
||||
// 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(
|
||||
symbol = MaterialSymbols.EmojiEmotions,
|
||||
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))
|
||||
LeaveRoomButton(onClick = onLeave)
|
||||
|
||||
+3
-10
@@ -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.EditNestViewModel
|
||||
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.HandRaiseQueueSection
|
||||
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 showHostMenu by rememberSaveable { mutableStateOf(false) }
|
||||
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
|
||||
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// Summary is collapsed by default; tapping the top-bar title
|
||||
// toggles it so the user can preview the room description without
|
||||
@@ -268,7 +266,8 @@ internal fun NestFullScreen(
|
||||
isOnStage = isOnStageMe,
|
||||
handRaised = handRaised,
|
||||
onHandRaisedChange = onHandRaisedChange,
|
||||
onShowReactionPicker = { showReactionPicker = true },
|
||||
roomNote = roomNote,
|
||||
accountViewModel = accountViewModel,
|
||||
onLeave = {
|
||||
if (isHost) {
|
||||
showHostLeaveConfirm = true
|
||||
@@ -310,6 +309,7 @@ internal fun NestFullScreen(
|
||||
onLongPressParticipant = onLongPressParticipant,
|
||||
onTapParticipant = onLongPressParticipant,
|
||||
myPubkey = myPubkey,
|
||||
reactionsByPubkey = reactionsByPubkey,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
@@ -349,13 +349,6 @@ internal fun NestFullScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showReactionPicker) {
|
||||
RoomReactionPickerSheet(
|
||||
onPick = { emoji -> accountViewModel.reactToOrDelete(roomNote, emoji) },
|
||||
onDismiss = { showReactionPicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showHostLeaveConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showHostLeaveConfirm = false },
|
||||
|
||||
+31
-16
@@ -303,6 +303,7 @@ internal fun AudienceGrid(
|
||||
onLongPressParticipant: ((String) -> Unit)? = null,
|
||||
onTapParticipant: ((String) -> Unit)? = null,
|
||||
myPubkey: String? = null,
|
||||
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
||||
) {
|
||||
if (members.isEmpty()) {
|
||||
Box(
|
||||
@@ -341,7 +342,7 @@ internal fun AudienceGrid(
|
||||
audioLevel = 0f,
|
||||
isConnecting = false,
|
||||
showMicBadge = false,
|
||||
reactions = emptyList(),
|
||||
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
|
||||
accountViewModel = accountViewModel,
|
||||
onLongPressParticipant = onLongPressParticipant,
|
||||
onTapParticipant = onTapParticipant,
|
||||
@@ -524,8 +525,36 @@ private fun MemberCell(
|
||||
isConnecting = isConnecting,
|
||||
showMicBadge = showMicBadge,
|
||||
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(
|
||||
baseUser = user,
|
||||
@@ -548,7 +577,6 @@ private fun AvatarAndBadges(
|
||||
isConnecting: Boolean,
|
||||
showMicBadge: Boolean,
|
||||
isSpeaking: Boolean,
|
||||
reactions: List<RoomReaction>,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ClickableUserPicture(
|
||||
@@ -591,19 +619,6 @@ private fun AvatarAndBadges(
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+204
-86
@@ -21,38 +21,50 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
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.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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
|
||||
* is keyed by emoji content; bursts of the same emoji collapse into
|
||||
* a single `🔥 ×3` chip whose count tracks live as new reactions land.
|
||||
* Floating-emoji overlay drawn under a speaker's avatar. Each
|
||||
* incoming `kind-7` becomes its own independent chip — two
|
||||
* 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
|
||||
* chip lives for [REACTION_WINDOW_SEC] seconds: the moment its
|
||||
* youngest reaction arrives the chip fades+scales in, and over that
|
||||
* window it slowly drifts upward and fades out before the eviction
|
||||
* tick removes it from the underlying list.
|
||||
* Reactions live for [REACTION_WINDOW_SEC] seconds in the
|
||||
* aggregator; each chip animates over [REACTION_RISE_MS] and then
|
||||
* stays invisible (alpha=0) until the aggregator evicts it. We cap
|
||||
* the visible-chip count at [MAX_VISIBLE_CHIPS] so a flurry of
|
||||
* reactions doesn't bleed the Row into the next column.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeakerReactionOverlay(
|
||||
@@ -65,24 +77,37 @@ internal fun SpeakerReactionOverlay(
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.6f),
|
||||
modifier = modifier,
|
||||
) {
|
||||
// Group by content so duplicate emojis collapse into a single
|
||||
// "🔥 ×N" chip. Order by most recent so a fresh reaction lands
|
||||
// at the front of the row.
|
||||
val byContent = reactions.groupBy { it.content }
|
||||
val ordered =
|
||||
byContent.toList().sortedByDescending { (_, list) ->
|
||||
list.maxOf { it.createdAtSec }
|
||||
// Most-recent first; capped so the layered Box doesn't grow
|
||||
// an unbounded set of overlapping chips. Older reactions drop
|
||||
// off as new ones arrive (the aggregator still tracks them
|
||||
// for eviction but they're not drawn once past the cap).
|
||||
val visible =
|
||||
remember(reactions) {
|
||||
reactions
|
||||
.sortedByDescending { it.createdAtSec }
|
||||
.take(MAX_VISIBLE_CHIPS)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ordered.forEach { (content, list) ->
|
||||
// Newest createdAt drives the chip's lifecycle —
|
||||
// a fresh reaction restarts the upward-drift+fade.
|
||||
val youngestSec = list.maxOf { it.createdAtSec }
|
||||
ReactionChip(
|
||||
content = content,
|
||||
count = list.size,
|
||||
youngestSec = youngestSec,
|
||||
)
|
||||
// Box, not Row — every chip stacks at the same right-anchored
|
||||
// spot so the X position stays fixed throughout the chip's
|
||||
// lifecycle. The previous Row layout slid older chips
|
||||
// leftward each time a new reaction arrived (sortedByDescending
|
||||
// put the newest at index 0). With overlapping chips, each
|
||||
// animates independently in place; the newest is drawn on
|
||||
// top by virtue of being last in the iteration.
|
||||
Box(contentAlignment = androidx.compose.ui.Alignment.BottomEnd) {
|
||||
// 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
|
||||
private fun ReactionChip(
|
||||
content: String,
|
||||
count: Int,
|
||||
youngestSec: Long,
|
||||
) {
|
||||
// One-shot rise + fade keyed on [youngestSec], so a fresh
|
||||
// reaction in the same emoji burst restarts the motion. The
|
||||
// previous implementation re-derived `progress` on a 100 ms
|
||||
// `delay` loop over the 10 s eviction window — drift came out
|
||||
// to ~0.16 dp per tick (visibly stepped) and the rise barely
|
||||
// moved over the full lifetime. Using `Animatable` drives the
|
||||
// value on Compose's frame clock at full refresh rate, and the
|
||||
// shorter [REACTION_RISE_MS] duration produces an actual
|
||||
// pop-and-rise instead of a slow creep.
|
||||
val rise = remember(youngestSec) { Animatable(0f) }
|
||||
// Manual frame-clock animation. Earlier passes using
|
||||
// `Animatable.animateTo(...)` and `animateFloatAsState(...)` never
|
||||
// produced visible motion in this site — five iterations of
|
||||
// tuning didn't help. Driving the progress State directly from
|
||||
// `withFrameNanos` is the lowest-level Compose animation pattern
|
||||
// there is: each frame, we read the elapsed time, compute a 0→1
|
||||
// fraction, and write it into a MutableFloatState. The
|
||||
// `graphicsLayer` lambda below reads that State, so its block
|
||||
// re-invokes per frame without going through the standard
|
||||
// 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) {
|
||||
// Wall-clock seed so a chip already mid-window when the
|
||||
// composable first mounts (e.g. recompose after rotation)
|
||||
// picks up where it left off rather than restarting.
|
||||
val ageMs = ((System.currentTimeMillis() / 1000L) - youngestSec).coerceAtLeast(0L) * 1000L
|
||||
val startProgress = (ageMs.toFloat() / REACTION_RISE_MS.toFloat()).coerceIn(0f, 1f)
|
||||
rise.snapTo(startProgress)
|
||||
val remainingMs = (REACTION_RISE_MS - ageMs).toInt().coerceAtLeast(0)
|
||||
if (remainingMs > 0) {
|
||||
rise.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = remainingMs, easing = LinearOutSlowInEasing),
|
||||
)
|
||||
val startNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val nowNanos = withFrameNanos { it }
|
||||
val elapsedMs = (nowNanos - startNanos) / 1_000_000f
|
||||
val raw = (elapsedMs / REACTION_RISE_MS).coerceIn(0f, 1f)
|
||||
// FastOutSlowInEasing — quick start, slow tail. Pulled
|
||||
// by hand so we don't import the animation-core easing
|
||||
// for one call site.
|
||||
progress = FastOutSlowInEasing.transform(raw)
|
||||
if (raw >= 1f) break
|
||||
}
|
||||
}
|
||||
|
||||
val progress = rise.value
|
||||
// Final rise distance has to land within the participant cell's
|
||||
// headroom — much past 24 dp clips into the avatar above on a
|
||||
// 75 dp grid cell.
|
||||
val driftDp = (-DRIFT_MAX_DP * progress).dp
|
||||
// Solid for the first half, then fades linearly over the second
|
||||
// half so the reaction reads as "popping in, then dissipating".
|
||||
val alpha = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f)
|
||||
|
||||
Surface(
|
||||
// The chip drifts up, grows, and fades out — three concurrent
|
||||
// tracks driven by the same `progress` value, all set inside
|
||||
// the graphicsLayer lambda so the read tracks at draw time
|
||||
// (per frame, no full recomposition needed):
|
||||
// * translationY: 0 → -DRIFT_MAX_DP. Capped within the cell's
|
||||
// headroom (otherwise the chip clips the avatar above on a
|
||||
// 75 dp grid cell).
|
||||
// * scale: 1 → SCALE_MAX. Mirrors a "rising bubble" — the
|
||||
// emoji looks closer/larger as it lifts.
|
||||
// * 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
|
||||
.offset(y = driftDp)
|
||||
.alpha(alpha),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 1.dp,
|
||||
.size(CHIP_SIZE)
|
||||
.graphicsLayer {
|
||||
val p = progress
|
||||
translationY = -DRIFT_MAX_DP.dp.toPx() * p
|
||||
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
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
RenderReactionContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
// Rise + fade plays out well inside the 10 s eviction window so the
|
||||
// chip is fully transparent before the aggregator drops it from the
|
||||
// list (avoids a hard pop on removal). 6 s keeps the chip solidly
|
||||
// visible for ~3 s (alpha=1 holds for the first half) before the
|
||||
// fade begins — earlier 1.5 s and 4 s passes still read as a
|
||||
// "blink" relative to nostrnests' web reaction lifetime.
|
||||
private const val REACTION_RISE_MS = 6000L
|
||||
/**
|
||||
* Render a single reaction's content the same way
|
||||
* `ReactionsRow.RenderReactionType` does, so a kind-7 with a NIP-30
|
||||
* custom emoji shows the image (not the `:shortcode:url` string)
|
||||
* and special "+" / "-" tokens fall back to the standard heart / 👎
|
||||
* conventions.
|
||||
*
|
||||
* - `":<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
|
||||
|
||||
// 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
|
||||
|
||||
@@ -659,7 +659,6 @@
|
||||
<string name="nest_chat_send">Send</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_reactions_title">React</string>
|
||||
<string name="nest_reactions_button">React</string>
|
||||
<string name="nest_edit_title">Edit room</string>
|
||||
<string name="nest_edit_save">Save</string>
|
||||
|
||||
+12
-4
@@ -78,9 +78,6 @@ class RoomReactionsAggregator {
|
||||
// event id so the same kind-7 from two relays only counts once.
|
||||
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. */
|
||||
fun apply(
|
||||
event: ReactionEvent,
|
||||
@@ -102,13 +99,24 @@ class RoomReactionsAggregator {
|
||||
* cadence (typically every second so the floating-up animation
|
||||
* frame rate is set by the eviction tick rather than by a
|
||||
* 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>> {
|
||||
val it = byEventId.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
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. */
|
||||
|
||||
+21
-14
@@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RoomReactionsStateTest {
|
||||
private val alice = "a".repeat(64)
|
||||
@@ -71,14 +70,19 @@ class RoomReactionsStateTest {
|
||||
}
|
||||
|
||||
@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()
|
||||
agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L)
|
||||
val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L)
|
||||
|
||||
// Two reactions on bob.
|
||||
assertEquals(setOf(bob), snap.keys)
|
||||
assertEquals(2, snap[bob]!!.size)
|
||||
assertEquals(setOf(alice, charlie), snap.keys)
|
||||
assertEquals(1, snap[alice]!!.size)
|
||||
assertEquals("🔥", snap[alice]!![0].content)
|
||||
assertEquals(1, snap[charlie]!!.size)
|
||||
assertEquals("👏", snap[charlie]!![0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,20 +93,22 @@ class RoomReactionsStateTest {
|
||||
// Fresh reaction (T=105) — inside the window.
|
||||
val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L)
|
||||
|
||||
// bob has only the fresh one left.
|
||||
assertEquals(1, snap[bob]!!.size)
|
||||
assertEquals("👏", snap[bob]!![0].content)
|
||||
// alice's reaction is evicted, charlie's stays.
|
||||
assertNull(snap[alice])
|
||||
assertEquals(1, snap[charlie]!!.size)
|
||||
assertEquals("👏", snap[charlie]!![0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregatorRoomWideReactionsKeyedByEmptyString() {
|
||||
fun aggregatorRoomWideReactionsKeyedBySender() {
|
||||
val agg = RoomReactionsAggregator()
|
||||
val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L)
|
||||
|
||||
// Room-wide reactions land under the empty-string key so the
|
||||
// map's value-type stays uniform; the UI can split them on render.
|
||||
assertTrue(snap.containsKey(""))
|
||||
assertEquals(1, snap[""]!!.size)
|
||||
// A reaction with no `p`-tag (room-wide) still groups under
|
||||
// the sender's key, so it floats from the reactor's avatar
|
||||
// exactly the same way a speaker-targeted reaction does.
|
||||
assertEquals(setOf(alice), snap.keys)
|
||||
assertEquals(1, snap[alice]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -129,6 +135,7 @@ class RoomReactionsStateTest {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user