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:
Claude
2026-05-14 13:08:13 +00:00
22 changed files with 833 additions and 418 deletions
@@ -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
@@ -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,
@@ -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,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 }
}
}
@@ -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"])
}
}
@@ -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)
@@ -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
@@ -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)
}
}
@@ -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)
}
}