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:
+7
-5
@@ -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,
|
||||
|
||||
+54
@@ -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
|
||||
|
||||
+195
-2
@@ -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
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+21
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+144
@@ -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
|
||||
+35
@@ -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 —
|
||||
|
||||
+110
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user