Merge pull request #2469 from vitorpamplona/claude/add-zaps-to-chat-n1wsc

Add live stream top zappers leaderboard and NIP-53 event types
This commit is contained in:
Vitor Pamplona
2026-04-20 21:11:00 -04:00
committed by GitHub
28 changed files with 1969 additions and 42 deletions
@@ -169,9 +169,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent 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.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent 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.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -1495,6 +1497,44 @@ object LocalCache : ILocalCache, ICacheProvider {
return new 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") @Suppress("UNUSED_PARAMETER")
fun consume( fun consume(
event: ChannelHideMessageEvent, event: ChannelHideMessageEvent,
@@ -1515,8 +1555,14 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
): Boolean { ): Boolean {
val note = getOrCreateNote(event.id) 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)) { if (wasVerified || justVerify(event)) {
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
@@ -1541,6 +1587,8 @@ object LocalCache : ILocalCache, ICacheProvider {
repliesTo.forEach { it.addZap(zapRequest, note) } repliesTo.forEach { it.addZap(zapRequest, note) }
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note) refreshNewNoteObservers(note)
return true return true
@@ -1549,6 +1597,27 @@ object LocalCache : ILocalCache, ICacheProvider {
return false 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( fun consume(
event: LnZapRequestEvent, event: LnZapRequestEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -2647,6 +2716,8 @@ object LocalCache : ILocalCache, ICacheProvider {
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesEvent -> consume(event, relay, wasVerified)
is LiveActivitiesChatMessageEvent -> 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 MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
@@ -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.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer 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.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent 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.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@@ -938,6 +940,10 @@ private fun RenderNoteRow(
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
} }
is LiveActivitiesClipEvent -> {
RenderChatClip(baseNote, accountViewModel, nav)
}
is FhirResourceEvent -> { is FhirResourceEvent -> {
RenderFhirResource(baseNote, accountViewModel, nav) RenderFhirResource(baseNote, accountViewModel, nav)
} }
@@ -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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout 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.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.RenderCreateChannelNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile 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.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent 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.splits.hasZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
@@ -109,20 +115,29 @@ fun ChatroomMessageCompose(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) { canPreview -> ) { canPreview ->
NormalChatNote( val event = baseNote.event
baseNote, if (event is LnZapEvent) {
routeForLastRead, RenderChatZap(baseNote, accountViewModel, nav)
innerQuote, } else if (event is LiveActivitiesRaidEvent) {
canPreview, RenderChatRaid(baseNote, accountViewModel, nav)
parentBackgroundColor, } else if (event is LiveActivitiesClipEvent) {
accountViewModel, RenderChatClip(baseNote, accountViewModel, nav)
nav, } else {
onWantsToReply, NormalChatNote(
onWantsToEditDraft, baseNote,
onScrollToNote, routeForLastRead,
shouldHighlight, innerQuote,
onHighlightFinished, canPreview,
) parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply,
onWantsToEditDraft,
onScrollToNote,
shouldHighlight,
onHighlightFinished,
)
}
} }
} }
} }
@@ -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,
)
}
}
}
}
@@ -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)
@@ -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,
)
}
}
}
}
}
@@ -21,8 +21,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Channel 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.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -40,4 +44,18 @@ fun ChannelFilterAssemblerSubscription(
} }
KeyDataSourceSubscription(state, dataSource) 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()
}
}
} }
@@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler(
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? = ): List<RelayBasedFilter>? =
when (val channel = key.channel) { when (val channel = key.channel) {
is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since) is EphemeralChatChannel -> {
is PublicChatChannel -> filterMessagesToPublicChat(channel, since) filterMessagesToEphemeralChat(channel, since)
is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since) }
else -> null
is PublicChatChannel -> {
filterMessagesToPublicChat(channel, since)
}
is LiveActivitiesChannel -> {
filterMessagesToLiveActivities(channel, since) +
filterGoalForLiveActivities(channel, since)
}
else -> {
null
}
} }
override fun id(key: ChannelQueryState) = key.channel override fun id(key: ChannelQueryState) = key.channel
@@ -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", "<hex event id>"]`.
* We fetch the 9041 goal event by id and the 9735 zap receipts that `#e`-tag it.
*/
fun filterGoalForLiveActivities(
channel: LiveActivitiesChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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,
),
),
)
}
}
@@ -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.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent 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( fun filterMessagesToLiveActivities(
channel: LiveActivitiesChannel, channel: LiveActivitiesChannel,
@@ -35,7 +38,13 @@ fun filterMessagesToLiveActivities(
relay = it, relay = it,
filter = filter =
Filter( Filter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND), kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesRaidEvent.KIND,
LiveActivitiesClipEvent.KIND,
LnZapEvent.KIND,
),
tags = mapOf("a" to listOfNotNull(channel.address.toValue())), tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
limit = 200, limit = 200,
since = since?.get(it)?.time, since = since?.get(it)?.time,
@@ -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.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel 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.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.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -127,6 +129,8 @@ fun LiveActivityChannelView(
.weight(1f, true), .weight(1f, true),
) { ) {
ShowVideoStreaming(channel, accountViewModel) ShowVideoStreaming(channel, accountViewModel)
LiveStreamTopZappers(channel, accountViewModel, nav)
LiveStreamGoalHeader(channel, accountViewModel, nav)
RefreshingChatroomFeedView( RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState, feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -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))
}
@@ -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<TopZapperEntry>`.
*/
@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 <T : ViewModel> create(modelClass: Class<T>): 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))
}
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
@@ -51,16 +52,30 @@ fun filterUserProfileMedia(
?: user.allUsedRelaysOrNull() ?: user.allUsedRelaysOrNull()
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map { relay -> return relays.flatMap { relay ->
RelayBasedFilter( listOf(
relay = relay, RelayBasedFilter(
filter = relay = relay,
Filter( filter =
kinds = UserProfileMediaKinds, Filter(
authors = listOf(user.pubkeyHex), kinds = UserProfileMediaKinds,
limit = 200, authors = listOf(user.pubkeyHex),
since = since?.get(relay)?.time, 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,
),
),
) )
} }
} }
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent
@@ -130,6 +131,21 @@ fun GalleryThumbnail(
thumbhash = imeta.thumbhash, 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 { } else {
emptyList() emptyList()
} }
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent
@@ -75,17 +76,23 @@ class UserProfileGalleryFeedFilter(
user: User, user: User,
): Boolean { ): Boolean {
val noteEvent = it.event val noteEvent = it.event
return ( val authoredByUser =
( it.event?.pubKey == user.pubkeyHex &&
it.event?.pubKey == user.pubkeyHex && (
( noteEvent is PictureEvent ||
noteEvent is PictureEvent || noteEvent is RegularVideoEvent ||
noteEvent is RegularVideoEvent || (noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) || (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) )
)
) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) // 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) && params.match(noteEvent, it.relays) &&
account.isAcceptable(it) account.isAcceptable(it)
} }
+4
View File
@@ -53,6 +53,10 @@
<string name="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 be able to boost posts</string> <string name="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 be able to boost posts</string>
<string name="login_with_a_private_key_to_like_posts">You are using a public key and public keys are read-only. Login with a Private key to like posts</string> <string name="login_with_a_private_key_to_like_posts">You are using a public key and public keys are read-only. Login with a Private key to like posts</string>
<string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string> <string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string>
<string name="chat_zap_amount_suffix">zapped %1$s sats</string>
<string name="chat_zap_anonymous">Anonymous</string>
<string name="chat_raid_is_raiding">is raiding</string>
<string name="chat_clip_created_a_clip">created a clip</string>
<string name="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 send zaps</string> <string name="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 send zaps</string>
<string name="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 follow</string> <string name="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 follow</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string> <string name="login_with_a_private_key_to_be_able_to_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string>
@@ -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,
)
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip53LiveActivities
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A single entry on a stream's top-zappers leaderboard.
*
* @param bucketKey either a real zapper pubkey or [LiveActivityTopZappersAggregator.ANON_KEY]
* for zaps whose zap-request carried an `anon` tag.
* @param totalSats sum of amounts across every zap that mapped to this bucket.
* @param isAnonymous true when the bucket is the shared anonymous pool.
*/
@Immutable
data class TopZapperEntry(
val bucketKey: HexKey,
val totalSats: Long,
val isAnonymous: Boolean,
)
/**
* Represents a single zap-receipt contribution before aggregation. All inputs are
* plain values so this stays testable in pure Kotlin without Compose or Android.
*
* @param receiptId the kind-9735 receipt event id used to de-duplicate receipts that
* appear in both the stream's #a and the goal's #e feed.
* @param zapperPubKey pubkey of the zap-request signer (random one-time key for anon/private).
* @param isAnonymous true when the zap-request carried an `anon` tag (empty or encrypted).
* @param sats amount in satoshis.
*/
@Immutable
data class ZapContribution(
val receiptId: HexKey,
val zapperPubKey: HexKey,
val isAnonymous: Boolean,
val sats: Long,
)
/**
* Computes a top-N zap leaderboard for a live stream.
*
* Inputs are raw [ZapContribution]s from any number of sources; the aggregator handles:
* - de-duplication by receipt id (a zap that tags both `#a` and `#e` counts once),
* - anonymous bucketing (all `anon`-tagged zaps collapse into one entry),
* - sum-by-contributor and sort-desc-by-total,
* - top-N truncation.
*
* This is a pure function no Compose, no Android, no LocalCache so it can be unit
* tested in isolation and reused from any platform target.
*/
object LiveActivityTopZappersAggregator {
/** Sentinel key that collapses every anonymous / private zap into a single leaderboard entry. */
const val ANON_KEY: HexKey = "anon"
/** Default cap — matches zap.stream's TopZappers limit. */
const val DEFAULT_LIMIT = 10
fun aggregate(
contributions: Iterable<ZapContribution>,
limit: Int = DEFAULT_LIMIT,
): List<TopZapperEntry> {
if (limit <= 0) return emptyList()
// receiptId -> contribution. Dedupes a single zap that arrives via both #a and #e.
val unique = HashMap<HexKey, ZapContribution>()
for (c in contributions) {
unique[c.receiptId] = c
}
if (unique.isEmpty()) return emptyList()
val totals = HashMap<HexKey, Long>(unique.size)
var hasAnon = false
for (c in unique.values) {
val key = if (c.isAnonymous) ANON_KEY else c.zapperPubKey
if (c.isAnonymous) hasAnon = true
totals[key] = (totals[key] ?: 0L) + c.sats
}
return totals.entries
.asSequence()
.sortedByDescending { it.value }
.take(limit)
.map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY && hasAnon) }
.toList()
}
}
@@ -0,0 +1,173 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.viewmodels
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Live-stream top-zappers leaderboard state, computed off the UI thread and maintained
* incrementally.
*
* The leaderboard draws from two independent contribution sets keyed by zap-receipt id:
* - [streamContributions] zaps routed into the [LiveActivitiesChannel]'s cache via the
* stream's `#a` subscription;
* - [goalContributions] zaps attached to a NIP-75 goal note via `#e`.
*
* The union is flattened on each change and fed into [LiveActivityTopZappersAggregator] for
* de-dup, anonymous bucketing, sum-by-contributor, and top-N sort, publishing a stable
* `List<TopZapperEntry>` via [topZappers] for dumb UI consumption.
*/
@Stable
class LiveStreamTopZappersViewModel(
private val channel: LiveActivitiesChannel,
private val limit: Int = LiveActivityTopZappersAggregator.DEFAULT_LIMIT,
) : ViewModel() {
private val mutex = Mutex()
// receiptId -> ZapContribution, partitioned by source. Aggregator dedupes at merge.
private val streamContributions = HashMap<HexKey, ZapContribution>()
private val goalContributions = HashMap<HexKey, ZapContribution>()
private val _topZappers = MutableStateFlow<List<TopZapperEntry>>(emptyList())
val topZappers: StateFlow<List<TopZapperEntry>> = _topZappers.asStateFlow()
private var goalNote: Note? = null
private var goalObserverJob: Job? = null
init {
viewModelScope.launch(Dispatchers.IO) {
// Seed from the channel cache.
mutex.withLock {
channel.notes.forEach { _, note ->
contributionFromStreamZap(note)?.let { streamContributions[it.receiptId] = it }
}
}
publish()
// Keep in sync with subsequent arrivals.
channel.changesFlow().collect { change ->
val mutated = applyChannelChange(change)
if (mutated) publish()
}
}
}
/**
* Attach (or detach) a NIP-75 goal note whose zaps should flow into the leaderboard.
* Passing the same instance twice is a no-op; passing null clears the goal source.
*/
fun setGoalNote(newGoalNote: Note?) {
if (newGoalNote === goalNote) return
goalNote = newGoalNote
goalObserverJob?.cancel()
goalObserverJob =
viewModelScope.launch(Dispatchers.IO) {
// Reset goal bucket and re-seed from the new note (if any) before subscribing.
rebuildGoalContributions(newGoalNote)
publish()
if (newGoalNote == null) return@launch
// Observe future goal-zap arrivals. Each emission rebuilds the goal bucket;
// the aggregator already handles de-dup at merge-time.
newGoalNote.flow().zaps.stateFlow.collect {
rebuildGoalContributions(newGoalNote)
publish()
}
}
}
private suspend fun applyChannelChange(change: ListChange<Note>): Boolean =
mutex.withLock {
when (change) {
is ListChange.Addition -> upsertStream(change.item)
is ListChange.Deletion -> removeStream(change.item.idHex)
is ListChange.SetAddition -> change.item.fold(false) { acc, n -> upsertStream(n) || acc }
is ListChange.SetDeletion -> change.item.fold(false) { acc, n -> removeStream(n.idHex) || acc }
}
}
private fun upsertStream(note: Note): Boolean {
val c = contributionFromStreamZap(note) ?: return false
val prev = streamContributions[c.receiptId]
if (prev == c) return false
streamContributions[c.receiptId] = c
return true
}
private fun removeStream(receiptId: HexKey): Boolean = streamContributions.remove(receiptId) != null
private suspend fun rebuildGoalContributions(goal: Note?) {
mutex.withLock {
goalContributions.clear()
goal?.zaps?.forEach { (zapRequestNote, receiptNote) ->
contributionFromGoalZap(zapRequestNote, receiptNote)?.let {
goalContributions[it.receiptId] = it
}
}
}
}
private fun publish() {
val merged = streamContributions.values + goalContributions.values
_topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit)
}
private fun contributionFromStreamZap(note: Note): ZapContribution? {
val ev = note.event as? LnZapEvent ?: return null
val request = ev.zapRequest ?: return null
val sats = ev.amount()?.toLong() ?: return null
return ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
}
private fun contributionFromGoalZap(
zapRequestNote: Note,
receiptNote: Note?,
): ZapContribution? {
val receiptEv = receiptNote?.event as? LnZapEvent ?: return null
val request = zapRequestNote.event as? LnZapRequestEvent ?: return null
val sats = receiptEv.amount()?.toLong() ?: return null
return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats)
}
}
/** True for both public anonymous and NIP-57 private zaps (any `anon` tag, empty or encrypted). */
private fun LnZapRequestEvent.isAnonTagged(): Boolean = tags.any { it.isNotEmpty() && it[0] == "anon" }
@@ -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())
}
}
@@ -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<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
PubKeyHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints(): List<PubKeyHint> = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys(): List<HexKey> = 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<LiveActivitiesEvent>,
videoUrl: String,
title: String,
host: HexKey = activity.event.pubKey,
caption: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesClipEvent>.() -> 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()
}
}
}
@@ -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<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = 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<LiveActivitiesEvent>,
to: EventHintBundle<LiveActivitiesEvent>,
message: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesRaidEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
addMarkedATag(from, MARKER_ROOT)
addMarkedATag(to, MARKER_MENTION)
initializer()
}
private fun TagArrayBuilder<LiveActivitiesRaidEvent>.addMarkedATag(
bundle: EventHintBundle<LiveActivitiesEvent>,
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))
}
}
}
@@ -117,6 +117,12 @@ class LiveActivitiesEvent(
fun pinned() = tags.mapNotNull(PinnedEventTag::parse) 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", "<hex event id>"]` 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? = fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) { if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
StatusTag.STATUS.ENDED StatusTag.STATUS.ENDED
@@ -139,6 +145,7 @@ class LiveActivitiesEvent(
companion object { companion object {
const val KIND = 30311 const val KIND = 30311
const val ALT = "Live activity event" const val ALT = "Live activity event"
const val GOAL_TAG = "goal"
suspend fun create( suspend fun create(
signer: NostrSigner, signer: NostrSigner,
@@ -165,9 +165,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent 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.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent 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.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -436,7 +438,9 @@ class EventFactory {
KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig) KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig)
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(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) 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) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.clip
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesClipEventTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val viewerAuthor = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
private val dummySig = "0".repeat(128)
@Test
fun parsesZapStreamShapedClip() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$host:stream-d", "wss://relay.example"),
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
arrayOf("alt", "Live stream clip"),
),
content = "Check this out",
sig = dummySig,
)
val activity = assertNotNull(event.activity())
assertEquals(LiveActivitiesEvent.KIND, activity.kind)
assertEquals(host, activity.pubKeyHex)
assertEquals("stream-d", activity.dTag)
assertEquals(host, event.host())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
assertEquals("Nice moment", event.title())
assertEquals("Check this out", event.content)
}
@Test
fun ignoresClipLackingStreamReference() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
),
content = "",
sig = dummySig,
)
assertNull(event.activity())
assertNull(event.activityAddress())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
}
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.raid
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesRaidEventTest {
private val sourceHost = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val targetHost = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
private val author = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
private val dummySig = "0".repeat(128)
@Test
fun parsesRootAndMentionAddresses() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:source-d", "wss://relay.example", "root"),
arrayOf("a", "${LiveActivitiesEvent.KIND}:$targetHost:target-d", "", "mention"),
),
content = "Heading over to stream!",
sig = dummySig,
)
val from = assertNotNull(event.fromAddress())
assertEquals(LiveActivitiesEvent.KIND, from.kind)
assertEquals(sourceHost, from.pubKeyHex)
assertEquals("source-d", from.dTag)
val to = assertNotNull(event.toAddress())
assertEquals(LiveActivitiesEvent.KIND, to.kind)
assertEquals(targetHost, to.pubKeyHex)
assertEquals("target-d", to.dTag)
}
@Test
fun ignoresUnmarkedOrWrongKindATags() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
// Wrong marker
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:x", "", "reply"),
// Wrong kind
arrayOf("a", "30023:$sourceHost:article", "", "root"),
// No marker at all
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:y"),
),
content = "",
sig = dummySig,
)
assertNull(event.fromAddress())
assertNull(event.toAddress())
}
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.streaming
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class LiveActivitiesEventGoalTagTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val goalId = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
private val dummySig = "0".repeat(128)
@Test
fun readsZapStreamGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("title", "My stream"),
arrayOf("goal", goalId),
),
content = "",
sig = dummySig,
)
assertEquals(goalId, event.goalEventId())
}
@Test
fun returnsNullWhenNoGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags = arrayOf(arrayOf("d", "stream-d")),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
@Test
fun returnsNullWhenGoalTagEmpty() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("goal", ""),
),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
}