refactor(live-chat): lift NIP-53 live-stream state into commons
Address the architectural critique from self-review. The leaderboard math and the shared system card are now platform-neutral primitives in commons, the on-UI-thread aggregation is gone, and two latent correctness bugs around subscription lifecycle and zap routing are fixed. - Introduce a pure LiveActivityTopZappersAggregator in commons that takes plain ZapContribution values and returns a sorted, deduped, anon-bucketed top-N list. Covered by a unit-test suite that is Compose/Android-independent. - Introduce LiveStreamTopZappersViewModel in commons/commonMain. It owns two partitioned contribution maps (stream-scoped and goal-scoped), mutates them under a Mutex on the IO dispatcher in response to channel.changesFlow and Note.zaps.stateFlow emissions, and publishes the aggregator result via a StateFlow<List<TopZapperEntry>>. UI is now a dumb consumer with no aggregation logic left in the composable. - Move StreamSystemCard to commons/commonMain/compose/ so Desktop can consume it alongside Android. - Fix attachZapToLiveActivityChannel to run even when a zap receipt was already consumed by another subscription. Previously a zap first seen by notifications/profile would never reach the stream's channel cache; addNote is already idempotent so this is a safe retro-route. - Fix ChannelFilterAssemblerSubscription to re-invalidate filters when a live-activity channel's metadata arrives. The 30311 stream event usually lands after the initial assembler run, so the goal-tag-driven subscription never fired. Now it re-runs as metadata resolves and the goal id is discovered. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
This commit is contained in:
@@ -1555,8 +1555,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
// Already processed this event.
|
||||
if (note.event != null) return false
|
||||
|
||||
// Already processed this event — still ensure it's routed into any live-activity
|
||||
// channel(s) it references. A zap that was first consumed by, e.g., the notifications
|
||||
// subscription must still appear in the stream's chat when the user opens the stream.
|
||||
if (note.event != null) {
|
||||
attachZapToLiveActivityChannel(event, note, relay)
|
||||
return false
|
||||
}
|
||||
|
||||
if (wasVerified || justVerify(event)) {
|
||||
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
|
||||
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* 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.feed.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Shared rounded, accent-tinted container used by the centered "system-style"
|
||||
* cards rendered inside the live stream chat feed (zaps, raids, clips).
|
||||
*
|
||||
* Keeps the three renderers visually consistent so they clearly stand apart
|
||||
* from regular chat bubbles. The content slot uses a BoxScope so callers can
|
||||
* pick their own internal layout (Row / Column / whatever).
|
||||
*/
|
||||
@Composable
|
||||
fun StreamSystemCard(
|
||||
accent: Color = MaterialTheme.colorScheme.primary,
|
||||
accentAlpha: Float = 0.12f,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val base =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(accent.copy(alpha = accentAlpha))
|
||||
|
||||
val clickable = if (onClick != null) base.clickable(onClick = onClick) else base
|
||||
|
||||
Box(
|
||||
modifier = clickable.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
+18
@@ -21,8 +21,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@@ -40,4 +44,18 @@ fun ChannelFilterAssemblerSubscription(
|
||||
}
|
||||
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
|
||||
// Live streams need the 30311 event to populate `channel.info` before we can read its
|
||||
// `goal` tag and add the goal+zap subscriptions. Re-invalidate when metadata changes so
|
||||
// the goal filter fires as soon as the stream event arrives.
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
val metadataState by channel
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val goalId = channel.info?.goalEventId()
|
||||
LaunchedEffect(goalId, metadataState) {
|
||||
dataSource.invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-97
@@ -43,11 +43,14 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.commons.nip53LiveActivities.TopZapperEntry
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.LiveStreamTopZappersViewModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
@@ -58,27 +61,14 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
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
|
||||
|
||||
/** Sentinel key used to bucket every anonymous/private zap into a single leaderboard entry. */
|
||||
private const val ANON_KEY = "anon"
|
||||
|
||||
private data class TopZapperEntry(
|
||||
val zapperPubKey: HexKey,
|
||||
val totalSats: BigDecimal,
|
||||
val isAnonymous: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Horizontally-scrollable leaderboard of the top zappers on a live stream. Matches
|
||||
* zap.stream's TopZappers look: pill chips with avatar + lightning + sats.
|
||||
*
|
||||
* State is owned by [LiveStreamTopZappersViewModel], which maintains the aggregation
|
||||
* incrementally off the UI thread and publishes a stable `List<TopZapperEntry>`.
|
||||
*/
|
||||
@Composable
|
||||
fun LiveStreamTopZappers(
|
||||
@@ -86,43 +76,33 @@ fun LiveStreamTopZappers(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val topZappersVm: LiveStreamTopZappersViewModel =
|
||||
viewModel(
|
||||
key = "TopZappers-${channel.address.toValue()}",
|
||||
factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = LiveStreamTopZappersViewModel(channel) as T
|
||||
},
|
||||
)
|
||||
|
||||
// Track the goal note lifecycle — pass it to the VM as it resolves, clear on channel change.
|
||||
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)
|
||||
var goalNoteHolder by remember(goalId) {
|
||||
mutableStateOf(accountViewModel.getNoteIfExists(goalId))
|
||||
}
|
||||
LaunchedEffect(goalId) {
|
||||
if (goalNoteHolder == null) {
|
||||
goalNoteHolder = accountViewModel.checkGetOrCreateNote(goalId)
|
||||
}
|
||||
topZappersVm.setGoalNote(goalNoteHolder)
|
||||
}
|
||||
TopZappersStrip(channel, goalNote, accountViewModel, nav)
|
||||
} else {
|
||||
TopZappersStrip(channel, null, accountViewModel, nav)
|
||||
LaunchedEffect(channel) { topZappersVm.setGoalNote(null) }
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
val entries by topZappersVm.topZappers.collectAsStateWithLifecycle()
|
||||
|
||||
if (entries.isEmpty()) return
|
||||
|
||||
@@ -131,7 +111,7 @@ private fun TopZappersStrip(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
) {
|
||||
items(entries, key = { it.zapperPubKey }) { entry ->
|
||||
items(entries, key = { it.bucketKey }) { entry ->
|
||||
ZapperPill(entry, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
@@ -147,7 +127,7 @@ private fun ZapperPill(
|
||||
if (entry.isAnonymous) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.clickable { nav.nav(Route.Profile(entry.zapperPubKey)) }
|
||||
Modifier.clickable { nav.nav(Route.Profile(entry.bucketKey)) }
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -168,7 +148,7 @@ private fun ZapperPill(
|
||||
)
|
||||
} else {
|
||||
UserPicture(
|
||||
userHex = entry.zapperPubKey,
|
||||
userHex = entry.bucketKey,
|
||||
size = Size24dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
@@ -176,53 +156,10 @@ private fun ZapperPill(
|
||||
}
|
||||
ZapIcon(Size16Modifier, BitcoinOrange)
|
||||
Text(
|
||||
text = showAmountInteger(entry.totalSats),
|
||||
text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(Modifier.padding(start = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun aggregateTopZappers(
|
||||
channel: LiveActivitiesChannel,
|
||||
goalNote: Note?,
|
||||
): List<TopZapperEntry> {
|
||||
// receiptId -> (zapperBucketKey, 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 request = ev.zapRequest ?: return@forEach
|
||||
val sats = ev.amount() ?: return@forEach
|
||||
byReceipt[note.idHex] = bucketKeyFor(request) 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 request = zapRequestNote.event as? LnZapRequestEvent ?: return@forEach
|
||||
val sats = receiptEv.amount() ?: return@forEach
|
||||
byReceipt[receiptNote.idHex] = bucketKeyFor(request) 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, isAnonymous = it.key == ANON_KEY) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Returns the aggregation key for a zap request: real pubkey, or the anon sentinel for any `anon`-tagged zap. */
|
||||
private fun bucketKeyFor(request: LnZapRequestEvent): HexKey = if (request.tags.any { it.isNotEmpty() && it[0] == "anon" }) ANON_KEY else request.pubKey
|
||||
|
||||
Reference in New Issue
Block a user