diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt index d1a7096a6..e82ce21db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt deleted file mode 100644 index 9c633500e..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt +++ /dev/null @@ -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("πŸ‘", "πŸ”₯", "πŸ˜‚", "πŸ‘€", "❀️", "⚑") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt new file mode 100644 index 000000000..e25e19172 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt @@ -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() + 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index ba3e5ff68..852a891c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 464b5ee92..35684c395 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -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(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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index f35bc41f9..8ac1d1551 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -303,6 +303,7 @@ internal fun AudienceGrid( onLongPressParticipant: ((String) -> Unit)? = null, onTapParticipant: ((String) -> Unit)? = null, myPubkey: String? = null, + reactionsByPubkey: Map> = 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, ) { 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), - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt index 88068a9d6..fb38f49d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt @@ -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. + * + * - `"::"` β†’ 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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a34c43df0..e9fd03b1c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -659,7 +659,6 @@ Send Say something… No messages yet. Be the first to chat. - React React Edit room Save diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt index a538d1c59..4fddcd8b1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt @@ -78,9 +78,6 @@ class RoomReactionsAggregator { // event id so the same kind-7 from two relays only counts once. private val byEventId = LinkedHashMap() - /** 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> { 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. */ diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt index 38df29857..7a894efa8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt @@ -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) } }