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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+29
-14
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -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)
|
||||
+133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler(
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? =
|
||||
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
|
||||
|
||||
+66
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+131
@@ -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))
|
||||
}
|
||||
+171
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-10
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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()
|
||||
}
|
||||
|
||||
+18
-11
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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_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="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_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>
|
||||
|
||||
+63
@@ -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,
|
||||
)
|
||||
}
|
||||
+106
@@ -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()
|
||||
}
|
||||
}
|
||||
+173
@@ -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" }
|
||||
+153
@@ -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())
|
||||
}
|
||||
}
|
||||
+114
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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", "<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? =
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+85
@@ -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())
|
||||
}
|
||||
}
|
||||
+85
@@ -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())
|
||||
}
|
||||
}
|
||||
+85
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user