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:
+2
@@ -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.dal.ChannelFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
|
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.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.ChannelNewMessageViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||||
@@ -128,6 +129,7 @@ fun LiveActivityChannelView(
|
|||||||
.weight(1f, true),
|
.weight(1f, true),
|
||||||
) {
|
) {
|
||||||
ShowVideoStreaming(channel, accountViewModel)
|
ShowVideoStreaming(channel, accountViewModel)
|
||||||
|
LiveStreamTopZappers(channel, accountViewModel, nav)
|
||||||
LiveStreamGoalHeader(channel, accountViewModel, nav)
|
LiveStreamGoalHeader(channel, accountViewModel, nav)
|
||||||
RefreshingChatroomFeedView(
|
RefreshingChatroomFeedView(
|
||||||
feedContentState = feedViewModel.feedState,
|
feedContentState = feedViewModel.feedState,
|
||||||
|
|||||||
+208
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user