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
|
// 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)
|
||||||
|
|||||||
-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.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)
|
||||||
|
|||||||
+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.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 },
|
||||||
|
|||||||
+31
-16
@@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+204
-86
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+12
-4
@@ -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. */
|
||||||
|
|||||||
+21
-14
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user