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..ac8d998f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -169,9 +169,11 @@ 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 +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 +1497,44 @@ 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 + } + + 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, @@ -1515,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) } @@ -1541,6 +1587,8 @@ object LocalCache : ILocalCache, ICacheProvider { repliesTo.forEach { it.addZap(zapRequest, note) } + attachZapToLiveActivityChannel(event, note, relay) + refreshNewNoteObservers(note) return true @@ -1549,6 +1597,27 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + private fun attachZapToLiveActivityChannel( + event: LnZapEvent, + note: Note, + relay: NormalizedRelayUrl?, + ) { + // Match zap.stream: only show zaps whose receiver is the live activity host. + val hosts = event.zappedAuthor().toHashSet() + if (hosts.isEmpty()) return + + // 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( event: LnZapRequestEvent, relay: NormalizedRelayUrl?, @@ -2647,6 +2716,8 @@ 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 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/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/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index e2d5fac54..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,9 @@ 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 import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile @@ -84,6 +87,9 @@ 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 import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -109,20 +115,29 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> - NormalChatNote( - baseNote, - routeForLastRead, - innerQuote, - canPreview, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply, - onWantsToEditDraft, - onScrollToNote, - shouldHighlight, - onHighlightFinished, - ) + val event = baseNote.event + if (event is LnZapEvent) { + 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, + 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/RenderChatClip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt new file mode 100644 index 000000000..bb5a8f39e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt @@ -0,0 +1,118 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.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 +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.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 accentAlpha = 0.12f + + 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, + ) + } + + if (!title.isNullOrBlank()) { + Text(text = title) + } + + 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, + ) + } + } + } +} 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..5838ff092 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -0,0 +1,141 @@ +/* + * 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.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.padding +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.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 +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 + + StreamSystemCard( + accent = accent, + accentAlpha = 0.14f, + onClick = { nav.nav(to.toRoute()) }, + ) { + 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, + ) + } + } + } + + 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/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..967ca1ec5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -0,0 +1,133 @@ +/* + * 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.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.padding +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.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 +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 accentAlpha = if (isBigZap) 0.18f else 0.08f + val amountText = card.amount ?: showAmountInteger(zapEvent.amount) + + 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, + ) + } + + 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/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/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/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 6ce98ef3b..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,9 @@ 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 fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -35,7 +38,13 @@ fun filterMessagesToLiveActivities( relay = it, filter = Filter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + kinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesRaidEvent.KIND, + LiveActivitiesClipEvent.KIND, + LnZapEvent.KIND, + ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, since = since?.get(it)?.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..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 @@ -38,6 +38,8 @@ 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.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 @@ -127,6 +129,8 @@ fun LiveActivityChannelView( .weight(1f, true), ) { ShowVideoStreaming(channel, accountViewModel) + LiveStreamTopZappers(channel, accountViewModel, nav) + 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/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..439ef3cb2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -0,0 +1,171 @@ +/* + * 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.heightIn +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.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.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 +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 +import java.math.BigDecimal + +/** + * 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( + channel: LiveActivitiesChannel, + 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 goalNoteHolder by remember(goalId) { + mutableStateOf(accountViewModel.getNoteIfExists(goalId)) + } + LaunchedEffect(goalId) { + if (goalNoteHolder == null) { + goalNoteHolder = accountViewModel.checkGetOrCreateNote(goalId) + } + topZappersVm.setGoalNote(goalNoteHolder) + } + } else { + LaunchedEffect(channel) { topZappersVm.setGoalNote(null) } + } + + val entries by topZappersVm.topZappers.collectAsStateWithLifecycle() + + 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.bucketKey }) { entry -> + ZapperPill(entry, accountViewModel, nav) + } + } +} + +@Composable +private fun ZapperPill( + entry: TopZapperEntry, + accountViewModel: AccountViewModel, + nav: INav, +) { + val clickModifier = + if (entry.isAnonymous) { + Modifier + } else { + Modifier.clickable { nav.nav(Route.Profile(entry.bucketKey)) } + } + + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = clickModifier, + ) { + Row( + // 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), + ) { + if (entry.isAnonymous) { + Text( + text = stringRes(R.string.chat_zap_anonymous), + style = MaterialTheme.typography.labelMedium, + ) + } else { + UserPicture( + userHex = entry.bucketKey, + size = Size24dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + ZapIcon(Size16Modifier, BitcoinOrange) + Text( + text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)), + style = MaterialTheme.typography.labelMedium, + ) + Spacer(Modifier.padding(start = 2.dp)) + } + } +} 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) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 071adb861..29357c28c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -53,6 +53,10 @@ 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 + 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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt new file mode 100644 index 000000000..52355902d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Shared rounded, accent-tinted container used by the centered "system-style" + * cards rendered inside the live stream chat feed (zaps, raids, clips). + * + * Kept platform-neutral so Desktop can consume it alongside Android. + */ +@Composable +fun StreamSystemCard( + accent: Color = MaterialTheme.colorScheme.primary, + accentAlpha: Float = 0.12f, + onClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit, +) { + val base = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(accent.copy(alpha = accentAlpha)) + + val clickable = if (onClick != null) base.clickable(onClick = onClick) else base + + Box( + modifier = clickable.padding(horizontal = 10.dp, vertical = 8.dp), + content = content, + ) +} 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()) + } +} 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/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/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, 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..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,9 +165,11 @@ 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 +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 @@ -436,7 +438,9 @@ 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) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) 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()) + } +}