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=<stream> and #e=<goalId> 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
This commit is contained in:
Claude
2026-04-20 23:09:10 +00:00
parent 43435427b2
commit 003603fd81
2 changed files with 210 additions and 0 deletions
@@ -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,
@@ -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<TopZapperEntry> {
// receiptId -> (zapperPubKey, sats) — dedupes a zap that appears via both #a and #e.
val byReceipt = HashMap<HexKey, Pair<HexKey, BigDecimal>>()
// 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<HexKey, BigDecimal>(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()
}