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.chat.LiveActivitiesChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-room state for every wire subscription scoped to a single
|
* 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
|
* Single per-room sub-assembler. Issues two [Filter]s per outbox
|
||||||
* relay on every key update:
|
* relay on every key update:
|
||||||
*
|
*
|
||||||
* 1. `kinds=[1311, 10312, 7]`, `#a=[roomATag]` — the chat, presence
|
* 1. `kinds=[1311, 10312, 7, 9735]`, `#a=[roomATag]` — the chat,
|
||||||
* and reaction streams the room consumes. Bundling them into
|
* presence, reaction and zap-receipt streams the room consumes.
|
||||||
* one Filter is safe: a single Filter requires (kind ∈ kinds)
|
* Bundling them into one Filter is safe: a single Filter
|
||||||
* AND (every tag dimension matches), and these three streams
|
* requires (kind ∈ kinds) AND (every tag dimension matches),
|
||||||
* share the same `#a` gate.
|
* and these streams all share the same `#a` gate.
|
||||||
*
|
*
|
||||||
* 2. `kinds=[4312]`, `#a=[roomATag]`, `#p=[localPubkey]` — admin
|
* 2. `kinds=[4312]`, `#a=[roomATag]`, `#p=[localPubkey]` — admin
|
||||||
* commands. Stays separate from (1) because the additional
|
* commands. Stays separate from (1) because the additional
|
||||||
@@ -94,6 +95,7 @@ class NestRoomFilterSubAssembler(
|
|||||||
LiveActivitiesChatMessageEvent.KIND,
|
LiveActivitiesChatMessageEvent.KIND,
|
||||||
MeetingRoomPresenceEvent.KIND,
|
MeetingRoomPresenceEvent.KIND,
|
||||||
ReactionEvent.KIND,
|
ReactionEvent.KIND,
|
||||||
|
LnZapEvent.KIND,
|
||||||
),
|
),
|
||||||
tags = mapOf("a" to listOf(key.note.idHex)),
|
tags = mapOf("a" to listOf(key.note.idHex)),
|
||||||
since = since?.get(relay)?.time,
|
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.chat.LiveActivitiesChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -61,6 +62,8 @@ internal fun NestRoomEventCollectors(
|
|||||||
ChatCollector(viewModel, roomATag)
|
ChatCollector(viewModel, roomATag)
|
||||||
ReactionsCollector(viewModel, roomATag)
|
ReactionsCollector(viewModel, roomATag)
|
||||||
ReactionsEvictionTicker(viewModel)
|
ReactionsEvictionTicker(viewModel)
|
||||||
|
ZapsCollector(viewModel, roomATag)
|
||||||
|
ZapsEvictionTicker(viewModel)
|
||||||
AdminCommandsCollector(viewModel, event, roomATag, localPubkey)
|
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
|
@Composable
|
||||||
private fun ReactionsEvictionTicker(viewModel: NestViewModel) {
|
private fun ReactionsEvictionTicker(viewModel: NestViewModel) {
|
||||||
// Only tick while there are reactions to evict. The aggregator's
|
// 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.AssistChip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.FilledTonalIconToggleButton
|
import androidx.compose.material3.FilledTonalIconToggleButton
|
||||||
@@ -52,7 +53,11 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.ConnectionUiState
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
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.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.
|
* Sticky bottom action bar for the room screen.
|
||||||
@@ -98,6 +117,9 @@ internal fun NestActionBar(
|
|||||||
onHandRaisedChange: (Boolean) -> Unit,
|
onHandRaisedChange: (Boolean) -> Unit,
|
||||||
onShowReactionPicker: () -> Unit,
|
onShowReactionPicker: () -> Unit,
|
||||||
onLeave: () -> Unit,
|
onLeave: () -> Unit,
|
||||||
|
roomNote: AddressableNote,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -123,6 +145,9 @@ internal fun NestActionBar(
|
|||||||
onHandRaisedChange = onHandRaisedChange,
|
onHandRaisedChange = onHandRaisedChange,
|
||||||
onShowReactionPicker = onShowReactionPicker,
|
onShowReactionPicker = onShowReactionPicker,
|
||||||
onLeave = onLeave,
|
onLeave = onLeave,
|
||||||
|
roomNote = roomNote,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,6 +379,9 @@ private fun EndCluster(
|
|||||||
onHandRaisedChange: (Boolean) -> Unit,
|
onHandRaisedChange: (Boolean) -> Unit,
|
||||||
onShowReactionPicker: () -> Unit,
|
onShowReactionPicker: () -> Unit,
|
||||||
onLeave: () -> Unit,
|
onLeave: () -> Unit,
|
||||||
|
roomNote: AddressableNote,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -364,8 +392,13 @@ private fun EndCluster(
|
|||||||
if (!isOnStage && isConnected) {
|
if (!isOnStage && isConnected) {
|
||||||
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
|
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
|
||||||
}
|
}
|
||||||
// React works in any state — even disconnected users can react
|
// Zap and React both work in any state — even disconnected
|
||||||
// via the room note.
|
// users can zap / react via the room note.
|
||||||
|
NestZapButton(
|
||||||
|
roomNote = roomNote,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
|
)
|
||||||
FilledTonalIconButton(onClick = onShowReactionPicker) {
|
FilledTonalIconButton(onClick = onShowReactionPicker) {
|
||||||
Icon(
|
Icon(
|
||||||
symbol = MaterialSymbols.EmojiEmotions,
|
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 ────────────────────────────────────────────────
|
// ── Reusable affordances ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@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.NestUiState
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.ParticipantGrid
|
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.navigation.topbars.ShorterTopAppBar
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestChatPanel
|
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 myPubkey = accountViewModel.account.signer.pubKey
|
||||||
val leaveScope = rememberCoroutineScope()
|
val leaveScope = rememberCoroutineScope()
|
||||||
val topBarContext = LocalContext.current
|
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 presences by viewModel.presences.collectAsState()
|
||||||
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
|
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
|
||||||
|
val zapsByPubkey by viewModel.recentZaps.collectAsState()
|
||||||
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
|
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
|
||||||
val audioLevels by viewModel.audioLevels.collectAsState()
|
val audioLevels by viewModel.audioLevels.collectAsState()
|
||||||
|
|
||||||
@@ -235,6 +242,7 @@ internal fun NestFullScreen(
|
|||||||
audioLevels = audioLevels,
|
audioLevels = audioLevels,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
reactionsByPubkey = reactionsByPubkey,
|
reactionsByPubkey = reactionsByPubkey,
|
||||||
|
zapsByPubkey = zapsByPubkey,
|
||||||
connectingSpeakers = ui.connectingSpeakers,
|
connectingSpeakers = ui.connectingSpeakers,
|
||||||
onLongPressParticipant = onLongPressParticipant,
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
// Tap on a stage avatar opens the same per-participant
|
// Tap on a stage avatar opens the same per-participant
|
||||||
@@ -276,6 +284,9 @@ internal fun NestFullScreen(
|
|||||||
onLeave()
|
onLeave()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
roomNote = roomNote,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = actionBarNav,
|
||||||
)
|
)
|
||||||
NestTabRow(
|
NestTabRow(
|
||||||
tabs = tabs,
|
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.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember
|
import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
|
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.ClickableUserPicture
|
||||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
@@ -151,6 +152,7 @@ internal fun StageGrid(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioLevels: Map<String, Float> = emptyMap(),
|
audioLevels: Map<String, Float> = emptyMap(),
|
||||||
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
||||||
|
zapsByPubkey: Map<String, List<RoomZap>> = emptyMap(),
|
||||||
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
|
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
|
||||||
onLongPressParticipant: ((String) -> Unit)? = null,
|
onLongPressParticipant: ((String) -> Unit)? = null,
|
||||||
onTapParticipant: ((String) -> Unit)? = null,
|
onTapParticipant: ((String) -> Unit)? = null,
|
||||||
@@ -228,6 +230,7 @@ internal fun StageGrid(
|
|||||||
isConnecting = member.pubkey in connectingSpeakers,
|
isConnecting = member.pubkey in connectingSpeakers,
|
||||||
showMicBadge = true,
|
showMicBadge = true,
|
||||||
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
|
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
|
||||||
|
zaps = zapsByPubkey[member.pubkey].orEmpty(),
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onLongPressParticipant = onLongPressParticipant,
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
onTapParticipant = if (isSelf) null else onTapParticipant,
|
onTapParticipant = if (isSelf) null else onTapParticipant,
|
||||||
@@ -363,6 +366,7 @@ private fun MemberCell(
|
|||||||
reactions: List<RoomReaction>,
|
reactions: List<RoomReaction>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
onLongPressParticipant: ((String) -> Unit)?,
|
onLongPressParticipant: ((String) -> Unit)?,
|
||||||
|
zaps: List<RoomZap> = emptyList(),
|
||||||
isSelf: Boolean = false,
|
isSelf: Boolean = false,
|
||||||
onTapSelf: (() -> Unit)? = null,
|
onTapSelf: (() -> Unit)? = null,
|
||||||
onTapParticipant: ((String) -> Unit)? = null,
|
onTapParticipant: ((String) -> Unit)? = null,
|
||||||
@@ -525,6 +529,7 @@ private fun MemberCell(
|
|||||||
showMicBadge = showMicBadge,
|
showMicBadge = showMicBadge,
|
||||||
isSpeaking = isSpeaking,
|
isSpeaking = isSpeaking,
|
||||||
reactions = reactions,
|
reactions = reactions,
|
||||||
|
zaps = zaps,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
UsernameDisplay(
|
UsernameDisplay(
|
||||||
@@ -549,6 +554,7 @@ private fun AvatarAndBadges(
|
|||||||
showMicBadge: Boolean,
|
showMicBadge: Boolean,
|
||||||
isSpeaking: Boolean,
|
isSpeaking: Boolean,
|
||||||
reactions: List<RoomReaction>,
|
reactions: List<RoomReaction>,
|
||||||
|
zaps: List<RoomZap> = emptyList(),
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
ClickableUserPicture(
|
ClickableUserPicture(
|
||||||
@@ -604,6 +610,21 @@ private fun AvatarAndBadges(
|
|||||||
.offset(x = 6.dp, y = 6.dp),
|
.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())
|
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
|
||||||
val recentReactions: StateFlow<Map<String, List<RoomReaction>>> = _recentReactions.asStateFlow()
|
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
|
* moq-lite `catalog.json` payload per speaker pubkey. Populated
|
||||||
* lazily as the per-speaker subscriptions land — listeners
|
* lazily as the per-speaker subscriptions land — listeners
|
||||||
@@ -610,6 +621,30 @@ class NestViewModel(
|
|||||||
_recentReactions.value = reactionsAgg.evictAndSnapshot(olderThanSec)
|
_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
|
* Whether this VM was constructed with capture + encoder factories. The
|
||||||
* UI uses this to decide whether to render the talk button at all —
|
* 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