Merge remote-tracking branch 'origin/main' into claude/add-zap-button-nest-KoZG4
# Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt
This commit is contained in:
+2
-1
@@ -420,7 +420,7 @@ class RichTextParser {
|
||||
)
|
||||
|
||||
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
||||
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
|
||||
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a")
|
||||
val pdfExt = listOf("pdf")
|
||||
|
||||
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
|
||||
@@ -556,6 +556,7 @@ val mimeTypeMap: Map<String, String> =
|
||||
"wav" to "audio/wav",
|
||||
"ogg" to "audio/ogg",
|
||||
"m4a" to "audio/mp4",
|
||||
"f4a" to "audio/mp4",
|
||||
"aac" to "audio/aac",
|
||||
"flac" to "audio/flac",
|
||||
// Documents
|
||||
|
||||
+15
-1
@@ -69,6 +69,16 @@ data class ParticipantGrid(
|
||||
* AND whose latest presence advertises `onstage != false`. A
|
||||
* speaker who explicitly emitted `onstage=0` ("step off the
|
||||
* stage") drops to audience without losing their role tag.
|
||||
*
|
||||
* Caveat: a presence event is only honoured for the on-stage
|
||||
* gate when it's NEWER than the role grant ([roleGrantSec],
|
||||
* conservatively the kind-30312 `created_at`). Otherwise a
|
||||
* freshly-promoted audience member whose pre-promotion
|
||||
* `kind-10312` carried `onstage=0` would be pinned to the
|
||||
* audience tab until they re-heartbeat — which on nostrnests
|
||||
* never happens, since audience members there don't toggle
|
||||
* their `onstage` flag back on after a role bump. Stale
|
||||
* presence ⇒ trust the role.
|
||||
* * Audience: every pubkey with recent presence that isn't on
|
||||
* stage, plus participant-tagged users who haven't emitted
|
||||
* presence yet (rendered as `absent = true`).
|
||||
@@ -86,6 +96,7 @@ fun buildParticipantGrid(
|
||||
participants: List<ParticipantTag>,
|
||||
presences: Map<String, RoomPresence>,
|
||||
hostPubkey: HexKey? = null,
|
||||
roleGrantSec: Long = 0L,
|
||||
): ParticipantGrid {
|
||||
val effectiveParticipants =
|
||||
if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) {
|
||||
@@ -103,7 +114,10 @@ fun buildParticipantGrid(
|
||||
val pres = presences[p.pubKey]
|
||||
val role = p.effectiveRole()
|
||||
val canSpeak = p.canSpeak()
|
||||
val onstageFlag = pres?.onstage ?: true // default true for backwards compat
|
||||
// Stale-presence override: trust the role when the presence
|
||||
// event predates the role grant. See KDoc above for why.
|
||||
val presenceIsFresh = pres != null && pres.updatedAtSec >= roleGrantSec
|
||||
val onstageFlag = if (presenceIsFresh) pres.onstage else true
|
||||
val member =
|
||||
RoomMember(
|
||||
pubkey = p.pubKey,
|
||||
|
||||
+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. */
|
||||
|
||||
+26
-19
@@ -24,22 +24,22 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
|
||||
/**
|
||||
* One in-flight kind-9735 zap to render as a floating overlay on a
|
||||
* participant's avatar (or as a room-wide pulse). Same ephemeral
|
||||
* contract as [RoomReaction]: the aggregator drops entries older
|
||||
* than the staleness window so the overlay clears itself without
|
||||
* per-component animation bookkeeping.
|
||||
* One in-flight kind-9735 zap to render as a floating overlay on the
|
||||
* zapper's avatar. Same ephemeral contract as [RoomReaction]: the
|
||||
* aggregator drops entries older than the staleness window so the
|
||||
* overlay clears itself without per-component animation bookkeeping.
|
||||
*
|
||||
* `targetPubkey == null` means the zap targets the room itself (no
|
||||
* `p` tag on the receipt); the UI can render those room-wide.
|
||||
* `targetPubkey == null` means the receipt carried no `p` tag — the
|
||||
* zap still floats from the zapper's avatar, so the target is only
|
||||
* informational.
|
||||
*/
|
||||
@Immutable
|
||||
data class RoomZap(
|
||||
/** Zap receipt event id — used for dedup across re-emits. */
|
||||
val eventId: String,
|
||||
/** Who paid the invoice (zap recipient lnurl provider's signing key). */
|
||||
/** Who SENT the zap (the kind-9734 request author, not the LN service). */
|
||||
val sourcePubkey: String,
|
||||
/** Participant the zap is aimed at, or `null` for the room itself. */
|
||||
/** Participant the zap names via its `p` tag, or `null` if none. */
|
||||
val targetPubkey: String?,
|
||||
/** Amount in sats, or `null` if the invoice couldn't be parsed. */
|
||||
val amountSats: Long?,
|
||||
@@ -47,14 +47,16 @@ data class RoomZap(
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Project a kind-9735 [LnZapEvent] into a [RoomZap]. The target
|
||||
* pubkey is the FIRST `p` tag — the participant a zap message
|
||||
* names; an empty list means the zap is room-wide.
|
||||
* Project a kind-9735 [LnZapEvent] into a [RoomZap]. The
|
||||
* source pubkey is the embedded kind-9734 request author —
|
||||
* the actual zapper — NOT the receipt's own `pubKey`, which
|
||||
* is the lnurl service provider's signing key. Falls back to
|
||||
* the receipt issuer only when the request can't be parsed.
|
||||
*/
|
||||
fun from(event: LnZapEvent): RoomZap =
|
||||
RoomZap(
|
||||
eventId = event.id,
|
||||
sourcePubkey = event.pubKey,
|
||||
sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey,
|
||||
targetPubkey = event.zappedAuthor().firstOrNull(),
|
||||
amountSats = event.amount?.toLong(),
|
||||
createdAtSec = event.createdAt,
|
||||
@@ -65,8 +67,8 @@ data class RoomZap(
|
||||
/**
|
||||
* Sliding-window aggregator for room zaps. Mirrors
|
||||
* [RoomReactionsAggregator] — same dedup-by-event-id rule and
|
||||
* groupBy-target-pubkey output shape so the UI can use one overlay
|
||||
* component for both streams.
|
||||
* group-by-sender output shape so the UI can use one overlay
|
||||
* placement convention for both streams.
|
||||
*
|
||||
* Not thread-safe — call from the VM's single coroutine.
|
||||
*/
|
||||
@@ -75,9 +77,6 @@ class RoomZapsAggregator {
|
||||
// event id so the same receipt from two relays only counts once.
|
||||
private val byEventId = LinkedHashMap<String, RoomZap>()
|
||||
|
||||
/** Stable key for room-wide zaps in the returned map. */
|
||||
private val roomWideKey = ""
|
||||
|
||||
/** Apply one zap and return the post-evict snapshot. */
|
||||
fun apply(
|
||||
event: LnZapEvent,
|
||||
@@ -96,12 +95,20 @@ class RoomZapsAggregator {
|
||||
* — typically every second so the floating-up animation frame
|
||||
* rate is set by the eviction tick rather than by a
|
||||
* per-Composable timer.
|
||||
*
|
||||
* Zaps are grouped by [RoomZap.sourcePubkey] — the user who SENT
|
||||
* the zap — so the chip floats up from the zapper's own avatar,
|
||||
* matching how [RoomReactionsAggregator] groups reactions. In a
|
||||
* live audio room the audience expects to see who's zapping, not
|
||||
* who's being zapped; and the zapper may be an audience member,
|
||||
* so the overlay is wired into the audience grid as well as the
|
||||
* stage.
|
||||
*/
|
||||
fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomZap>> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.richtext
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class F4aAudioParserTest {
|
||||
@Test
|
||||
fun detectsF4aByExtension() {
|
||||
val url = "https://example.com/audio/track.f4a"
|
||||
val state = RichTextParser().parseText(url, EmptyTagList, null)
|
||||
|
||||
val media = state.mediaForPager[url]
|
||||
assertTrue(media is MediaUrlVideo, "Expected MediaUrlVideo for .f4a URL")
|
||||
|
||||
val segment = state.paragraphs[0].words[0]
|
||||
assertTrue(segment is VideoSegment, "Expected VideoSegment for .f4a URL, got ${segment::class.simpleName}")
|
||||
assertEquals(url, segment.segmentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isVideoUrlHelperMatchesF4aExtension() {
|
||||
assertTrue(RichTextParser.isVideoUrl("https://example.com/track.f4a"))
|
||||
assertTrue(RichTextParser.isVideoUrl("https://example.com/track.F4A"))
|
||||
assertTrue(RichTextParser.isVideoUrl("https://example.com/track.f4a?sig=abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun f4aMapsToMp4AudioMimeType() {
|
||||
assertEquals("audio/mp4", mimeTypeMap["f4a"])
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -313,7 +313,7 @@ class NestViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onReactionEventGroupsByTargetAndEvictsOnTick() =
|
||||
fun onReactionEventGroupsBySenderAndEvictsOnTick() =
|
||||
runVmTest {
|
||||
val vm = newViewModel { FakeNestsListener() }
|
||||
val alice = "a".repeat(64)
|
||||
@@ -339,10 +339,13 @@ class NestViewModelTest {
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
// Two reactions land within the 30-s window.
|
||||
// Two reactions sent BY alice land within the 30-s window.
|
||||
// The aggregator groups by sender (so the floating chip
|
||||
// rises from the reactor's avatar), NOT by the `p`-tag
|
||||
// target — both land under alice's key.
|
||||
vm.onReactionEvent(rxn(alice, bob, "🔥", 100L), nowSec = 100L)
|
||||
vm.onReactionEvent(rxn(alice, bob, "👏", 105L), nowSec = 105L)
|
||||
assertEquals(2, vm.recentReactions.value[bob]!!.size)
|
||||
assertEquals(2, vm.recentReactions.value[alice]!!.size)
|
||||
|
||||
// LocalCache.observeEvents re-emits the full matching list
|
||||
// on every cache mutation; the same kind-7 must collapse
|
||||
@@ -350,7 +353,7 @@ class NestViewModelTest {
|
||||
val replayed = rxn(alice, bob, "🔥", 100L, id = "f".repeat(64))
|
||||
vm.onReactionEvent(replayed, nowSec = 105L)
|
||||
vm.onReactionEvent(replayed, nowSec = 105L)
|
||||
assertEquals(3, vm.recentReactions.value[bob]!!.size)
|
||||
assertEquals(3, vm.recentReactions.value[alice]!!.size)
|
||||
|
||||
// Tick advances past the window — all reactions evicted.
|
||||
vm.evictReactions(olderThanSec = 200L)
|
||||
|
||||
+44
@@ -168,6 +168,50 @@ class ParticipantGridTest {
|
||||
assertEquals(ROLE.HOST, hostRow?.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stalePresenceWithOnstageFalseIsIgnoredAfterRoleGrant() {
|
||||
// Audience-promoted-to-speaker scenario: presence event was
|
||||
// emitted at t=100 with onstage=false (their pre-promotion
|
||||
// audience-mode kind-10312). The host then promotes them at
|
||||
// t=200 (the room's new createdAt). Without the staleness
|
||||
// gate, this lands the speaker in the audience tab even
|
||||
// though their role just changed — the bug reported on
|
||||
// 2026-05-13. With roleGrantSec=200, the t=100 presence is
|
||||
// stale and the role tag wins → speaker on stage.
|
||||
val grid =
|
||||
buildParticipantGrid(
|
||||
participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)),
|
||||
presences =
|
||||
mapOf(
|
||||
host to presence(host, 200L),
|
||||
speaker to presence(speaker, 100L, onstage = false),
|
||||
),
|
||||
roleGrantSec = 200L,
|
||||
)
|
||||
assertEquals(setOf(host, speaker), grid.onStage.map { it.pubkey }.toSet())
|
||||
assertEquals(0, grid.audience.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshOnstageFalseAfterRoleGrantStillDropsToAudience() {
|
||||
// Inverse: speaker emits onstage=0 AFTER the role grant
|
||||
// (deliberate "stepped off stage" while keeping the role).
|
||||
// Should still drop to audience — the existing leave-stage
|
||||
// contract.
|
||||
val grid =
|
||||
buildParticipantGrid(
|
||||
participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)),
|
||||
presences =
|
||||
mapOf(
|
||||
host to presence(host, 200L),
|
||||
speaker to presence(speaker, 300L, onstage = false),
|
||||
),
|
||||
roleGrantSec = 200L,
|
||||
)
|
||||
assertEquals(setOf(host), grid.onStage.map { it.pubkey }.toSet())
|
||||
assertEquals(setOf(speaker), grid.audience.map { it.pubkey }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitHostInPTagsIsNotDuplicated() {
|
||||
// The author already tagged themselves with role=host. The
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-27
@@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RoomZapsStateTest {
|
||||
private val alice = "a".repeat(64)
|
||||
@@ -34,11 +33,13 @@ class RoomZapsStateTest {
|
||||
private var nextEventId = 1
|
||||
|
||||
/**
|
||||
* Builds a kind-9735 zap receipt tagged for a room (`a`) and,
|
||||
* optionally, a target participant (`p`). No `bolt11` tag is
|
||||
* attached, so [LnZapEvent.amount] resolves to null — the
|
||||
* aggregator's grouping / eviction / dedup logic does not depend
|
||||
* on the amount, so that keeps the fixtures invoice-free.
|
||||
* Builds a kind-9735 zap receipt with no embedded `description`
|
||||
* request, so [RoomZap.from] falls back to the receipt's own
|
||||
* `pubKey` as the sender — i.e. [from] is the grouping key. A
|
||||
* `p` tag for [to] is attached when non-null (informational
|
||||
* `targetPubkey`); no `bolt11` tag, so the amount resolves to
|
||||
* null. The aggregator's grouping / eviction / dedup logic does
|
||||
* not depend on the amount, which keeps the fixtures invoice-free.
|
||||
*/
|
||||
private fun zap(
|
||||
from: String,
|
||||
@@ -62,7 +63,7 @@ class RoomZapsStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromEventGroupsByTargetPubkey() {
|
||||
fun fromEventUsesSenderAndTarget() {
|
||||
val zap = RoomZap.from(zap(alice, bob, 100L))
|
||||
assertEquals(alice, zap.sourcePubkey)
|
||||
assertEquals(bob, zap.targetPubkey)
|
||||
@@ -74,17 +75,21 @@ class RoomZapsStateTest {
|
||||
fun fromEventNullTargetWhenNoPTag() {
|
||||
val zap = RoomZap.from(zap(alice, null, 100L))
|
||||
assertNull(zap.targetPubkey)
|
||||
// Source still resolves even without a `p` tag — the zap
|
||||
// floats from the zapper's avatar regardless of target.
|
||||
assertEquals(alice, zap.sourcePubkey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregatorGroupsByParticipant() {
|
||||
fun aggregatorGroupsBySender() {
|
||||
val agg = RoomZapsAggregator()
|
||||
// Two zaps from alice, aimed at different participants — both
|
||||
// float from alice's avatar, so both land under alice's key.
|
||||
agg.apply(zap(alice, bob, 100L), nowSec = 100L, windowSec = 30L)
|
||||
val snap = agg.apply(zap(charlie, bob, 100L), nowSec = 100L, windowSec = 30L)
|
||||
val snap = agg.apply(zap(alice, charlie, 100L), nowSec = 100L, windowSec = 30L)
|
||||
|
||||
// Two zaps on bob.
|
||||
assertEquals(setOf(bob), snap.keys)
|
||||
assertEquals(2, snap[bob]!!.size)
|
||||
assertEquals(setOf(alice), snap.keys)
|
||||
assertEquals(2, snap[alice]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -93,25 +98,14 @@ class RoomZapsStateTest {
|
||||
// Old zap (T=70) — outside the window when now=110, windowSec=30.
|
||||
agg.apply(zap(alice, bob, 70L), nowSec = 70L, windowSec = 30L)
|
||||
// Fresh zap (T=105) — inside the window.
|
||||
val snap = agg.apply(zap(charlie, bob, 105L), nowSec = 110L, windowSec = 30L)
|
||||
val snap = agg.apply(zap(bob, charlie, 105L), nowSec = 110L, windowSec = 30L)
|
||||
|
||||
// bob has only the fresh one left.
|
||||
// alice's stale zap is gone; only bob's fresh one remains.
|
||||
assertNull(snap[alice])
|
||||
assertEquals(1, snap[bob]!!.size)
|
||||
assertEquals(105L, snap[bob]!![0].createdAtSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregatorRoomWideZapsKeyedByEmptyString() {
|
||||
val agg = RoomZapsAggregator()
|
||||
val snap = agg.apply(zap(alice, null, 100L), nowSec = 100L, windowSec = 30L)
|
||||
|
||||
// Room-wide zaps (no `p` tag) 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun evictAndSnapshotIsIdempotentWhenNothingChanged() {
|
||||
val agg = RoomZapsAggregator()
|
||||
@@ -136,6 +130,6 @@ class RoomZapsStateTest {
|
||||
agg.apply(replay, nowSec = 100L, windowSec = 30L)
|
||||
val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L)
|
||||
|
||||
assertEquals(1, snap[bob]!!.size)
|
||||
assertEquals(1, snap[alice]!!.size)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user