diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 41f7a8fb3..c58e725ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1596,17 +1596,20 @@ object LocalCache : ILocalCache, ICacheProvider { note: Note, relay: NormalizedRelayUrl?, ) { - val address = - event.tags - .asSequence() - .mapNotNull(ATag::parseAddress) - .firstOrNull { it.kind == LiveActivitiesEvent.KIND } - ?: return + // Match zap.stream: only show zaps whose receiver is the live activity host. + val hosts = event.zappedAuthor().toHashSet() + if (hosts.isEmpty()) return - // Match zap.stream: only show zaps whose receiver is the live activity host - if (event.zappedAuthor().none { it == address.pubKeyHex }) return - - getOrCreateLiveChannel(address).addNote(note, relay) + // Route into every live-activity address this zap references (zap.stream uses one, but + // a receipt could legitimately reference multiple simulcasted streams). + event.tags + .asSequence() + .mapNotNull(ATag::parseAddress) + .filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex in hosts } + .distinct() + .forEach { address -> + getOrCreateLiveChannel(address).addNote(note, relay) + } } fun consume( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt index 21edb4767..4561d7025 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt @@ -20,21 +20,18 @@ */ 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.Column 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.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight 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.service.playback.composable.VideoView 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.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -64,56 +62,56 @@ fun RenderChatClip( val authorHex = remember(clip) { clip.pubKey } val accent = MaterialTheme.colorScheme.primary - val containerColor = remember(accent) { accent.copy(alpha = 0.12f) } + val accentAlpha = 0.12f - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(8.dp)) - .background(containerColor) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav) - Spacer(StdHorzSpacer) - LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user -> - if (user != null) { - UsernameDisplay( - baseUser = user, - fontWeight = FontWeight.Bold, - accountViewModel = accountViewModel, - ) + StreamSystemCard(accent = accent, accentAlpha = accentAlpha) { + val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = accentAlpha)) } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + 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()) { - Text(text = title) - } + if (!title.isNullOrBlank()) { + Text(text = title) + } - val caption = clip.content - if (caption.isNotBlank()) { - Text(text = caption) - } + val caption = clip.content + if (caption.isNotBlank()) { + CrossfadeToDisplayComment( + comment = caption, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } - if (!videoUrl.isNullOrBlank()) { - VideoView( - videoUri = videoUrl, - mimeType = null, - title = title, - roundedCorner = true, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) + if (!videoUrl.isNullOrBlank()) { + VideoView( + videoUri = videoUrl, + mimeType = null, + title = title, + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt index 00b0b6f31..3bc3d1bf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -20,15 +20,11 @@ */ 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.Column 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos import androidx.compose.material3.Icon @@ -40,7 +36,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -73,74 +68,72 @@ fun RenderChatRaid( if (from == null || to == null) return val accent = MaterialTheme.colorScheme.primary - val containerColor = remember(accent) { accent.copy(alpha = 0.14f) } - val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) } - Row( - modifier = - Modifier - .fillMaxWidth() - .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), + StreamSystemCard( + accent = accent, + accentAlpha = 0.14f, + onClick = { nav.nav(to.toRoute()) }, ) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) - Spacer(StdHorzSpacer) - LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user -> - if (user != null) { - UsernameDisplay( - baseUser = user, - fontWeight = FontWeight.Bold, - accountViewModel = accountViewModel, - ) + val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = 0.14f)) } + + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) + 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) - 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, - ) - } + val message = raid.content + if (message.isNotBlank()) { + Spacer(Modifier.padding(top = 2.dp)) + CrossfadeToDisplayComment( + comment = message, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) } } - val message = raid.content - if (message.isNotBlank()) { - Spacer(Modifier.padding(top = 2.dp)) - CrossfadeToDisplayComment( - comment = message, - backgroundColor = backgroundColor, - nav = nav, - accountViewModel = accountViewModel, - ) - } + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = null, + tint = accent, + modifier = Size20Modifier, + ) } - - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, - contentDescription = null, - tint = accent, - modifier = Size20Modifier, - ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt index 449d500bc..b066890b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -20,14 +20,11 @@ */ 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.Column 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.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -36,7 +33,6 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -79,67 +75,57 @@ fun RenderChatZap( (zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS } - val containerColor = - if (isBigZap) { - BitcoinOrange.copy(alpha = 0.18f) - } else { - BitcoinOrange.copy(alpha = 0.08f) - } - - val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) } - + val accentAlpha = if (isBigZap) 0.18f else 0.08f val amountText = card.amount ?: showAmountInteger(zapEvent.amount) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(8.dp)) - .background(containerColor) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - ZapIcon( - if (isBigZap) Size24Modifier else Size20Modifier, - BitcoinOrange, - ) + StreamSystemCard(accent = BitcoinOrange, accentAlpha = accentAlpha) { + val backgroundColor = remember(accentAlpha) { mutableStateOf(BitcoinOrange.copy(alpha = accentAlpha)) } + + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + 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) - UsernameDisplay( - baseUser = sender, - fontWeight = FontWeight.Bold, - accountViewModel = accountViewModel, - ) - } else { Text( - text = stringRes(R.string.chat_zap_anonymous), - fontWeight = FontWeight.Bold, + text = stringRes(R.string.chat_zap_amount_suffix, amountText), + color = BitcoinOrange, + fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal, ) } - Spacer(StdHorzSpacer) - Text( - text = stringRes(R.string.chat_zap_amount_suffix, amountText), - color = BitcoinOrange, - fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal, - ) - } - - card.comment?.takeIf { it.isNotBlank() }?.let { comment -> - Spacer(Modifier.padding(top = 2.dp)) - CrossfadeToDisplayComment( - comment = comment, - 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, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt new file mode 100644 index 000000000..ae6d80efb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt index 1437b5312..15fdec655 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -44,6 +44,7 @@ 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.R import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.Note 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.showAmountInteger 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.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.Size24dp @@ -63,9 +65,13 @@ 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, ) /** @@ -137,26 +143,37 @@ private fun ZapperPill( accountViewModel: AccountViewModel, nav: INav, ) { + val clickModifier = + if (entry.isAnonymous) { + Modifier + } else { + Modifier.clickable { nav.nav(Route.Profile(entry.zapperPubKey)) } + } + Surface( shape = CircleShape, color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = - Modifier.clickable { - nav.nav(Route.Profile(entry.zapperPubKey)) - }, + modifier = clickModifier, ) { 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, - ) + if (entry.isAnonymous) { + Text( + text = stringRes(R.string.chat_zap_anonymous), + style = MaterialTheme.typography.labelMedium, + ) + } else { + UserPicture( + userHex = entry.zapperPubKey, + size = Size24dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } ZapIcon(Size16Modifier, BitcoinOrange) Text( text = showAmountInteger(entry.totalSats), @@ -171,25 +188,25 @@ private fun aggregateTopZappers( channel: LiveActivitiesChannel, goalNote: Note?, ): List { - // 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>() // 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 request = ev.zapRequest ?: 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. goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) -> 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 - byReceipt[receiptNote.idHex] = zapper to sats + byReceipt[receiptNote.idHex] = bucketKeyFor(request) to sats } if (byReceipt.isEmpty()) return emptyList() @@ -203,6 +220,9 @@ private fun aggregateTopZappers( .asSequence() .sortedByDescending { it.value } .take(TOP_ZAPPERS_LIMIT) - .map { TopZapperEntry(it.key, it.value) } + .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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt new file mode 100644 index 000000000..473d8374e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt @@ -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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt new file mode 100644 index 000000000..25b02addb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt @@ -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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt new file mode 100644 index 000000000..20ce31fa6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt @@ -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()) + } +}