From 238c9ea626bd4643be396f75ba547f7cac426c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:47:12 +0000 Subject: [PATCH 1/9] feat(live-chat): display zap receipts inline in live stream chat Subscribe to kind 9735 alongside kind 1311 against the stream's #a tag, route host-directed zaps into the LiveActivitiesChannel cache, and render them as a centered, lightning-accented row with the zapper, amount, and optional zap message. Matches zap.stream's chat behavior for NIP-53 + NIP-57 interop. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 20 +++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 34 ++-- .../chats/feed/types/RenderChatZap.kt | 146 ++++++++++++++++++ .../FilterMessagesToLiveStream.kt | 3 +- amethyst/src/main/res/values/strings.xml | 2 + 5 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt 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 8512b8d51..305da3fcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1541,6 +1541,8 @@ object LocalCache : ILocalCache, ICacheProvider { repliesTo.forEach { it.addZap(zapRequest, note) } + attachZapToLiveActivityChannel(event, note, relay) + refreshNewNoteObservers(note) return true @@ -1549,6 +1551,24 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + private fun attachZapToLiveActivityChannel( + event: LnZapEvent, + 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 + if (event.zappedAuthor().none { it == address.pubKeyHex }) return + + getOrCreateLiveChannel(address).addNote(note, relay) + } + fun consume( event: LnZapRequestEvent, relay: NormalizedRelayUrl?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index e2d5fac54..753f5cf90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile @@ -84,6 +85,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -109,20 +111,24 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> - NormalChatNote( - baseNote, - routeForLastRead, - innerQuote, - canPreview, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply, - onWantsToEditDraft, - onScrollToNote, - shouldHighlight, - onHighlightFinished, - ) + if (baseNote.event is LnZapEvent) { + RenderChatZap(baseNote, accountViewModel, nav) + } else { + NormalChatNote( + baseNote, + routeForLastRead, + innerQuote, + canPreview, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + onWantsToEditDraft, + onScrollToNote, + shouldHighlight, + onHighlightFinished, + ) + } } } } 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 new file mode 100644 index 000000000..449d500bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -0,0 +1,146 @@ +/* + * 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.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 +import androidx.compose.runtime.mutableStateOf +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 +import com.vitorpamplona.amethyst.model.Note +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.note.ZapAmountCommentNotification +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.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +private const val BIG_ZAP_THRESHOLD_SATS = 50_000L + +@Composable +fun RenderChatZap( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val zapEvent = baseNote.event as? LnZapEvent ?: return + + val card by produceState( + ZapAmountCommentNotification(user = null, comment = null, amount = null), + baseNote, + ) { + value = accountViewModel.innerDecryptAmountMessage(baseNote) ?: value + } + + val isBigZap = + remember(zapEvent) { + (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 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, + ) + + 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, + ) + } + + 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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 6ce98ef3b..10da3f52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -35,7 +36,7 @@ fun filterMessagesToLiveActivities( relay = it, filter = Filter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LnZapEvent.KIND), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, since = since?.get(it)?.time, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b537cbcf6..63b4ad22c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -53,6 +53,8 @@ You are using a public key and public keys are read-only. Login with a Private key to be able to boost posts You are using a public key and public keys are read-only. Login with a Private key to like posts No Zap Amount Setup. Long Press to change + zapped %1$s sats + Anonymous You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps You are using a public key and public keys are read-only. Login with a Private key to be able to follow You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow From 4786647762b89f6afae42fd6928a70119b99f877 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:29:50 +0000 Subject: [PATCH 2/9] feat(live-chat): display live stream raids (kind 1312) inline Add a Quartz LiveActivitiesRaidEvent for zap.stream's kind 1312 raid convention with root/mention a-tag markers. Wire it into the event factory, subscribe to it in the live-activity chat filter, and route received raids into both source and target LiveActivitiesChannel caches so viewers on either side see the notification. Render raids as an accent-colored pill showing "X is raiding Y" that navigates to the target stream when tapped. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 22 +++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 7 +- .../chats/feed/types/RenderChatRaid.kt | 147 ++++++++++++++++++ .../FilterMessagesToLiveStream.kt | 8 +- amethyst/src/main/res/values/strings.xml | 1 + .../raid/LiveActivitiesRaidEvent.kt | 115 ++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 7 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt 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 305da3fcf..124d7db39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -172,6 +172,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -1495,6 +1496,26 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + fun consume( + event: LiveActivitiesRaidEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val fromAddress = event.fromAddress() + val toAddress = event.toAddress() + if (fromAddress == null && toAddress == null) return false + + val new = consumeRegularEvent(event, relay, wasVerified) + + if (new) { + val note = getOrCreateNote(event.id) + fromAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) } + toAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) } + } + + return new + } + @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelHideMessageEvent, @@ -2667,6 +2688,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) + is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 753f5cf90..fa5e645ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent @@ -85,6 +86,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -111,8 +113,11 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> - if (baseNote.event is LnZapEvent) { + val event = baseNote.event + if (event is LnZapEvent) { RenderChatZap(baseNote, accountViewModel, nav) + } else if (event is LiveActivitiesRaidEvent) { + RenderChatRaid(baseNote, accountViewModel, nav) } else { NormalChatNote( baseNote, 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 new file mode 100644 index 000000000..00b0b6f31 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -0,0 +1,147 @@ +/* + * 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.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 +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +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 +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent + +@Composable +fun RenderChatRaid( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val raid = baseNote.event as? LiveActivitiesRaidEvent ?: return + val from = remember(raid) { raid.fromAddress() } + val to = remember(raid) { raid.toAddress() } + + // Without both endpoints we can't render a meaningful raid card. + 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), + ) { + 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, + ) + } + } + } + + 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, + ) + } +} + +private fun Address.toRoute(): Route = Route.LiveActivityChannel(kind, pubKeyHex, dTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 10da3f52c..15fd2035c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent fun filterMessagesToLiveActivities( @@ -36,7 +37,12 @@ fun filterMessagesToLiveActivities( relay = it, filter = Filter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LnZapEvent.KIND), + kinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesRaidEvent.KIND, + LnZapEvent.KIND, + ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, since = since?.get(it)?.time, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 63b4ad22c..2a2eb75d0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ No Zap Amount Setup. Long Press to change zapped %1$s sats Anonymous + is raiding You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps You are using a public key and public keys are read-only. Login with a Private key to be able to follow You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt new file mode 100644 index 000000000..cbf9f605e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt @@ -0,0 +1,115 @@ +/* + * 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 androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-53 live stream raid (zap.stream convention, kind 1312). + * + * A raid is authored by the source streamer to redirect viewers to another live + * stream. The event carries two `a` tags referencing NIP-53 Live Activities + * (kind 30311) differentiated by NIP-10-style markers at position 3: + * - "root" -> the source stream (the raid is being sent FROM) + * - "mention" -> the target stream (the raid is being sent TO) + * + * The `content` is a free-text raid message from the source streamer. + */ +@Immutable +class LiveActivitiesRaidEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + fun fromActivity(): ATag? = findActivity(MARKER_ROOT) + + fun toActivity(): ATag? = findActivity(MARKER_MENTION) + + fun fromAddress(): Address? = fromActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) } + + fun toAddress(): Address? = toActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) } + + private fun findActivity(marker: String): ATag? = + tags + .asSequence() + .filter { it.size > 3 && it[0] == ATag.TAG_NAME && it[3] == marker } + .mapNotNull(ATag::parse) + .firstOrNull { it.kind == LiveActivitiesEvent.KIND } + + companion object { + const val KIND = 1312 + const val ALT = "Live activity raid" + const val MARKER_ROOT = "root" + const val MARKER_MENTION = "mention" + + /** + * Builds an event template for a raid. Sender must be the host of the `from` stream. + * + * @param from source stream (the one currently live that is being ended/redirected) + * @param to target stream (where viewers should be redirected) + * @param message raid announcement text + */ + fun build( + from: EventHintBundle, + to: EventHintBundle, + message: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + addMarkedATag(from, MARKER_ROOT) + addMarkedATag(to, MARKER_MENTION) + initializer() + } + + private fun TagArrayBuilder.addMarkedATag( + bundle: EventHintBundle, + marker: String, + ) { + val relayUrl = bundle.relay?.url.orEmpty() + val addressId = + Address.assemble( + LiveActivitiesEvent.KIND, + bundle.event.pubKey, + bundle.event.dTag(), + ) + add(arrayOf(ATag.TAG_NAME, addressId, relayUrl, marker)) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 8c46307f2..c4d721624 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -168,6 +168,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -437,6 +438,7 @@ class EventFactory { LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) From 8368c9d66130806c335c9510caca9de9dc55be78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:53:02 +0000 Subject: [PATCH 3/9] feat(live-chat): display live stream clips (kind 1313) inline Add a Quartz LiveActivitiesClipEvent for zap.stream's kind 1313 clip convention, parsing the `a` tag (stream address), `p` tag (host), `r` tag (video URL), and `title` tag. Register it in EventFactory, subscribe in the live-activity chat filter, and route clips into the source LiveActivitiesChannel cache. Render clips as an accent-colored card showing "X created a clip" with the title and an inline video player for the clip's playable URL. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 20 +++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 4 + .../chats/feed/types/RenderChatClip.kt | 119 ++++++++++++++++++ .../FilterMessagesToLiveStream.kt | 2 + amethyst/src/main/res/values/strings.xml | 1 + .../clip/LiveActivitiesClipEvent.kt | 114 +++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 7 files changed, 262 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt 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 124d7db39..41f7a8fb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -169,6 +169,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent @@ -1516,6 +1517,24 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + fun consume( + event: LiveActivitiesClipEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val activityAddress = event.activityAddress() ?: return false + + val new = consumeRegularEvent(event, relay, wasVerified) + + if (new) { + val channel = getOrCreateLiveChannel(activityAddress) + val note = getOrCreateNote(event.id) + channel.addNote(note, relay) + } + + return new + } + @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelHideMessageEvent, @@ -2689,6 +2708,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) + is LiveActivitiesClipEvent -> consume(event, relay, wasVerified) is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index fa5e645ce..d01995e59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote @@ -86,6 +87,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup @@ -118,6 +120,8 @@ fun ChatroomMessageCompose( RenderChatZap(baseNote, accountViewModel, nav) } else if (event is LiveActivitiesRaidEvent) { RenderChatRaid(baseNote, accountViewModel, nav) + } else if (event is LiveActivitiesClipEvent) { + RenderChatClip(baseNote, accountViewModel, nav) } else { NormalChatNote( baseNote, 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 new file mode 100644 index 000000000..21edb4767 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt @@ -0,0 +1,119 @@ +/* + * 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.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.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 +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.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent + +@Composable +fun RenderChatClip( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val clip = baseNote.event as? LiveActivitiesClipEvent ?: return + + val videoUrl = remember(clip) { clip.videoUrl() } + val title = remember(clip) { clip.title() } + val authorHex = remember(clip) { clip.pubKey } + + val accent = MaterialTheme.colorScheme.primary + val containerColor = remember(accent) { accent.copy(alpha = 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, + ) + } + } + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.chat_clip_created_a_clip), + color = accent, + fontWeight = FontWeight.Bold, + ) + } + + if (!title.isNullOrBlank()) { + Text(text = title) + } + + val caption = clip.content + if (caption.isNotBlank()) { + Text(text = caption) + } + + 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/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 15fd2035c..c1c47325a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -41,6 +42,7 @@ fun filterMessagesToLiveActivities( listOf( LiveActivitiesChatMessageEvent.KIND, LiveActivitiesRaidEvent.KIND, + LiveActivitiesClipEvent.KIND, LnZapEvent.KIND, ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2a2eb75d0..3e0f7e1c7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -56,6 +56,7 @@ zapped %1$s sats Anonymous is raiding + created a clip You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps You are using a public key and public keys are read-only. Login with a Private key to be able to follow You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt new file mode 100644 index 000000000..32bb28397 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt @@ -0,0 +1,114 @@ +/* + * 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 androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-53 live stream clip (zap.stream convention, kind 1313). + * + * A clip is a standalone highlight produced from an ongoing or past live stream. + * The event carries: + * - `a` -> the source stream address (kind 30311) + * - `p` -> the stream host's pubkey + * - `r` -> direct playable video URL (MP4/HLS) + * - `title` -> clip title + * - `alt` -> NIP-31 fallback text + * + * `content` is an optional free-text caption. + */ +@Immutable +class LiveActivitiesClipEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + PubKeyHintProvider { + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints(): List = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys(): List = tags.mapNotNull(PTag::parseKey) + + fun activity(): ATag? = + tags + .asSequence() + .mapNotNull(ATag::parse) + .firstOrNull { it.kind == LiveActivitiesEvent.KIND } + + fun activityAddress(): Address? = activity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) } + + fun host(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun videoUrl(): String? = tags.firstNotNullOfOrNull(ReferenceTag::parse) + + fun title(): String? = tags.firstNotNullOfOrNull(TitleTag::parse) + + companion object { + const val KIND = 1313 + const val ALT = "Live activity clip" + + /** + * Builds an event template for a clip. Typically published by the clip-authoring backend + * on behalf of a viewer, but can also be published directly by a client. + */ + fun build( + activity: EventHintBundle, + videoUrl: String, + title: String, + host: HexKey = activity.event.pubKey, + caption: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, caption, createdAt) { + aTag(ATag(activity.event.kind, activity.event.pubKey, activity.event.dTag(), activity.relay)) + add(PTag.assemble(host, null)) + add(ReferenceTag.assemble(videoUrl)) + add(TitleTag.assemble(title)) + add(AltTag.assemble(ALT)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index c4d721624..c22b0e799 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -165,6 +165,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent @@ -437,6 +438,7 @@ class EventFactory { KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig) LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) From 43435427b2254b08cff4f7fedc5e46c198b5e6e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 22:10:54 +0000 Subject: [PATCH 4/9] feat(live-chat): show NIP-75 zap goal at top of live stream chat Add a goalEventId() accessor on LiveActivitiesEvent that reads the zap.stream `["goal", ""]` tag. When a stream channel's 30311 event references a goal, fetch the kind 9041 event and its zap receipts (#e=) so the existing goal-progress aggregation works. Render a compact LiveStreamGoalHeader above the chat feed showing the goal title, the existing GoalProgressBar, and a one-tap ZapReaction that targets the goal event so viewers can contribute directly to the fundraiser without leaving the stream. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../ChannelPublicFilterSubAssembler.kt | 20 ++- .../FilterGoalForLiveActivity.kt | 66 +++++++++ .../nip53LiveActivities/ChannelView.kt | 2 + .../header/LiveStreamGoalHeader.kt | 131 ++++++++++++++++++ .../streaming/LiveActivitiesEvent.kt | 7 + 5 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt index 6fab0c332..0daf59b22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt @@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler( since: SincePerRelayMap?, ): List? = when (val channel = key.channel) { - is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since) - is PublicChatChannel -> filterMessagesToPublicChat(channel, since) - is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since) - else -> null + is EphemeralChatChannel -> { + filterMessagesToEphemeralChat(channel, since) + } + + is PublicChatChannel -> { + filterMessagesToPublicChat(channel, since) + } + + is LiveActivitiesChannel -> { + filterMessagesToLiveActivities(channel, since) + + filterGoalForLiveActivities(channel, since) + } + + else -> { + null + } } override fun id(key: ChannelQueryState) = key.channel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt new file mode 100644 index 000000000..427101e5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent + +/** + * Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts + * that count toward it. + * + * zap.stream attaches a goal to a 30311 event via `["goal", ""]`. + * We fetch the 9041 goal event by id and the 9735 zap receipts that `#e`-tag it. + */ +fun filterGoalForLiveActivities( + channel: LiveActivitiesChannel, + since: SincePerRelayMap?, +): List { + val goalId = channel.info?.goalEventId() ?: return emptyList() + + return channel.relays().toSet().flatMap { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(GoalEvent.KIND), + ids = listOf(goalId), + limit = 1, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LnZapEvent.KIND), + tags = mapOf("e" to listOf(goalId)), + limit = 200, + since = since?.get(relay)?.time, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt index e84895170..1e210886c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -127,6 +128,7 @@ fun LiveActivityChannelView( .weight(1f, true), ) { ShowVideoStreaming(channel, accountViewModel) + LiveStreamGoalHeader(channel, accountViewModel, nav) RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt new file mode 100644 index 000000000..794281aa1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header + +import androidx.compose.foundation.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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.types.GoalProgressBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent + +/** + * Compact header rendered above the live-activity chat feed when the stream + * has a NIP-75 zap goal attached. Shows the goal title, a progress bar, and + * a one-tap zap button targeting the goal event. + */ +@Composable +fun LiveStreamGoalHeader( + channel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val goalId = channel.info?.goalEventId() ?: return + + LoadNote(baseNoteHex = goalId, accountViewModel = accountViewModel) { goalNote -> + if (goalNote != null) { + GoalHeaderContent(goalNote, accountViewModel, nav) + } + } +} + +@Composable +private fun GoalHeaderContent( + goalNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val goal = goalNote.event as? GoalEvent ?: return + + val title = + remember(goal) { + goal.summary()?.ifBlank { null } ?: goal.content.take(120).ifBlank { null } + } + val goalAmountSats = remember(goal) { (goal.amount() ?: 0L) / 1000 } + + if (goalAmountSats <= 0) return + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + + Spacer(StdHorzSpacer) + ZapReaction( + baseNote = goalNote, + grayTint = MaterialTheme.colorScheme.placeholderText, + accountViewModel = accountViewModel, + iconSize = Size20dp, + iconSizeModifier = Size20Modifier, + showCounter = false, + nav = nav, + ) + } + + GoalProgressBar( + note = goalNote, + goalAmountSats = goalAmountSats, + accountViewModel = accountViewModel, + ) + } + + HorizontalDivider(thickness = 0.5.dp) + Spacer(Modifier.height(2.dp)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index bf710592a..67754e328 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -117,6 +117,12 @@ class LiveActivitiesEvent( fun pinned() = tags.mapNotNull(PinnedEventTag::parse) + /** + * zap.stream convention: a NIP-75 zap goal (kind 9041) is attached to a live stream + * via a flat tag `["goal", ""]` on the 30311 event. + */ + fun goalEventId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == GOAL_TAG && it[1].isNotEmpty() }?.get(1) + fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) { StatusTag.STATUS.ENDED @@ -139,6 +145,7 @@ class LiveActivitiesEvent( companion object { const val KIND = 30311 const val ALT = "Live activity event" + const val GOAL_TAG = "goal" suspend fun create( signer: NostrSigner, From 003603fd81aea2505d63e9c48bcf8a4f5f22bc61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:09:10 +0000 Subject: [PATCH 5/9] feat(live-chat): top zappers leaderboard above live stream chat Add a horizontally-scrollable strip of pill chips showing the top zappers on a live stream, placed above the goal header. Each chip has an avatar, lightning icon, and compact sat total (K/M), matching zap.stream's TopZappers look. Tapping a chip opens the zapper's profile. Aggregates zaps from two sources and dedupes by receipt id so a zap that tags both #a= and #e= doesn't double-count: - stream-scoped host-directed zaps already routed into the LiveActivitiesChannel cache via the existing #a subscription - goal-scoped zaps reachable through goalNote.zaps when a NIP-75 goal is attached Sorts contributors by total sats desc, takes top 10, hides the row when there are none. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../nip53LiveActivities/ChannelView.kt | 2 + .../header/LiveStreamTopZappers.kt | 208 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt index 1e210886c..344403048 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatro import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -128,6 +129,7 @@ fun LiveActivityChannelView( .weight(1f, true), ) { ShowVideoStreaming(channel, accountViewModel) + LiveStreamTopZappers(channel, accountViewModel, nav) LiveStreamGoalHeader(channel, accountViewModel, nav) RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, 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 new file mode 100644 index 000000000..1437b5312 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import java.math.BigDecimal + +private const val TOP_ZAPPERS_LIMIT = 10 + +private data class TopZapperEntry( + val zapperPubKey: HexKey, + val totalSats: BigDecimal, +) + +/** + * Horizontally-scrollable leaderboard of the top zappers on a live stream. + * Aggregates zaps from both the stream's #a subscription and the attached + * NIP-75 zap goal (if any), de-duplicating by zap-receipt id. Matches + * zap.stream's TopZappers UX: pill chips with avatar + lightning + sats. + */ +@Composable +fun LiveStreamTopZappers( + channel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val goalId = channel.info?.goalEventId() + + if (goalId != null) { + var goalNote by remember(goalId) { mutableStateOf(accountViewModel.getNoteIfExists(goalId)) } + if (goalNote == null) { + LaunchedEffect(goalId) { + goalNote = accountViewModel.checkGetOrCreateNote(goalId) + } + } + TopZappersStrip(channel, goalNote, accountViewModel, nav) + } else { + TopZappersStrip(channel, null, accountViewModel, nav) + } +} + +@Composable +private fun TopZappersStrip( + channel: LiveActivitiesChannel, + goalNote: Note?, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Trigger recomposition whenever a new zap lands in the channel cache. + val channelTick by channel.changesFlow().collectAsStateWithLifecycle(initialValue = null) + + // Trigger recomposition when zaps arrive for the goal note. + val goalZapsState = + if (goalNote != null) { + observeNoteZaps(goalNote, accountViewModel).value + } else { + null + } + + val entries = + remember(channel, channelTick, goalNote, goalZapsState) { + aggregateTopZappers(channel, goalNote) + } + + if (entries.isEmpty()) return + + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + items(entries, key = { it.zapperPubKey }) { entry -> + ZapperPill(entry, accountViewModel, nav) + } + } +} + +@Composable +private fun ZapperPill( + entry: TopZapperEntry, + accountViewModel: AccountViewModel, + nav: INav, +) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier.clickable { + nav.nav(Route.Profile(entry.zapperPubKey)) + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + UserPicture( + userHex = entry.zapperPubKey, + size = Size24dp, + accountViewModel = accountViewModel, + nav = nav, + ) + ZapIcon(Size16Modifier, BitcoinOrange) + Text( + text = showAmountInteger(entry.totalSats), + style = MaterialTheme.typography.labelMedium, + ) + Spacer(Modifier.padding(start = 2.dp)) + } + } +} + +private fun aggregateTopZappers( + channel: LiveActivitiesChannel, + goalNote: Note?, +): List { + // receiptId -> (zapperPubKey, 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 sats = ev.amount() ?: return@forEach + byReceipt[note.idHex] = zapper to sats + } + } + + // Goal-scoped zaps attached to the goal note via #e. + goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) -> + val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach + val zapper = (zapRequestNote.event as? LnZapRequestEvent)?.pubKey ?: return@forEach + val sats = receiptEv.amount() ?: return@forEach + byReceipt[receiptNote.idHex] = zapper to sats + } + + if (byReceipt.isEmpty()) return emptyList() + + val totals = HashMap(byReceipt.size) + byReceipt.values.forEach { (pk, sats) -> + totals[pk] = (totals[pk] ?: BigDecimal.ZERO) + sats + } + + return totals.entries + .asSequence() + .sortedByDescending { it.value } + .take(TOP_ZAPPERS_LIMIT) + .map { TopZapperEntry(it.key, it.value) } + .toList() +} From 766274d0b2b11d72969746a45ee98d4849847c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:29:05 +0000 Subject: [PATCH 6/9] 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 --- .../amethyst/model/LocalCache.kt | 23 ++-- .../chats/feed/types/RenderChatClip.kt | 96 +++++++------- .../chats/feed/types/RenderChatRaid.kt | 121 +++++++++--------- .../chats/feed/types/RenderChatZap.kt | 100 +++++++-------- .../chats/feed/types/StreamSystemCard.kt | 65 ++++++++++ .../header/LiveStreamTopZappers.kt | 52 +++++--- .../clip/LiveActivitiesClipEventTest.kt | 85 ++++++++++++ .../raid/LiveActivitiesRaidEventTest.kt | 85 ++++++++++++ .../LiveActivitiesEventGoalTagTest.kt | 85 ++++++++++++ 9 files changed, 516 insertions(+), 196 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt 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()) + } +} From e12b6b4172b0bd5476746940f47ebdfeb7b87d9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:33:55 +0000 Subject: [PATCH 7/9] feat(live-chat): surface stream clips in the profile gallery tab Clips are authored by viewers but carry a `p` tag identifying the stream host, so they naturally belong on the host's profile. Extend the profile media subscription with `{kinds:[1313], #p:[pubkey]}`, accept LiveActivitiesClipEvent in UserProfileGalleryFeedFilter when the clip references a stream hosted by this user, and render the clip's `r`-tag URL as a MediaUrlVideo tile in GalleryThumbnail. Also plug LiveActivitiesClipEvent into NoteCompose via the existing RenderChatClip so tapping a clip tile opens a working thread view instead of an empty one. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/ui/note/NoteCompose.kt | 6 ++++ .../datasource/FilterUserProfileMedia.kt | 35 +++++++++++++------ .../loggedIn/profile/gallery/GalleryThumb.kt | 16 +++++++++ .../dal/UserProfileGalleryFeedFilter.kt | 29 +++++++++------ 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a869d042b..8f007e18f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -172,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -250,6 +251,7 @@ import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -938,6 +940,10 @@ private fun RenderNoteRow( RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) } + is LiveActivitiesClipEvent -> { + RenderChatClip(baseNote, accountViewModel, nav) + } + is FhirResourceEvent -> { RenderFhirResource(baseNote, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt index 93170f9d3..ae247c5dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -51,16 +52,30 @@ fun filterUserProfileMedia( ?: user.allUsedRelaysOrNull() ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) - return relays.map { relay -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = UserProfileMediaKinds, - authors = listOf(user.pubkeyHex), - limit = 200, - since = since?.get(relay)?.time, - ), + return relays.flatMap { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfileMediaKinds, + authors = listOf(user.pubkeyHex), + limit = 200, + since = since?.get(relay)?.time, + ), + ), + // Clips are authored by viewers but carry a `p` tag identifying the stream host. + // Fetch clips OF this profile's streams so they surface in their gallery. + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LiveActivitiesClipEvent.KIND), + tags = mapOf("p" to listOf(user.pubkeyHex)), + limit = 100, + since = since?.get(relay)?.time, + ), + ), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 66f06d75e..69f439936 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent @@ -130,6 +131,21 @@ fun GalleryThumbnail( thumbhash = imeta.thumbhash, ) } + } else if (noteEvent is LiveActivitiesClipEvent) { + noteEvent.videoUrl()?.let { url -> + listOf( + MediaUrlVideo( + url = url, + description = noteEvent.title() ?: noteEvent.content, + hash = null, + blurhash = null, + dim = null, + uri = null, + mimeType = null, + thumbhash = null, + ), + ) + } ?: emptyList() } else { emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 0e943641d..bcb315079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent @@ -75,17 +76,23 @@ class UserProfileGalleryFeedFilter( user: User, ): Boolean { val noteEvent = it.event - return ( - ( - it.event?.pubKey == user.pubkeyHex && - ( - noteEvent is PictureEvent || - noteEvent is RegularVideoEvent || - (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || - (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) - ) - ) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) - ) && + val authoredByUser = + it.event?.pubKey == user.pubkeyHex && + ( + noteEvent is PictureEvent || + noteEvent is RegularVideoEvent || + (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || + (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) + ) + + // Clips are authored by viewers, not the host — accept them when they reference + // a stream hosted by this user AND carry a playable URL. + val clipOfUsersStream = + noteEvent is LiveActivitiesClipEvent && + noteEvent.host() == user.pubkeyHex && + !noteEvent.videoUrl().isNullOrBlank() + + return (authoredByUser || clipOfUsersStream) && params.match(noteEvent, it.relays) && account.isAcceptable(it) } From 4888d30f6cf08a7ed7f95941154fee017742ebce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 00:20:01 +0000 Subject: [PATCH 8/9] 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>. 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 --- .../amethyst/model/LocalCache.kt | 10 +- .../chats/feed/types/RenderChatClip.kt | 1 + .../chats/feed/types/RenderChatRaid.kt | 1 + .../chats/feed/types/RenderChatZap.kt | 1 + .../ChannelFilterAssemblerSubscription.kt | 18 ++ .../header/LiveStreamTopZappers.kt | 131 ++++--------- .../nip53LiveActivities}/StreamSystemCard.kt | 6 +- .../LiveActivityTopZappersAggregator.kt | 106 +++++++++++ .../LiveStreamTopZappersViewModel.kt | 173 ++++++++++++++++++ .../LiveActivityTopZappersAggregatorTest.kt | 153 ++++++++++++++++ 10 files changed, 497 insertions(+), 103 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities}/StreamSystemCard.kt (89%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt 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 c58e725ef..ac8d998f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } 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 4561d7025..bb5a8f39e 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 @@ -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 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 3bc3d1bf3..5838ff092 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 @@ -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 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 b066890b8..967ca1ec5 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 @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index ea531c1bd..e8231e71b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -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() + } + } } 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 15fdec655..1624e3f51 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 @@ -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`. */ @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 create(modelClass: Class): 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 { - // 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 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(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt index ae6d80efb..52355902d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -38,9 +38,7 @@ 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). + * Kept platform-neutral so Desktop can consume it alongside Android. */ @Composable fun StreamSystemCard( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt new file mode 100644 index 000000000..2d0e88564 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt @@ -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, + limit: Int = DEFAULT_LIMIT, + ): List { + if (limit <= 0) return emptyList() + + // receiptId -> contribution. Dedupes a single zap that arrives via both #a and #e. + val unique = HashMap() + for (c in contributions) { + unique[c.receiptId] = c + } + if (unique.isEmpty()) return emptyList() + + val totals = HashMap(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() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt new file mode 100644 index 000000000..032c53a75 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -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` 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() + private val goalContributions = HashMap() + + private val _topZappers = MutableStateFlow>(emptyList()) + val topZappers: StateFlow> = _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): 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" } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt new file mode 100644 index 000000000..f1d64c7e1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt @@ -0,0 +1,153 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator.ANON_KEY +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator.aggregate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LiveActivityTopZappersAggregatorTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val carol = "c".repeat(64) + + private fun zap( + receipt: String, + zapper: String = alice, + sats: Long = 100, + anon: Boolean = false, + ) = ZapContribution( + receiptId = receipt, + zapperPubKey = zapper, + isAnonymous = anon, + sats = sats, + ) + + @Test + fun sortsByTotalDescending() { + val result = + aggregate( + listOf( + zap("r1", alice, 100), + zap("r2", bob, 500), + zap("r3", carol, 250), + ), + ) + + assertEquals(listOf(bob, carol, alice), result.map { it.bucketKey }) + assertEquals(listOf(500L, 250L, 100L), result.map { it.totalSats }) + } + + @Test + fun sumsMultipleZapsFromSameContributor() { + val result = + aggregate( + listOf( + zap("r1", alice, 100), + zap("r2", alice, 200), + zap("r3", bob, 250), + ), + ) + + assertEquals(listOf(alice, bob), result.map { it.bucketKey }) + assertEquals(300L, result[0].totalSats) + assertEquals(250L, result[1].totalSats) + } + + @Test + fun dedupesByReceiptId() { + // Same zap arriving via both #a (stream) and #e (goal) should count once. + val result = + aggregate( + listOf( + zap("r1", alice, 500), + zap("r1", alice, 500), + ), + ) + + assertEquals(1, result.size) + assertEquals(500L, result[0].totalSats) + } + + @Test + fun bucketsAnonymousZapsUnderSingleSentinel() { + // Each anon zap has a random one-time pubkey; they must collapse into one entry. + val anon1 = "1".repeat(64) + val anon2 = "2".repeat(64) + val result = + aggregate( + listOf( + zap("r1", alice, 100), + zap("r2", anon1, 200, anon = true), + zap("r3", anon2, 300, anon = true), + ), + ) + + assertEquals(2, result.size) + val anon = result.first { it.isAnonymous } + assertEquals(ANON_KEY, anon.bucketKey) + assertEquals(500L, anon.totalSats) + val realUser = result.first { !it.isAnonymous } + assertEquals(alice, realUser.bucketKey) + assertEquals(100L, realUser.totalSats) + } + + @Test + fun doesNotFalselyMarkRealPubkeyAnonymousWhenItCollidesWithSentinel() { + // Defensive: if some stream has a zapper whose pubkey literally equals "anon", + // we don't collide because the sentinel is 4 chars and real pubkeys are 64. + val result = + aggregate( + listOf( + zap("r1", alice, 100), + ), + ) + + assertTrue(result.none { it.isAnonymous }) + } + + @Test + fun respectsTopNLimit() { + val contributions = + (1..15).map { + zap(receipt = "r$it", zapper = "$it".repeat(64), sats = it.toLong() * 10) + } + + val result = aggregate(contributions, limit = 5) + + assertEquals(5, result.size) + // Highest total first. + assertEquals(15 * 10L, result[0].totalSats) + assertEquals(11 * 10L, result[4].totalSats) + } + + @Test + fun handlesEmptyInput() { + assertTrue(aggregate(emptyList()).isEmpty()) + } + + @Test + fun handlesZeroLimit() { + assertTrue(aggregate(listOf(zap("r1")), limit = 0).isEmpty()) + } +} From 900d953fb196fd1120a8233199900c7bf8df63ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 01:05:52 +0000 Subject: [PATCH 9/9] fix(live-chat): keep Top Zappers chips the same height when anonymous The "Anonymous" chip rendered without a 24dp UserPicture, so its pill shrank to the text's height and broke horizontal alignment with the avatar chips in the row. Give the chip row a minimum height equal to the avatar size so text-only variants stay visually aligned. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../nip53LiveActivities/header/LiveStreamTopZappers.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 1624e3f51..439ef3cb2 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 @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -137,7 +138,12 @@ private fun ZapperPill( modifier = clickModifier, ) { Row( - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + // Min height matches the avatar so text-only chips (Anonymous) stay aligned + // with the avatar chips in the horizontal row. + modifier = + Modifier + .heightIn(min = Size24dp) + .padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) {