Merge pull request #2469 from vitorpamplona/claude/add-zaps-to-chat-n1wsc
Add live stream top zappers leaderboard and NIP-53 event types
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.compose.nip53LiveActivities
|
||||
|
||||
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).
|
||||
*
|
||||
* Kept platform-neutral so Desktop can consume it alongside Android.
|
||||
*/
|
||||
@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,
|
||||
)
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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.nip53LiveActivities
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* A single entry on a stream's top-zappers leaderboard.
|
||||
*
|
||||
* @param bucketKey either a real zapper pubkey or [LiveActivityTopZappersAggregator.ANON_KEY]
|
||||
* for zaps whose zap-request carried an `anon` tag.
|
||||
* @param totalSats sum of amounts across every zap that mapped to this bucket.
|
||||
* @param isAnonymous true when the bucket is the shared anonymous pool.
|
||||
*/
|
||||
@Immutable
|
||||
data class TopZapperEntry(
|
||||
val bucketKey: HexKey,
|
||||
val totalSats: Long,
|
||||
val isAnonymous: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a single zap-receipt contribution before aggregation. All inputs are
|
||||
* plain values so this stays testable in pure Kotlin without Compose or Android.
|
||||
*
|
||||
* @param receiptId the kind-9735 receipt event id — used to de-duplicate receipts that
|
||||
* appear in both the stream's #a and the goal's #e feed.
|
||||
* @param zapperPubKey pubkey of the zap-request signer (random one-time key for anon/private).
|
||||
* @param isAnonymous true when the zap-request carried an `anon` tag (empty or encrypted).
|
||||
* @param sats amount in satoshis.
|
||||
*/
|
||||
@Immutable
|
||||
data class ZapContribution(
|
||||
val receiptId: HexKey,
|
||||
val zapperPubKey: HexKey,
|
||||
val isAnonymous: Boolean,
|
||||
val sats: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Computes a top-N zap leaderboard for a live stream.
|
||||
*
|
||||
* Inputs are raw [ZapContribution]s from any number of sources; the aggregator handles:
|
||||
* - de-duplication by receipt id (a zap that tags both `#a` and `#e` counts once),
|
||||
* - anonymous bucketing (all `anon`-tagged zaps collapse into one entry),
|
||||
* - sum-by-contributor and sort-desc-by-total,
|
||||
* - top-N truncation.
|
||||
*
|
||||
* This is a pure function — no Compose, no Android, no LocalCache — so it can be unit
|
||||
* tested in isolation and reused from any platform target.
|
||||
*/
|
||||
object LiveActivityTopZappersAggregator {
|
||||
/** Sentinel key that collapses every anonymous / private zap into a single leaderboard entry. */
|
||||
const val ANON_KEY: HexKey = "anon"
|
||||
|
||||
/** Default cap — matches zap.stream's TopZappers limit. */
|
||||
const val DEFAULT_LIMIT = 10
|
||||
|
||||
fun aggregate(
|
||||
contributions: Iterable<ZapContribution>,
|
||||
limit: Int = DEFAULT_LIMIT,
|
||||
): List<TopZapperEntry> {
|
||||
if (limit <= 0) return emptyList()
|
||||
|
||||
// receiptId -> contribution. Dedupes a single zap that arrives via both #a and #e.
|
||||
val unique = HashMap<HexKey, ZapContribution>()
|
||||
for (c in contributions) {
|
||||
unique[c.receiptId] = c
|
||||
}
|
||||
if (unique.isEmpty()) return emptyList()
|
||||
|
||||
val totals = HashMap<HexKey, Long>(unique.size)
|
||||
var hasAnon = false
|
||||
for (c in unique.values) {
|
||||
val key = if (c.isAnonymous) ANON_KEY else c.zapperPubKey
|
||||
if (c.isAnonymous) hasAnon = true
|
||||
totals[key] = (totals[key] ?: 0L) + c.sats
|
||||
}
|
||||
|
||||
return totals.entries
|
||||
.asSequence()
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY && hasAnon) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.ListChange
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Live-stream top-zappers leaderboard state, computed off the UI thread and maintained
|
||||
* incrementally.
|
||||
*
|
||||
* The leaderboard draws from two independent contribution sets keyed by zap-receipt id:
|
||||
* - [streamContributions] — zaps routed into the [LiveActivitiesChannel]'s cache via the
|
||||
* stream's `#a` subscription;
|
||||
* - [goalContributions] — zaps attached to a NIP-75 goal note via `#e`.
|
||||
*
|
||||
* The union is flattened on each change and fed into [LiveActivityTopZappersAggregator] for
|
||||
* de-dup, anonymous bucketing, sum-by-contributor, and top-N sort, publishing a stable
|
||||
* `List<TopZapperEntry>` via [topZappers] for dumb UI consumption.
|
||||
*/
|
||||
@Stable
|
||||
class LiveStreamTopZappersViewModel(
|
||||
private val channel: LiveActivitiesChannel,
|
||||
private val limit: Int = LiveActivityTopZappersAggregator.DEFAULT_LIMIT,
|
||||
) : ViewModel() {
|
||||
private val mutex = Mutex()
|
||||
|
||||
// receiptId -> ZapContribution, partitioned by source. Aggregator dedupes at merge.
|
||||
private val streamContributions = HashMap<HexKey, ZapContribution>()
|
||||
private val goalContributions = HashMap<HexKey, ZapContribution>()
|
||||
|
||||
private val _topZappers = MutableStateFlow<List<TopZapperEntry>>(emptyList())
|
||||
val topZappers: StateFlow<List<TopZapperEntry>> = _topZappers.asStateFlow()
|
||||
|
||||
private var goalNote: Note? = null
|
||||
private var goalObserverJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Seed from the channel cache.
|
||||
mutex.withLock {
|
||||
channel.notes.forEach { _, note ->
|
||||
contributionFromStreamZap(note)?.let { streamContributions[it.receiptId] = it }
|
||||
}
|
||||
}
|
||||
publish()
|
||||
|
||||
// Keep in sync with subsequent arrivals.
|
||||
channel.changesFlow().collect { change ->
|
||||
val mutated = applyChannelChange(change)
|
||||
if (mutated) publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach (or detach) a NIP-75 goal note whose zaps should flow into the leaderboard.
|
||||
* Passing the same instance twice is a no-op; passing null clears the goal source.
|
||||
*/
|
||||
fun setGoalNote(newGoalNote: Note?) {
|
||||
if (newGoalNote === goalNote) return
|
||||
goalNote = newGoalNote
|
||||
|
||||
goalObserverJob?.cancel()
|
||||
goalObserverJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Reset goal bucket and re-seed from the new note (if any) before subscribing.
|
||||
rebuildGoalContributions(newGoalNote)
|
||||
publish()
|
||||
|
||||
if (newGoalNote == null) return@launch
|
||||
|
||||
// Observe future goal-zap arrivals. Each emission rebuilds the goal bucket;
|
||||
// the aggregator already handles de-dup at merge-time.
|
||||
newGoalNote.flow().zaps.stateFlow.collect {
|
||||
rebuildGoalContributions(newGoalNote)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyChannelChange(change: ListChange<Note>): Boolean =
|
||||
mutex.withLock {
|
||||
when (change) {
|
||||
is ListChange.Addition -> upsertStream(change.item)
|
||||
is ListChange.Deletion -> removeStream(change.item.idHex)
|
||||
is ListChange.SetAddition -> change.item.fold(false) { acc, n -> upsertStream(n) || acc }
|
||||
is ListChange.SetDeletion -> change.item.fold(false) { acc, n -> removeStream(n.idHex) || acc }
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertStream(note: Note): Boolean {
|
||||
val c = contributionFromStreamZap(note) ?: return false
|
||||
val prev = streamContributions[c.receiptId]
|
||||
if (prev == c) return false
|
||||
streamContributions[c.receiptId] = c
|
||||
return true
|
||||
}
|
||||
|
||||
private fun removeStream(receiptId: HexKey): Boolean = streamContributions.remove(receiptId) != null
|
||||
|
||||
private suspend fun rebuildGoalContributions(goal: Note?) {
|
||||
mutex.withLock {
|
||||
goalContributions.clear()
|
||||
goal?.zaps?.forEach { (zapRequestNote, receiptNote) ->
|
||||
contributionFromGoalZap(zapRequestNote, receiptNote)?.let {
|
||||
goalContributions[it.receiptId] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val merged = streamContributions.values + goalContributions.values
|
||||
_topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit)
|
||||
}
|
||||
|
||||
private fun contributionFromStreamZap(note: Note): ZapContribution? {
|
||||
val ev = note.event as? LnZapEvent ?: return null
|
||||
val request = ev.zapRequest ?: return null
|
||||
val sats = ev.amount()?.toLong() ?: return null
|
||||
return ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
|
||||
private fun contributionFromGoalZap(
|
||||
zapRequestNote: Note,
|
||||
receiptNote: Note?,
|
||||
): ZapContribution? {
|
||||
val receiptEv = receiptNote?.event as? LnZapEvent ?: return null
|
||||
val request = zapRequestNote.event as? LnZapRequestEvent ?: return null
|
||||
val sats = receiptEv.amount()?.toLong() ?: return null
|
||||
return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats)
|
||||
}
|
||||
}
|
||||
|
||||
/** True for both public anonymous and NIP-57 private zaps (any `anon` tag, empty or encrypted). */
|
||||
private fun LnZapRequestEvent.isAnonTagged(): Boolean = tags.any { it.isNotEmpty() && it[0] == "anon" }
|
||||
Reference in New Issue
Block a user