From 6425fcd65130538dbeb917343bc489b4db9b4912 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 04:43:25 +0000 Subject: [PATCH] feat(nests): add zap button to the full-screen action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../datasource/NestRoomFilterAssembler.kt | 12 +- .../room/lifecycle/NestRoomEventCollectors.kt | 54 +++++ .../nests/room/screen/NestActionBar.kt | 197 +++++++++++++++++- .../nests/room/screen/NestFullScreen.kt | 11 + .../nests/room/stage/ParticipantsGrid.kt | 21 ++ .../nests/room/stage/SpeakerZapOverlay.kt | 144 +++++++++++++ .../commons/viewmodels/NestViewModel.kt | 35 ++++ .../commons/viewmodels/RoomZapsState.kt | 110 ++++++++++ 8 files changed, 577 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index 5d2ba8643..74432b846 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index 5e67573ea..4ac548b7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index ba3e5ff68..35327c16f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 464b5ee92..1dcf4431a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index f35bc41f9..33e87656f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -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 = emptyMap(), reactionsByPubkey: Map> = emptyMap(), + zapsByPubkey: Map> = emptyMap(), connectingSpeakers: ImmutableSet = 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, accountViewModel: AccountViewModel, onLongPressParticipant: ((String) -> Unit)?, + zaps: List = 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, + zaps: List = 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), + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt new file mode 100644 index 000000000..7e5332684 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt @@ -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, + 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 33a5f1dbe..52bfb1c38 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -174,6 +174,17 @@ class NestViewModel( private val _recentReactions = MutableStateFlow>>(emptyMap()) val recentReactions: StateFlow>> = _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>>(emptyMap()) + val recentZaps: StateFlow>> = _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 — diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt new file mode 100644 index 000000000..164d4759b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -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() + + /** 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> { + 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> { + 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() +}