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

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

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

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

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

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

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

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

Tests updated for sender-grouped aggregator semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-13 21:06:27 -04:00
parent d21eb0e226
commit 78be534df5
10 changed files with 445 additions and 240 deletions
@@ -78,9 +78,6 @@ class RoomReactionsAggregator {
// event id so the same kind-7 from two relays only counts once.
private val byEventId = LinkedHashMap<String, RoomReaction>()
/** Stable key for room-wide reactions in the returned map. */
private val roomWideKey = ""
/** Apply one reaction and return the post-evict snapshot. */
fun apply(
event: ReactionEvent,
@@ -102,13 +99,24 @@ class RoomReactionsAggregator {
* cadence (typically every second so the floating-up animation
* frame rate is set by the eviction tick rather than by a
* per-Composable timer).
*
* Reactions are grouped by [RoomReaction.sourcePubkey] — the
* user who SENT the reaction. The chip floats up from the
* reactor's own avatar (matching nostrnests' UX) rather than
* from the target speaker's avatar (which would surface the
* NIP-25 `p`-tag's "originalAuthor" semantics — useful for
* comment threads but confusing in a live audio room, where the
* audience expects to see who's reacting, not who's being
* reacted to). Room-wide reactions (no `p`-tag at all) still
* land under their sender's key, so they show up on the
* reactor's avatar just like targeted ones.
*/
fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> {
val it = byEventId.entries.iterator()
while (it.hasNext()) {
if (it.next().value.createdAtSec < olderThanSec) it.remove()
}
return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey }
return byEventId.values.groupBy { it.sourcePubkey }
}
/** Whether the aggregator currently holds any unevicted reactions. */
@@ -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)
}
}