feat(audio-rooms): RoomReaction + sliding-window aggregator
Data + dedup logic for the speaker-avatar reaction overlay (T1 #3): RoomReaction — one ephemeral kind-7 reaction. Carries the source pubkey, the target pubkey (null for room-wide), the emoji/content, and the createdAt second. Standard data-class equality so the StateFlow can suppress no-op tick re-emits via map.equals. RoomReactionsAggregator — `apply(event, nowSec, windowSec)` records a fresh reaction and returns the post-evict Map<targetPubkey, List<RoomReaction>>. `evictAndSnapshot(olderThanSec)` is the per-tick sweep — caller drives the cadence (typically every second so the floating-up animation frame rate is set by the eviction tick rather than per-component timers). Room-wide reactions (no `p` tag) land under the empty-string key so the value-type stays uniform. Tests cover: * Tag projection (with + without `p` target) * Multi-source grouping by target speaker * Window-edge eviction (a reaction at T=70 is gone at now=110, window=30; a reaction at T=105 stays) * Empty-string key for room-wide reactions * Idempotent eviction snapshot — same input twice produces equal Maps so the UI doesn't recompose on no-op ticks Zap reactions (kind 9735) carry an amount + zapper that aren't in the v1 RoomReaction shape; that's a follow-up if/when the UI grows a "satoshi rain" treatment. The ledger stays generic on `content` so adding it later is additive.
This commit is contained in:
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.commons.viewmodels
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
|
||||
/**
|
||||
* One in-flight reaction to render as a floating overlay on a
|
||||
* speaker's avatar. Ephemeral by design — the aggregator drops
|
||||
* entries older than the staleness window so the overlay clears
|
||||
* itself without per-component animation bookkeeping.
|
||||
*
|
||||
* `targetPubkey == null` means the reaction targets the room itself
|
||||
* (no `p` tag in the event); the UI can render those as a
|
||||
* room-wide pulse instead of attaching to a single avatar.
|
||||
*/
|
||||
@Immutable
|
||||
data class RoomReaction(
|
||||
/** Who reacted. */
|
||||
val sourcePubkey: String,
|
||||
/** Speaker the reaction is aimed at, or `null` for room-wide. */
|
||||
val targetPubkey: String?,
|
||||
/** Emoji / "+" / shortcode body. */
|
||||
val content: String,
|
||||
val createdAtSec: Long,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Project a kind-7 [ReactionEvent] into a [RoomReaction]. The
|
||||
* target pubkey is the FIRST `p` tag — nostrnests reactions
|
||||
* carry one when the user picked a speaker; an empty list
|
||||
* means a room-wide reaction.
|
||||
*/
|
||||
fun from(event: ReactionEvent): RoomReaction =
|
||||
RoomReaction(
|
||||
sourcePubkey = event.pubKey,
|
||||
targetPubkey = event.originalAuthor().firstOrNull(),
|
||||
content = event.content,
|
||||
createdAtSec = event.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding-window aggregator. Holds reactions keyed by target pubkey
|
||||
* (with `null` lumped under the empty-string key so the map's
|
||||
* value-type is uniform); the room screen reads
|
||||
* `byTarget()` per render and the UI naturally fades as the window
|
||||
* slides.
|
||||
*
|
||||
* Not thread-safe — call from the VM's single coroutine.
|
||||
*/
|
||||
class RoomReactionsAggregator {
|
||||
private val all = mutableListOf<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,
|
||||
nowSec: Long,
|
||||
windowSec: Long,
|
||||
): Map<String, List<RoomReaction>> {
|
||||
all += RoomReaction.from(event)
|
||||
return evictAndSnapshot(nowSec - windowSec)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop reactions older than [olderThanSec]. Caller drives the
|
||||
* cadence (typically every second so the floating-up animation
|
||||
* frame rate is set by the eviction tick rather than by a
|
||||
* per-Composable timer).
|
||||
*/
|
||||
fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> {
|
||||
all.removeAll { it.createdAtSec < olderThanSec }
|
||||
return all.groupBy { it.targetPubkey ?: roomWideKey }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user