From 003603fd81aea2505d63e9c48bcf8a4f5f22bc61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:09:10 +0000 Subject: [PATCH] feat(live-chat): top zappers leaderboard above live stream chat Add a horizontally-scrollable strip of pill chips showing the top zappers on a live stream, placed above the goal header. Each chip has an avatar, lightning icon, and compact sat total (K/M), matching zap.stream's TopZappers look. Tapping a chip opens the zapper's profile. Aggregates zaps from two sources and dedupes by receipt id so a zap that tags both #a= and #e= doesn't double-count: - stream-scoped host-directed zaps already routed into the LiveActivitiesChannel cache via the existing #a subscription - goal-scoped zaps reachable through goalNote.zaps when a NIP-75 goal is attached Sorts contributors by total sats desc, takes top 10, hides the row when there are none. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../nip53LiveActivities/ChannelView.kt | 2 + .../header/LiveStreamTopZappers.kt | 208 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt index 1e210886c..344403048 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatro import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -128,6 +129,7 @@ fun LiveActivityChannelView( .weight(1f, true), ) { ShowVideoStreaming(channel, accountViewModel) + LiveStreamTopZappers(channel, accountViewModel, nav) LiveStreamGoalHeader(channel, accountViewModel, nav) RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt new file mode 100644 index 000000000..1437b5312 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -0,0 +1,208 @@ +/* + * 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.chats.publicChannels.nip53LiveActivities.header + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import java.math.BigDecimal + +private const val TOP_ZAPPERS_LIMIT = 10 + +private data class TopZapperEntry( + val zapperPubKey: HexKey, + val totalSats: BigDecimal, +) + +/** + * Horizontally-scrollable leaderboard of the top zappers on a live stream. + * Aggregates zaps from both the stream's #a subscription and the attached + * NIP-75 zap goal (if any), de-duplicating by zap-receipt id. Matches + * zap.stream's TopZappers UX: pill chips with avatar + lightning + sats. + */ +@Composable +fun LiveStreamTopZappers( + channel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val goalId = channel.info?.goalEventId() + + if (goalId != null) { + var goalNote by remember(goalId) { mutableStateOf(accountViewModel.getNoteIfExists(goalId)) } + if (goalNote == null) { + LaunchedEffect(goalId) { + goalNote = accountViewModel.checkGetOrCreateNote(goalId) + } + } + TopZappersStrip(channel, goalNote, accountViewModel, nav) + } else { + TopZappersStrip(channel, null, accountViewModel, nav) + } +} + +@Composable +private fun TopZappersStrip( + channel: LiveActivitiesChannel, + goalNote: Note?, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Trigger recomposition whenever a new zap lands in the channel cache. + val channelTick by channel.changesFlow().collectAsStateWithLifecycle(initialValue = null) + + // Trigger recomposition when zaps arrive for the goal note. + val goalZapsState = + if (goalNote != null) { + observeNoteZaps(goalNote, accountViewModel).value + } else { + null + } + + val entries = + remember(channel, channelTick, goalNote, goalZapsState) { + aggregateTopZappers(channel, goalNote) + } + + if (entries.isEmpty()) return + + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + items(entries, key = { it.zapperPubKey }) { entry -> + ZapperPill(entry, accountViewModel, nav) + } + } +} + +@Composable +private fun ZapperPill( + entry: TopZapperEntry, + accountViewModel: AccountViewModel, + nav: INav, +) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier.clickable { + nav.nav(Route.Profile(entry.zapperPubKey)) + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + UserPicture( + userHex = entry.zapperPubKey, + size = Size24dp, + accountViewModel = accountViewModel, + nav = nav, + ) + ZapIcon(Size16Modifier, BitcoinOrange) + Text( + text = showAmountInteger(entry.totalSats), + style = MaterialTheme.typography.labelMedium, + ) + Spacer(Modifier.padding(start = 2.dp)) + } + } +} + +private fun aggregateTopZappers( + channel: LiveActivitiesChannel, + goalNote: Note?, +): List { + // receiptId -> (zapperPubKey, sats) — dedupes a zap that appears via both #a and #e. + val byReceipt = HashMap>() + + // Stream-level zaps routed to the channel cache. + channel.notes.forEach { _, note -> + val ev = note.event + if (ev is LnZapEvent) { + val zapper = ev.zapRequest?.pubKey ?: return@forEach + val sats = ev.amount() ?: return@forEach + byReceipt[note.idHex] = zapper to sats + } + } + + // Goal-scoped zaps attached to the goal note via #e. + goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) -> + val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach + val zapper = (zapRequestNote.event as? LnZapRequestEvent)?.pubKey ?: return@forEach + val sats = receiptEv.amount() ?: return@forEach + byReceipt[receiptNote.idHex] = zapper to sats + } + + if (byReceipt.isEmpty()) return emptyList() + + val totals = HashMap(byReceipt.size) + byReceipt.values.forEach { (pk, sats) -> + totals[pk] = (totals[pk] ?: BigDecimal.ZERO) + sats + } + + return totals.entries + .asSequence() + .sortedByDescending { it.value } + .take(TOP_ZAPPERS_LIMIT) + .map { TopZapperEntry(it.key, it.value) } + .toList() +}