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:
+12
-4
@@ -78,9 +78,6 @@ class RoomReactionsAggregator {
|
||||
// event id so the same kind-7 from two relays only counts once.
|
||||
private val byEventId = LinkedHashMap<String, RoomReaction>()
|
||||
|
||||
/** Stable key for room-wide reactions in the returned map. */
|
||||
private val roomWideKey = ""
|
||||
|
||||
/** Apply one reaction and return the post-evict snapshot. */
|
||||
fun apply(
|
||||
event: ReactionEvent,
|
||||
@@ -102,13 +99,24 @@ class RoomReactionsAggregator {
|
||||
* cadence (typically every second so the floating-up animation
|
||||
* frame rate is set by the eviction tick rather than by a
|
||||
* per-Composable timer).
|
||||
*
|
||||
* Reactions are grouped by [RoomReaction.sourcePubkey] — the
|
||||
* user who SENT the reaction. The chip floats up from the
|
||||
* reactor's own avatar (matching nostrnests' UX) rather than
|
||||
* from the target speaker's avatar (which would surface the
|
||||
* NIP-25 `p`-tag's "originalAuthor" semantics — useful for
|
||||
* comment threads but confusing in a live audio room, where the
|
||||
* audience expects to see who's reacting, not who's being
|
||||
* reacted to). Room-wide reactions (no `p`-tag at all) still
|
||||
* land under their sender's key, so they show up on the
|
||||
* reactor's avatar just like targeted ones.
|
||||
*/
|
||||
fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> {
|
||||
val it = byEventId.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
if (it.next().value.createdAtSec < olderThanSec) it.remove()
|
||||
}
|
||||
return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey }
|
||||
return byEventId.values.groupBy { it.sourcePubkey }
|
||||
}
|
||||
|
||||
/** Whether the aggregator currently holds any unevicted reactions. */
|
||||
|
||||
+21
-14
@@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RoomReactionsStateTest {
|
||||
private val alice = "a".repeat(64)
|
||||
@@ -71,14 +70,19 @@ class RoomReactionsStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregatorGroupsBySpeaker() {
|
||||
fun aggregatorGroupsBySender() {
|
||||
// Two reactions sent BY alice and charlie, both targeting bob.
|
||||
// Group by sender so the floating chip rises from the
|
||||
// reactor's avatar rather than the target speaker's.
|
||||
val agg = RoomReactionsAggregator()
|
||||
agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L)
|
||||
val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L)
|
||||
|
||||
// Two reactions on bob.
|
||||
assertEquals(setOf(bob), snap.keys)
|
||||
assertEquals(2, snap[bob]!!.size)
|
||||
assertEquals(setOf(alice, charlie), snap.keys)
|
||||
assertEquals(1, snap[alice]!!.size)
|
||||
assertEquals("🔥", snap[alice]!![0].content)
|
||||
assertEquals(1, snap[charlie]!!.size)
|
||||
assertEquals("👏", snap[charlie]!![0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,20 +93,22 @@ class RoomReactionsStateTest {
|
||||
// Fresh reaction (T=105) — inside the window.
|
||||
val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L)
|
||||
|
||||
// bob has only the fresh one left.
|
||||
assertEquals(1, snap[bob]!!.size)
|
||||
assertEquals("👏", snap[bob]!![0].content)
|
||||
// alice's reaction is evicted, charlie's stays.
|
||||
assertNull(snap[alice])
|
||||
assertEquals(1, snap[charlie]!!.size)
|
||||
assertEquals("👏", snap[charlie]!![0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregatorRoomWideReactionsKeyedByEmptyString() {
|
||||
fun aggregatorRoomWideReactionsKeyedBySender() {
|
||||
val agg = RoomReactionsAggregator()
|
||||
val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L)
|
||||
|
||||
// Room-wide reactions land under the empty-string key so the
|
||||
// map's value-type stays uniform; the UI can split them on render.
|
||||
assertTrue(snap.containsKey(""))
|
||||
assertEquals(1, snap[""]!!.size)
|
||||
// A reaction with no `p`-tag (room-wide) still groups under
|
||||
// the sender's key, so it floats from the reactor's avatar
|
||||
// exactly the same way a speaker-targeted reaction does.
|
||||
assertEquals(setOf(alice), snap.keys)
|
||||
assertEquals(1, snap[alice]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -129,6 +135,7 @@ class RoomReactionsStateTest {
|
||||
agg.apply(replay, nowSec = 100L, windowSec = 30L)
|
||||
val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L)
|
||||
|
||||
assertEquals(1, snap[bob]!!.size)
|
||||
// Sender-grouped: only one entry, under the sender (alice).
|
||||
assertEquals(1, snap[alice]!!.size)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user