refactor(live-chat): polish NIP-53 chat renderers from self-review

- Extract a shared StreamSystemCard composable for the rounded accent
  container used by zap, raid, and clip cards so they share one visual
  language.
- Unify clip caption rendering through CrossfadeToDisplayComment so
  captions get the same NIP-19 / emoji treatment as zap and raid
  messages.
- Iterate every `a` tag when routing zap receipts into live-activity
  channels so a receipt referencing multiple simulcasted streams lands
  in each of them, and fold the host match into the same pass.
- Bucket anonymous and private zaps under a single sentinel key in the
  Top Zappers aggregator so an "Anonymous" chip shows once with the
  combined total instead of one chip per one-time pubkey.
- Add commonTest coverage for LiveActivitiesRaidEvent marker parsing,
  LiveActivitiesClipEvent tag parsing, and LiveActivitiesEvent.goalEventId().

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
This commit is contained in:
Claude
2026-04-20 23:29:05 +00:00
parent 003603fd81
commit 766274d0b2
9 changed files with 516 additions and 196 deletions
@@ -1596,17 +1596,20 @@ object LocalCache : ILocalCache, ICacheProvider {
note: Note, note: Note,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
) { ) {
val address = // Match zap.stream: only show zaps whose receiver is the live activity host.
event.tags val hosts = event.zappedAuthor().toHashSet()
.asSequence() if (hosts.isEmpty()) return
.mapNotNull(ATag::parseAddress)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
?: return
// Match zap.stream: only show zaps whose receiver is the live activity host // Route into every live-activity address this zap references (zap.stream uses one, but
if (event.zappedAuthor().none { it == address.pubKeyHex }) return // a receipt could legitimately reference multiple simulcasted streams).
event.tags
getOrCreateLiveChannel(address).addNote(note, relay) .asSequence()
.mapNotNull(ATag::parseAddress)
.filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex in hosts }
.distinct()
.forEach { address ->
getOrCreateLiveChannel(address).addNote(note, relay)
}
} }
fun consume( fun consume(
@@ -20,21 +20,18 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
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.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -42,6 +39,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,56 +62,56 @@ fun RenderChatClip(
val authorHex = remember(clip) { clip.pubKey } val authorHex = remember(clip) { clip.pubKey }
val accent = MaterialTheme.colorScheme.primary val accent = MaterialTheme.colorScheme.primary
val containerColor = remember(accent) { accent.copy(alpha = 0.12f) } val accentAlpha = 0.12f
Column( StreamSystemCard(accent = accent, accentAlpha = accentAlpha) {
modifier = val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = accentAlpha)) }
Modifier
.fillMaxWidth() Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
.padding(horizontal = 8.dp, vertical = 2.dp) Row(verticalAlignment = Alignment.CenterVertically) {
.clip(RoundedCornerShape(8.dp)) UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav)
.background(containerColor) Spacer(StdHorzSpacer)
.padding(horizontal = 10.dp, vertical = 8.dp), LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user ->
verticalArrangement = Arrangement.spacedBy(6.dp), if (user != null) {
) { UsernameDisplay(
Row(verticalAlignment = Alignment.CenterVertically) { baseUser = user,
UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav) fontWeight = FontWeight.Bold,
Spacer(StdHorzSpacer) accountViewModel = accountViewModel,
LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user -> )
if (user != null) { }
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
} }
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.chat_clip_created_a_clip),
color = accent,
fontWeight = FontWeight.Bold,
)
} }
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.chat_clip_created_a_clip),
color = accent,
fontWeight = FontWeight.Bold,
)
}
if (!title.isNullOrBlank()) { if (!title.isNullOrBlank()) {
Text(text = title) Text(text = title)
} }
val caption = clip.content val caption = clip.content
if (caption.isNotBlank()) { if (caption.isNotBlank()) {
Text(text = caption) CrossfadeToDisplayComment(
} comment = caption,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel,
)
}
if (!videoUrl.isNullOrBlank()) { if (!videoUrl.isNullOrBlank()) {
VideoView( VideoView(
videoUri = videoUrl, videoUri = videoUrl,
mimeType = null, mimeType = null,
title = title, title = title,
roundedCorner = true, roundedCorner = true,
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
}
} }
} }
} }
@@ -20,15 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types 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.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -40,7 +36,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -73,74 +68,72 @@ fun RenderChatRaid(
if (from == null || to == null) return if (from == null || to == null) return
val accent = MaterialTheme.colorScheme.primary val accent = MaterialTheme.colorScheme.primary
val containerColor = remember(accent) { accent.copy(alpha = 0.14f) }
val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) }
Row( StreamSystemCard(
modifier = accent = accent,
Modifier accentAlpha = 0.14f,
.fillMaxWidth() onClick = { nav.nav(to.toRoute()) },
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(containerColor)
.clickable { nav.nav(to.toRoute()) }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Column(modifier = Modifier.weight(1f)) { val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = 0.14f)) }
Row(verticalAlignment = Alignment.CenterVertically) {
UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) Row(
Spacer(StdHorzSpacer) verticalAlignment = Alignment.Top,
LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user -> horizontalArrangement = Arrangement.spacedBy(8.dp),
if (user != null) { ) {
UsernameDisplay( Column(modifier = Modifier.weight(1f)) {
baseUser = user, Row(verticalAlignment = Alignment.CenterVertically) {
fontWeight = FontWeight.Bold, UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav)
accountViewModel = accountViewModel, Spacer(StdHorzSpacer)
) LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
}
}
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.chat_raid_is_raiding),
color = accent,
fontWeight = FontWeight.Bold,
)
Spacer(StdHorzSpacer)
UserPicture(to.pubKeyHex, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer)
LoadUser(baseUserHex = to.pubKeyHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
}
} }
} }
Spacer(StdHorzSpacer) val message = raid.content
Text( if (message.isNotBlank()) {
text = stringRes(R.string.chat_raid_is_raiding), Spacer(Modifier.padding(top = 2.dp))
color = accent, CrossfadeToDisplayComment(
fontWeight = FontWeight.Bold, comment = message,
) backgroundColor = backgroundColor,
Spacer(StdHorzSpacer) nav = nav,
accountViewModel = accountViewModel,
UserPicture(to.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) )
Spacer(StdHorzSpacer)
LoadUser(baseUserHex = to.pubKeyHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
}
} }
} }
val message = raid.content Icon(
if (message.isNotBlank()) { imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
Spacer(Modifier.padding(top = 2.dp)) contentDescription = null,
CrossfadeToDisplayComment( tint = accent,
comment = message, modifier = Size20Modifier,
backgroundColor = backgroundColor, )
nav = nav,
accountViewModel = accountViewModel,
)
}
} }
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = null,
tint = accent,
modifier = Size20Modifier,
)
} }
} }
@@ -20,14 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -36,7 +33,6 @@ import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -79,67 +75,57 @@ fun RenderChatZap(
(zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS (zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS
} }
val containerColor = val accentAlpha = if (isBigZap) 0.18f else 0.08f
if (isBigZap) {
BitcoinOrange.copy(alpha = 0.18f)
} else {
BitcoinOrange.copy(alpha = 0.08f)
}
val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) }
val amountText = card.amount ?: showAmountInteger(zapEvent.amount) val amountText = card.amount ?: showAmountInteger(zapEvent.amount)
Row( StreamSystemCard(accent = BitcoinOrange, accentAlpha = accentAlpha) {
modifier = val backgroundColor = remember(accentAlpha) { mutableStateOf(BitcoinOrange.copy(alpha = accentAlpha)) }
Modifier
.fillMaxWidth() Row(
.padding(horizontal = 8.dp, vertical = 2.dp) modifier = Modifier,
.clip(RoundedCornerShape(8.dp)) verticalAlignment = Alignment.CenterVertically,
.background(containerColor) horizontalArrangement = Arrangement.spacedBy(6.dp),
.padding(horizontal = 10.dp, vertical = 6.dp), ) {
verticalAlignment = Alignment.CenterVertically, ZapIcon(
horizontalArrangement = Arrangement.spacedBy(6.dp), if (isBigZap) Size24Modifier else Size20Modifier,
) { BitcoinOrange,
ZapIcon( )
if (isBigZap) Size24Modifier else Size20Modifier,
BitcoinOrange, Column(modifier = Modifier.weight(1f)) {
) Row(verticalAlignment = Alignment.CenterVertically) {
val sender = card.user
if (sender != null) {
UserPicture(sender, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer)
UsernameDisplay(
baseUser = sender,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
} else {
Text(
text = stringRes(R.string.chat_zap_anonymous),
fontWeight = FontWeight.Bold,
)
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val sender = card.user
if (sender != null) {
UserPicture(sender, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer) Spacer(StdHorzSpacer)
UsernameDisplay(
baseUser = sender,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
} else {
Text( Text(
text = stringRes(R.string.chat_zap_anonymous), text = stringRes(R.string.chat_zap_amount_suffix, amountText),
fontWeight = FontWeight.Bold, color = BitcoinOrange,
fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal,
) )
} }
Spacer(StdHorzSpacer) card.comment?.takeIf { it.isNotBlank() }?.let { comment ->
Text( Spacer(Modifier.padding(top = 2.dp))
text = stringRes(R.string.chat_zap_amount_suffix, amountText), CrossfadeToDisplayComment(
color = BitcoinOrange, comment = comment,
fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal, backgroundColor = backgroundColor,
) nav = nav,
} accountViewModel = accountViewModel,
)
card.comment?.takeIf { it.isNotBlank() }?.let { comment -> }
Spacer(Modifier.padding(top = 2.dp))
CrossfadeToDisplayComment(
comment = comment,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel,
)
} }
} }
} }
@@ -0,0 +1,65 @@
/*
* 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,
)
}
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
@@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size24dp
@@ -63,9 +65,13 @@ import java.math.BigDecimal
private const val TOP_ZAPPERS_LIMIT = 10 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( private data class TopZapperEntry(
val zapperPubKey: HexKey, val zapperPubKey: HexKey,
val totalSats: BigDecimal, val totalSats: BigDecimal,
val isAnonymous: Boolean,
) )
/** /**
@@ -137,26 +143,37 @@ private fun ZapperPill(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val clickModifier =
if (entry.isAnonymous) {
Modifier
} else {
Modifier.clickable { nav.nav(Route.Profile(entry.zapperPubKey)) }
}
Surface( Surface(
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier = clickModifier,
Modifier.clickable {
nav.nav(Route.Profile(entry.zapperPubKey))
},
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
UserPicture( if (entry.isAnonymous) {
userHex = entry.zapperPubKey, Text(
size = Size24dp, text = stringRes(R.string.chat_zap_anonymous),
accountViewModel = accountViewModel, style = MaterialTheme.typography.labelMedium,
nav = nav, )
) } else {
UserPicture(
userHex = entry.zapperPubKey,
size = Size24dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
ZapIcon(Size16Modifier, BitcoinOrange) ZapIcon(Size16Modifier, BitcoinOrange)
Text( Text(
text = showAmountInteger(entry.totalSats), text = showAmountInteger(entry.totalSats),
@@ -171,25 +188,25 @@ private fun aggregateTopZappers(
channel: LiveActivitiesChannel, channel: LiveActivitiesChannel,
goalNote: Note?, goalNote: Note?,
): List<TopZapperEntry> { ): List<TopZapperEntry> {
// receiptId -> (zapperPubKey, sats) — dedupes a zap that appears via both #a and #e. // receiptId -> (zapperBucketKey, sats) — dedupes a zap that appears via both #a and #e.
val byReceipt = HashMap<HexKey, Pair<HexKey, BigDecimal>>() val byReceipt = HashMap<HexKey, Pair<HexKey, BigDecimal>>()
// Stream-level zaps routed to the channel cache. // Stream-level zaps routed to the channel cache.
channel.notes.forEach { _, note -> channel.notes.forEach { _, note ->
val ev = note.event val ev = note.event
if (ev is LnZapEvent) { if (ev is LnZapEvent) {
val zapper = ev.zapRequest?.pubKey ?: return@forEach val request = ev.zapRequest ?: return@forEach
val sats = ev.amount() ?: return@forEach val sats = ev.amount() ?: return@forEach
byReceipt[note.idHex] = zapper to sats byReceipt[note.idHex] = bucketKeyFor(request) to sats
} }
} }
// Goal-scoped zaps attached to the goal note via #e. // Goal-scoped zaps attached to the goal note via #e.
goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) -> goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) ->
val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach
val zapper = (zapRequestNote.event as? LnZapRequestEvent)?.pubKey ?: return@forEach val request = zapRequestNote.event as? LnZapRequestEvent ?: return@forEach
val sats = receiptEv.amount() ?: return@forEach val sats = receiptEv.amount() ?: return@forEach
byReceipt[receiptNote.idHex] = zapper to sats byReceipt[receiptNote.idHex] = bucketKeyFor(request) to sats
} }
if (byReceipt.isEmpty()) return emptyList() if (byReceipt.isEmpty()) return emptyList()
@@ -203,6 +220,9 @@ private fun aggregateTopZappers(
.asSequence() .asSequence()
.sortedByDescending { it.value } .sortedByDescending { it.value }
.take(TOP_ZAPPERS_LIMIT) .take(TOP_ZAPPERS_LIMIT)
.map { TopZapperEntry(it.key, it.value) } .map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY) }
.toList() .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
@@ -0,0 +1,85 @@
/*
* 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.quartz.nip53LiveActivities.clip
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesClipEventTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val viewerAuthor = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
private val dummySig = "0".repeat(128)
@Test
fun parsesZapStreamShapedClip() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$host:stream-d", "wss://relay.example"),
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
arrayOf("alt", "Live stream clip"),
),
content = "Check this out",
sig = dummySig,
)
val activity = assertNotNull(event.activity())
assertEquals(LiveActivitiesEvent.KIND, activity.kind)
assertEquals(host, activity.pubKeyHex)
assertEquals("stream-d", activity.dTag)
assertEquals(host, event.host())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
assertEquals("Nice moment", event.title())
assertEquals("Check this out", event.content)
}
@Test
fun ignoresClipLackingStreamReference() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
),
content = "",
sig = dummySig,
)
assertNull(event.activity())
assertNull(event.activityAddress())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
}
}
@@ -0,0 +1,85 @@
/*
* 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.quartz.nip53LiveActivities.raid
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesRaidEventTest {
private val sourceHost = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val targetHost = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
private val author = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
private val dummySig = "0".repeat(128)
@Test
fun parsesRootAndMentionAddresses() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:source-d", "wss://relay.example", "root"),
arrayOf("a", "${LiveActivitiesEvent.KIND}:$targetHost:target-d", "", "mention"),
),
content = "Heading over to stream!",
sig = dummySig,
)
val from = assertNotNull(event.fromAddress())
assertEquals(LiveActivitiesEvent.KIND, from.kind)
assertEquals(sourceHost, from.pubKeyHex)
assertEquals("source-d", from.dTag)
val to = assertNotNull(event.toAddress())
assertEquals(LiveActivitiesEvent.KIND, to.kind)
assertEquals(targetHost, to.pubKeyHex)
assertEquals("target-d", to.dTag)
}
@Test
fun ignoresUnmarkedOrWrongKindATags() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
// Wrong marker
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:x", "", "reply"),
// Wrong kind
arrayOf("a", "30023:$sourceHost:article", "", "root"),
// No marker at all
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:y"),
),
content = "",
sig = dummySig,
)
assertNull(event.fromAddress())
assertNull(event.toAddress())
}
}
@@ -0,0 +1,85 @@
/*
* 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.quartz.nip53LiveActivities.streaming
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class LiveActivitiesEventGoalTagTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val goalId = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
private val dummySig = "0".repeat(128)
@Test
fun readsZapStreamGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("title", "My stream"),
arrayOf("goal", goalId),
),
content = "",
sig = dummySig,
)
assertEquals(goalId, event.goalEventId())
}
@Test
fun returnsNullWhenNoGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags = arrayOf(arrayOf("d", "stream-d")),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
@Test
fun returnsNullWhenGoalTagEmpty() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("goal", ""),
),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
}