feat(nests): add zap button to the full-screen action bar

Subscribes to kind-9735 zap receipts tagged with the room's a-pointer,
feeds them into the nest chat ledger (so RenderChatZap surfaces them
the same way live streams do) and into a sliding-window aggregator
that drives a floating  chip over the targeted participant's avatar
— mirrors the existing reaction overlay's animation cadence so both
streams visually feel like one system.

The button itself wraps NoteCompose's zapClick / ZapAmountChoicePopup
/ ZapCustomDialog defaults against the room's AddressableNote, so the
amount-choice popup, custom-amount dialog and multi-payable routing
all behave like the standard note  button.

https://claude.ai/code/session_01EW11kUdiEYPuPti7vtD2AR
This commit is contained in:
Claude
2026-05-14 04:43:25 +00:00
parent fb068a13c6
commit 6425fcd651
8 changed files with 577 additions and 7 deletions
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
/**
* Per-room state for every wire subscription scoped to a single
@@ -56,11 +57,11 @@ class NestRoomQueryState(
* Single per-room sub-assembler. Issues two [Filter]s per outbox
* relay on every key update:
*
* 1. `kinds=[1311, 10312, 7]`, `#a=[roomATag]` — the chat, presence
* and reaction streams the room consumes. Bundling them into
* one Filter is safe: a single Filter requires (kind ∈ kinds)
* AND (every tag dimension matches), and these three streams
* share the same `#a` gate.
* 1. `kinds=[1311, 10312, 7, 9735]`, `#a=[roomATag]` — the chat,
* presence, reaction and zap-receipt streams the room consumes.
* Bundling them into one Filter is safe: a single Filter
* requires (kind ∈ kinds) AND (every tag dimension matches),
* and these streams all share the same `#a` gate.
*
* 2. `kinds=[4312]`, `#a=[roomATag]`, `#p=[localPubkey]` — admin
* commands. Stays separate from (1) because the additional
@@ -94,6 +95,7 @@ class NestRoomFilterSubAssembler(
LiveActivitiesChatMessageEvent.KIND,
MeetingRoomPresenceEvent.KIND,
ReactionEvent.KIND,
LnZapEvent.KIND,
),
tags = mapOf("a" to listOf(key.note.idHex)),
since = since?.get(relay)?.time,
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -61,6 +62,8 @@ internal fun NestRoomEventCollectors(
ChatCollector(viewModel, roomATag)
ReactionsCollector(viewModel, roomATag)
ReactionsEvictionTicker(viewModel)
ZapsCollector(viewModel, roomATag)
ZapsEvictionTicker(viewModel)
AdminCommandsCollector(viewModel, event, roomATag, localPubkey)
}
@@ -144,6 +147,57 @@ private fun ReactionsCollector(
}
}
/**
* Pump kind-9735 zap receipts into the VM's chat ledger AND the
* floating zap-overlay aggregator. The same note flows two places:
*
* 1. [NestViewModel.onChatEvent] — `ChatroomMessageCompose` routes
* `LnZapEvent` notes through `RenderChatZap`, which is the same
* card live streams use. Sharing the chat ledger keeps zap
* cards interleaved with kind-1311 chat messages in time order.
*
* 2. [NestViewModel.onZapEvent] — same sliding-window pattern as
* reactions; drives the floating "⚡ Nsats" chip over the
* targeted participant's avatar (or the room itself).
*/
@Composable
private fun ZapsCollector(
viewModel: NestViewModel,
roomATag: String,
) {
LaunchedEffect(viewModel, roomATag) {
val filter =
Filter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("a" to listOf(roomATag)),
)
LocalCache.observeNotes(filter).collect { notes ->
val nowSec = System.currentTimeMillis() / 1000
notes.forEach { note ->
viewModel.onChatEvent(note)
(note.event as? LnZapEvent)?.let { viewModel.onZapEvent(it, nowSec) }
}
}
}
}
@Composable
private fun ZapsEvictionTicker(viewModel: NestViewModel) {
// Mirrors [ReactionsEvictionTicker] — only tick while there are
// zaps to evict. The aggregator's last eviction empties
// [NestViewModel.recentZaps], which flips [hasZaps] to false and
// cancels the loop until the next zap arrives.
val zaps by viewModel.recentZaps.collectAsState()
val hasZaps = zaps.values.any { it.isNotEmpty() }
LaunchedEffect(viewModel, hasZaps) {
if (!hasZaps) return@LaunchedEffect
while (isActive) {
delay(REACTIONS_TICK_MS)
viewModel.evictZaps(System.currentTimeMillis() / 1000 - REACTION_WINDOW_SEC_LOCAL)
}
}
}
@Composable
private fun ReactionsEvictionTicker(viewModel: NestViewModel) {
// Only tick while there are reactions to evict. The aggregator's
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
@@ -52,7 +53,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -67,7 +72,21 @@ import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
import com.vitorpamplona.amethyst.ui.note.ZapCustomDialog
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.note.zapClick
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Sticky bottom action bar for the room screen.
@@ -98,6 +117,9 @@ internal fun NestActionBar(
onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
onLeave: () -> Unit,
roomNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
@@ -123,6 +145,9 @@ internal fun NestActionBar(
onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = onShowReactionPicker,
onLeave = onLeave,
roomNote = roomNote,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -354,6 +379,9 @@ private fun EndCluster(
onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
onLeave: () -> Unit,
roomNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -364,8 +392,13 @@ private fun EndCluster(
if (!isOnStage && isConnected) {
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
}
// React works in any state — even disconnected users can react
// via the room note.
// Zap and React both work in any state — even disconnected
// users can zap / react via the room note.
NestZapButton(
roomNote = roomNote,
accountViewModel = accountViewModel,
nav = nav,
)
FilledTonalIconButton(onClick = onShowReactionPicker) {
Icon(
symbol = MaterialSymbols.EmojiEmotions,
@@ -377,6 +410,166 @@ private fun EndCluster(
}
}
/**
* Round zap button styled to match [HandRaiseToggle] / the React
* button — same `FilledTonalIconButton` shape and tonal palette, but
* tinted with [BitcoinOrange] while a zap is in flight so the user
* sees the progress without needing a separate amount label.
*
* Click behavior is delegated to NoteCompose's [zapClick], which
* applies the user's configured zap amount choices the same way the
* normal note ⚡ button does (single-tap fires the default amount;
* multi-choice opens [ZapAmountChoicePopup]; an unconfigured account
* opens [ZapCustomDialog]). Long-press routes to the
* [Route.UpdateZapAmount] settings screen via the activity's
* [BouncingIntentNav] (no-op when the route can't be expressed as a
* `nostr:` URI — same fallback as the chat panel uses).
*/
@OptIn(ExperimentalUuidApi::class)
@Composable
private fun NestZapButton(
roomNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var wantsToZap by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
val animatedProgress = zappingProgress
val isZapping = animatedProgress > 0.00001f && animatedProgress < 0.99999f
FilledTonalIconButton(
onClick = {
scope.launch {
zapClick(
baseNote = roomNote,
accountViewModel = accountViewModel,
context = context,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZappingProgress = { progress -> scope.launch { zappingProgress = progress } },
onMultipleChoices = { scope.launch { wantsToZap = true } },
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(error, payable.info.user),
)
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
onCustomAmount = { wantsToSetCustomZap = true },
)
}
},
) {
if (isZapping) {
CircularProgressIndicator(
progress = { animatedProgress },
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = stringRes(R.string.zap_description),
)
}
}
if (wantsToZap) {
ZapAmountChoicePopup(
baseNote = roomNote,
popupYOffset = 48.dp,
accountViewModel = accountViewModel,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onDismiss = {
wantsToZap = false
zappingProgress = 0f
},
onChangeAmount = {
scope.launch {
wantsToZap = false
nav.nav(Route.UpdateZapAmount())
}
},
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(error, payable.info.user),
)
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
)
}
if (wantsToSetCustomZap) {
ZapCustomDialog(
onZapStarts = { zapStartingTime = TimeUtils.now() },
onClose = { wantsToSetCustomZap = false },
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(error, payable.info.user),
)
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
accountViewModel = accountViewModel,
baseNote = roomNote,
)
}
}
// ── Reusable affordances ────────────────────────────────────────────────
@Composable
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.ParticipantGrid
import com.vitorpamplona.amethyst.ui.navigation.navs.BouncingIntentNav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestChatPanel
@@ -136,9 +137,15 @@ internal fun NestFullScreen(
val myPubkey = accountViewModel.account.signer.pubKey
val leaveScope = rememberCoroutineScope()
val topBarContext = LocalContext.current
// BouncingIntentNav handles the few routes the zap button can reach
// (UpdateZapAmount, ManualZapSplitPayment) by bouncing through
// MainActivity — same mechanism the NestChatPanel uses for tap-on-
// mention nav. Routes that don't map to a `nostr:` URI are no-ops.
val actionBarNav = remember(topBarContext, leaveScope) { BouncingIntentNav(topBarContext, leaveScope) }
val presences by viewModel.presences.collectAsState()
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
val zapsByPubkey by viewModel.recentZaps.collectAsState()
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
val audioLevels by viewModel.audioLevels.collectAsState()
@@ -235,6 +242,7 @@ internal fun NestFullScreen(
audioLevels = audioLevels,
accountViewModel = accountViewModel,
reactionsByPubkey = reactionsByPubkey,
zapsByPubkey = zapsByPubkey,
connectingSpeakers = ui.connectingSpeakers,
onLongPressParticipant = onLongPressParticipant,
// Tap on a stage avatar opens the same per-participant
@@ -276,6 +284,9 @@ internal fun NestFullScreen(
onLeave()
}
},
roomNote = roomNote,
accountViewModel = accountViewModel,
nav = actionBarNav,
)
NestTabRow(
tabs = tabs,
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -151,6 +152,7 @@ internal fun StageGrid(
modifier: Modifier = Modifier,
audioLevels: Map<String, Float> = emptyMap(),
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
zapsByPubkey: Map<String, List<RoomZap>> = emptyMap(),
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
onLongPressParticipant: ((String) -> Unit)? = null,
onTapParticipant: ((String) -> Unit)? = null,
@@ -228,6 +230,7 @@ internal fun StageGrid(
isConnecting = member.pubkey in connectingSpeakers,
showMicBadge = true,
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
zaps = zapsByPubkey[member.pubkey].orEmpty(),
accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant,
onTapParticipant = if (isSelf) null else onTapParticipant,
@@ -363,6 +366,7 @@ private fun MemberCell(
reactions: List<RoomReaction>,
accountViewModel: AccountViewModel,
onLongPressParticipant: ((String) -> Unit)?,
zaps: List<RoomZap> = emptyList(),
isSelf: Boolean = false,
onTapSelf: (() -> Unit)? = null,
onTapParticipant: ((String) -> Unit)? = null,
@@ -525,6 +529,7 @@ private fun MemberCell(
showMicBadge = showMicBadge,
isSpeaking = isSpeaking,
reactions = reactions,
zaps = zaps,
)
}
UsernameDisplay(
@@ -549,6 +554,7 @@ private fun AvatarAndBadges(
showMicBadge: Boolean,
isSpeaking: Boolean,
reactions: List<RoomReaction>,
zaps: List<RoomZap> = emptyList(),
) {
Box(contentAlignment = Alignment.Center) {
ClickableUserPicture(
@@ -604,6 +610,21 @@ private fun AvatarAndBadges(
.offset(x = 6.dp, y = 6.dp),
)
}
// Zaps float over the avatar's bottom-left corner so they don't
// collide with the reaction overlay (BottomEnd), the mic badge
// (BottomCenter), the role badge (TopStart) or the hand-raise
// badge (TopEnd). Same sliding-window cadence as reactions —
// both fade up + out over [REACTION_WINDOW_SEC] so the two
// streams animate in lockstep.
if (zaps.isNotEmpty()) {
SpeakerZapOverlay(
zaps = zaps,
modifier =
Modifier
.align(Alignment.BottomStart)
.offset(x = -6.dp, y = 6.dp),
)
}
}
}
@@ -0,0 +1,144 @@
/*
* 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.ui.screen.loggedIn.nests.room.stage
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC
import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.delay
/**
* Floating zap-chip overlay drawn over a participant's avatar — the
* zap counterpart to [SpeakerReactionOverlay]. Each chip is keyed by
* the zap event id; consecutive zaps to the same target stack into a
* row, with the chip life-cycle (`fadeIn + scaleIn` on arrival,
* upward drift + `fadeOut` over [REACTION_WINDOW_SEC]) matching
* reactions so both streams visually feel like the same animation.
*
* Renders an "⚡ Nsats" pill in [BitcoinOrange] so zaps are distinct
* from emoji reactions at a glance.
*/
@Composable
internal fun SpeakerZapOverlay(
zaps: List<RoomZap>,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = zaps.isNotEmpty(),
enter = fadeIn() + scaleIn(initialScale = 0.6f),
exit = fadeOut() + scaleOut(targetScale = 0.6f),
modifier = modifier,
) {
// Stable ordering by event id keeps the chip row from shuffling
// each tick — reactions sort by createdAt but zaps generally
// arrive one-at-a-time, so id order is good enough.
val ordered = zaps.sortedByDescending { it.createdAtSec }
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
ordered.take(MAX_VISIBLE_ZAPS).forEach { zap ->
ZapChip(zap = zap)
}
}
}
}
@Composable
private fun ZapChip(zap: RoomZap) {
// Mirror of [SpeakerReactionOverlay.ReactionChip]: tick a [progress]
// state from 0f → 1f over the eviction window so the chip can drift
// + fade in lockstep with how close it is to being evicted. Re-key
// on the event id so the animation restarts whenever a fresh zap
// replaces the value at this slot.
var progress by remember(zap.eventId) { mutableStateOf(0f) }
LaunchedEffect(zap.eventId) {
val ageMs = (System.currentTimeMillis() / 1000L - zap.createdAtSec).coerceAtLeast(0L) * 1000L
val remaining = (ZAP_WINDOW_MS - ageMs).coerceAtLeast(0L)
progress = (ageMs.toFloat() / ZAP_WINDOW_MS).coerceIn(0f, 1f)
if (remaining <= 0L) return@LaunchedEffect
val steps = (remaining / 100L).coerceAtLeast(1L)
repeat(steps.toInt()) {
delay(100L)
progress =
((System.currentTimeMillis() / 1000L - zap.createdAtSec).toFloat() * 1000f / ZAP_WINDOW_MS)
.coerceIn(0f, 1f)
}
progress = 1f
}
val driftDp = (-16f * progress).dp
val animatedAlpha by animateFloatAsState(
targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f),
animationSpec = tween(durationMillis = 100, easing = LinearEasing),
label = "zap-chip-alpha",
)
Surface(
modifier =
Modifier
.offset(y = driftDp)
.alpha(animatedAlpha),
shape = MaterialTheme.shapes.small,
color = BitcoinOrange.copy(alpha = 0.95f),
contentColor = MaterialTheme.colorScheme.onPrimary,
tonalElevation = 2.dp,
shadowElevation = 1.dp,
) {
val amountText =
zap.amountSats?.let { showAmountInteger(it.toBigDecimal()) } ?: ""
val label = if (amountText.isNotEmpty()) "$amountText" else ""
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
private const val ZAP_WINDOW_MS = REACTION_WINDOW_SEC * 1000L
// Cap the row at a small number so a burst of zaps to the same
// participant doesn't overflow the avatar's bottom-right corner —
// the eviction sweep clears them within the window anyway.
private const val MAX_VISIBLE_ZAPS = 3
@@ -174,6 +174,17 @@ class NestViewModel(
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
val recentReactions: StateFlow<Map<String, List<RoomReaction>>> = _recentReactions.asStateFlow()
/**
* Recent kind-9735 zap receipts for the floating zap-avatar overlay.
* Same shape as [recentReactions] so the UI can reuse the same
* floating-chip component keyed by target pubkey, room-wide
* zaps under the empty-string key. Sliding [REACTION_WINDOW_SEC]
* window driven by the platform layer's eviction tick.
*/
private val zapsAgg = RoomZapsAggregator()
private val _recentZaps = MutableStateFlow<Map<String, List<RoomZap>>>(emptyMap())
val recentZaps: StateFlow<Map<String, List<RoomZap>>> = _recentZaps.asStateFlow()
/**
* moq-lite `catalog.json` payload per speaker pubkey. Populated
* lazily as the per-speaker subscriptions land listeners
@@ -610,6 +621,30 @@ class NestViewModel(
_recentReactions.value = reactionsAgg.evictAndSnapshot(olderThanSec)
}
/**
* Apply one kind-9735 zap receipt to the sliding-window aggregator.
* Mirror of [onReactionEvent] the floating zap chip uses the
* same window so the visual cadence matches the React button.
*/
fun onZapEvent(
event: com.vitorpamplona.quartz.nip57Zaps.LnZapEvent,
nowSec: Long,
windowSec: Long = REACTION_WINDOW_SEC,
) {
if (closed) return
_recentZaps.value = zapsAgg.apply(event, nowSec, windowSec)
}
/**
* Drop zaps older than the staleness threshold. Platform layer
* drives this on the same 1-s tick that evicts reactions so both
* floating overlays share a single timer.
*/
fun evictZaps(olderThanSec: Long) {
if (closed) return
_recentZaps.value = zapsAgg.evictAndSnapshot(olderThanSec)
}
/**
* Whether this VM was constructed with capture + encoder factories. The
* UI uses this to decide whether to render the talk button at all
@@ -0,0 +1,110 @@
/*
* 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.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.
*
* `targetPubkey == null` means the zap targets the room itself (no
* `p` tag on the receipt); the UI can render those room-wide.
*/
@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). */
val sourcePubkey: String,
/** Participant the zap is aimed at, or `null` for the room itself. */
val targetPubkey: String?,
/** Amount in sats, or `null` if the invoice couldn't be parsed. */
val amountSats: Long?,
val createdAtSec: Long,
) {
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.
*/
fun from(event: LnZapEvent): RoomZap =
RoomZap(
eventId = event.id,
sourcePubkey = event.pubKey,
targetPubkey = event.zappedAuthor().firstOrNull(),
amountSats = event.amount?.toLong(),
createdAtSec = event.createdAt,
)
}
}
/**
* 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.
*
* Not thread-safe call from the VM's single coroutine.
*/
class RoomZapsAggregator {
// Insertion-ordered so groupBy preserves arrival order; keyed by
// 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,
nowSec: Long,
windowSec: Long,
): Map<String, List<RoomZap>> {
val incoming = RoomZap.from(event)
// Dedup: a relay re-delivery (or LocalCache.observeNotes's
// full-list re-emit) of the same receipt must not stack.
byEventId[incoming.eventId] = incoming
return evictAndSnapshot(nowSec - windowSec)
}
/**
* Drop zaps 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<RoomZap>> {
val it = byEventId.entries.iterator()
while (it.hasNext()) {
if (it.next().value.createdAtSec < olderThanSec) it.remove()
}
return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey }
}
/** Whether the aggregator currently holds any unevicted zaps. */
fun isEmpty(): Boolean = byEventId.isEmpty()
}