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.calendar.CalendarEvent
|
||||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||||
@@ -1495,6 +1497,44 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
return new
|
return new
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun consume(
|
||||||
|
event: LiveActivitiesRaidEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
wasVerified: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
val fromAddress = event.fromAddress()
|
||||||
|
val toAddress = event.toAddress()
|
||||||
|
if (fromAddress == null && toAddress == null) return false
|
||||||
|
|
||||||
|
val new = consumeRegularEvent(event, relay, wasVerified)
|
||||||
|
|
||||||
|
if (new) {
|
||||||
|
val note = getOrCreateNote(event.id)
|
||||||
|
fromAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) }
|
||||||
|
toAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(
|
||||||
|
event: LiveActivitiesClipEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
wasVerified: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
val activityAddress = event.activityAddress() ?: return false
|
||||||
|
|
||||||
|
val new = consumeRegularEvent(event, relay, wasVerified)
|
||||||
|
|
||||||
|
if (new) {
|
||||||
|
val channel = getOrCreateLiveChannel(activityAddress)
|
||||||
|
val note = getOrCreateNote(event.id)
|
||||||
|
channel.addNote(note, relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun consume(
|
fun consume(
|
||||||
event: ChannelHideMessageEvent,
|
event: ChannelHideMessageEvent,
|
||||||
@@ -1515,8 +1555,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
wasVerified: Boolean,
|
wasVerified: Boolean,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val note = getOrCreateNote(event.id)
|
val note = getOrCreateNote(event.id)
|
||||||
// Already processed this event.
|
|
||||||
if (note.event != null) return false
|
// Already processed this event — still ensure it's routed into any live-activity
|
||||||
|
// channel(s) it references. A zap that was first consumed by, e.g., the notifications
|
||||||
|
// subscription must still appear in the stream's chat when the user opens the stream.
|
||||||
|
if (note.event != null) {
|
||||||
|
attachZapToLiveActivityChannel(event, note, relay)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (wasVerified || justVerify(event)) {
|
if (wasVerified || justVerify(event)) {
|
||||||
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
|
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
|
||||||
@@ -1541,6 +1587,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
|
|
||||||
repliesTo.forEach { it.addZap(zapRequest, note) }
|
repliesTo.forEach { it.addZap(zapRequest, note) }
|
||||||
|
|
||||||
|
attachZapToLiveActivityChannel(event, note, relay)
|
||||||
|
|
||||||
refreshNewNoteObservers(note)
|
refreshNewNoteObservers(note)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -1549,6 +1597,27 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun attachZapToLiveActivityChannel(
|
||||||
|
event: LnZapEvent,
|
||||||
|
note: Note,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
) {
|
||||||
|
// Match zap.stream: only show zaps whose receiver is the live activity host.
|
||||||
|
val hosts = event.zappedAuthor().toHashSet()
|
||||||
|
if (hosts.isEmpty()) return
|
||||||
|
|
||||||
|
// Route into every live-activity address this zap references (zap.stream uses one, but
|
||||||
|
// a receipt could legitimately reference multiple simulcasted streams).
|
||||||
|
event.tags
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull(ATag::parseAddress)
|
||||||
|
.filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex in hosts }
|
||||||
|
.distinct()
|
||||||
|
.forEach { address ->
|
||||||
|
getOrCreateLiveChannel(address).addNote(note, relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun consume(
|
fun consume(
|
||||||
event: LnZapRequestEvent,
|
event: LnZapRequestEvent,
|
||||||
relay: NormalizedRelayUrl?,
|
relay: NormalizedRelayUrl?,
|
||||||
@@ -2647,6 +2716,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
|
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
|
||||||
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
|
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
|
||||||
|
is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified)
|
||||||
|
is LiveActivitiesClipEvent -> consume(event, relay, wasVerified)
|
||||||
is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
|
|||||||
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||||
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
|
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||||
@@ -250,6 +251,7 @@ import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
|
|||||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||||
@@ -938,6 +940,10 @@ private fun RenderNoteRow(
|
|||||||
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
|
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is LiveActivitiesClipEvent -> {
|
||||||
|
RenderChatClip(baseNote, accountViewModel, nav)
|
||||||
|
}
|
||||||
|
|
||||||
is FhirResourceEvent -> {
|
is FhirResourceEvent -> {
|
||||||
RenderFhirResource(baseNote, accountViewModel, nav)
|
RenderFhirResource(baseNote, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|||||||
+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.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
|
||||||
@@ -84,6 +87,9 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
|
|||||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
|
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||||
|
|
||||||
@@ -109,20 +115,29 @@ fun ChatroomMessageCompose(
|
|||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
) { canPreview ->
|
) { canPreview ->
|
||||||
NormalChatNote(
|
val event = baseNote.event
|
||||||
baseNote,
|
if (event is LnZapEvent) {
|
||||||
routeForLastRead,
|
RenderChatZap(baseNote, accountViewModel, nav)
|
||||||
innerQuote,
|
} else if (event is LiveActivitiesRaidEvent) {
|
||||||
canPreview,
|
RenderChatRaid(baseNote, accountViewModel, nav)
|
||||||
parentBackgroundColor,
|
} else if (event is LiveActivitiesClipEvent) {
|
||||||
accountViewModel,
|
RenderChatClip(baseNote, accountViewModel, nav)
|
||||||
nav,
|
} else {
|
||||||
onWantsToReply,
|
NormalChatNote(
|
||||||
onWantsToEditDraft,
|
baseNote,
|
||||||
onScrollToNote,
|
routeForLastRead,
|
||||||
shouldHighlight,
|
innerQuote,
|
||||||
onHighlightFinished,
|
canPreview,
|
||||||
)
|
parentBackgroundColor,
|
||||||
|
accountViewModel,
|
||||||
|
nav,
|
||||||
|
onWantsToReply,
|
||||||
|
onWantsToEditDraft,
|
||||||
|
onScrollToNote,
|
||||||
|
shouldHighlight,
|
||||||
|
onHighlightFinished,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
@@ -40,4 +44,18 @@ fun ChannelFilterAssemblerSubscription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
KeyDataSourceSubscription(state, dataSource)
|
KeyDataSourceSubscription(state, dataSource)
|
||||||
|
|
||||||
|
// Live streams need the 30311 event to populate `channel.info` before we can read its
|
||||||
|
// `goal` tag and add the goal+zap subscriptions. Re-invalidate when metadata changes so
|
||||||
|
// the goal filter fires as soon as the stream event arrives.
|
||||||
|
if (channel is LiveActivitiesChannel) {
|
||||||
|
val metadataState by channel
|
||||||
|
.flow()
|
||||||
|
.metadata.stateFlow
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val goalId = channel.info?.goalEventId()
|
||||||
|
LaunchedEffect(goalId, metadataState) {
|
||||||
|
dataSource.invalidateFilters()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler(
|
|||||||
since: SincePerRelayMap?,
|
since: SincePerRelayMap?,
|
||||||
): List<RelayBasedFilter>? =
|
): List<RelayBasedFilter>? =
|
||||||
when (val channel = key.channel) {
|
when (val channel = key.channel) {
|
||||||
is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since)
|
is EphemeralChatChannel -> {
|
||||||
is PublicChatChannel -> filterMessagesToPublicChat(channel, since)
|
filterMessagesToEphemeralChat(channel, since)
|
||||||
is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since)
|
}
|
||||||
else -> null
|
|
||||||
|
is PublicChatChannel -> {
|
||||||
|
filterMessagesToPublicChat(channel, since)
|
||||||
|
}
|
||||||
|
|
||||||
|
is LiveActivitiesChannel -> {
|
||||||
|
filterMessagesToLiveActivities(channel, since) +
|
||||||
|
filterGoalForLiveActivities(channel, since)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun id(key: ChannelQueryState) = key.channel
|
override fun id(key: ChannelQueryState) = key.channel
|
||||||
|
|||||||
+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.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
|
|
||||||
fun filterMessagesToLiveActivities(
|
fun filterMessagesToLiveActivities(
|
||||||
channel: LiveActivitiesChannel,
|
channel: LiveActivitiesChannel,
|
||||||
@@ -35,7 +38,13 @@ fun filterMessagesToLiveActivities(
|
|||||||
relay = it,
|
relay = it,
|
||||||
filter =
|
filter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
|
kinds =
|
||||||
|
listOf(
|
||||||
|
LiveActivitiesChatMessageEvent.KIND,
|
||||||
|
LiveActivitiesRaidEvent.KIND,
|
||||||
|
LiveActivitiesClipEvent.KIND,
|
||||||
|
LnZapEvent.KIND,
|
||||||
|
),
|
||||||
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
|
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
|
||||||
limit = 200,
|
limit = 200,
|
||||||
since = since?.get(it)?.time,
|
since = since?.get(it)?.time,
|
||||||
|
|||||||
+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.feed.RefreshingChatroomFeedView
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||||
@@ -127,6 +129,8 @@ fun LiveActivityChannelView(
|
|||||||
.weight(1f, true),
|
.weight(1f, true),
|
||||||
) {
|
) {
|
||||||
ShowVideoStreaming(channel, accountViewModel)
|
ShowVideoStreaming(channel, accountViewModel)
|
||||||
|
LiveStreamTopZappers(channel, accountViewModel, nav)
|
||||||
|
LiveStreamGoalHeader(channel, accountViewModel, nav)
|
||||||
RefreshingChatroomFeedView(
|
RefreshingChatroomFeedView(
|
||||||
feedContentState = feedViewModel.feedState,
|
feedContentState = feedViewModel.feedState,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
|
|||||||
+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.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||||
@@ -51,16 +52,30 @@ fun filterUserProfileMedia(
|
|||||||
?: user.allUsedRelaysOrNull()
|
?: user.allUsedRelaysOrNull()
|
||||||
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
|
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
|
||||||
|
|
||||||
return relays.map { relay ->
|
return relays.flatMap { relay ->
|
||||||
RelayBasedFilter(
|
listOf(
|
||||||
relay = relay,
|
RelayBasedFilter(
|
||||||
filter =
|
relay = relay,
|
||||||
Filter(
|
filter =
|
||||||
kinds = UserProfileMediaKinds,
|
Filter(
|
||||||
authors = listOf(user.pubkeyHex),
|
kinds = UserProfileMediaKinds,
|
||||||
limit = 200,
|
authors = listOf(user.pubkeyHex),
|
||||||
since = since?.get(relay)?.time,
|
limit = 200,
|
||||||
),
|
since = since?.get(relay)?.time,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Clips are authored by viewers but carry a `p` tag identifying the stream host.
|
||||||
|
// Fetch clips OF this profile's streams so they surface in their gallery.
|
||||||
|
RelayBasedFilter(
|
||||||
|
relay = relay,
|
||||||
|
filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(LiveActivitiesClipEvent.KIND),
|
||||||
|
tags = mapOf("p" to listOf(user.pubkeyHex)),
|
||||||
|
limit = 100,
|
||||||
|
since = since?.get(relay)?.time,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||||
|
|
||||||
@@ -130,6 +131,21 @@ fun GalleryThumbnail(
|
|||||||
thumbhash = imeta.thumbhash,
|
thumbhash = imeta.thumbhash,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (noteEvent is LiveActivitiesClipEvent) {
|
||||||
|
noteEvent.videoUrl()?.let { url ->
|
||||||
|
listOf(
|
||||||
|
MediaUrlVideo(
|
||||||
|
url = url,
|
||||||
|
description = noteEvent.title() ?: noteEvent.content,
|
||||||
|
hash = null,
|
||||||
|
blurhash = null,
|
||||||
|
dim = null,
|
||||||
|
uri = null,
|
||||||
|
mimeType = null,
|
||||||
|
thumbhash = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} ?: emptyList()
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
+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.DefaultFeedOrder
|
||||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
|
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent
|
import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent
|
||||||
@@ -75,17 +76,23 @@ class UserProfileGalleryFeedFilter(
|
|||||||
user: User,
|
user: User,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val noteEvent = it.event
|
val noteEvent = it.event
|
||||||
return (
|
val authoredByUser =
|
||||||
(
|
it.event?.pubKey == user.pubkeyHex &&
|
||||||
it.event?.pubKey == user.pubkeyHex &&
|
(
|
||||||
(
|
noteEvent is PictureEvent ||
|
||||||
noteEvent is PictureEvent ||
|
noteEvent is RegularVideoEvent ||
|
||||||
noteEvent is RegularVideoEvent ||
|
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
|
||||||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
|
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
|
||||||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
|
)
|
||||||
)
|
|
||||||
) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
|
// Clips are authored by viewers, not the host — accept them when they reference
|
||||||
) &&
|
// a stream hosted by this user AND carry a playable URL.
|
||||||
|
val clipOfUsersStream =
|
||||||
|
noteEvent is LiveActivitiesClipEvent &&
|
||||||
|
noteEvent.host() == user.pubkeyHex &&
|
||||||
|
!noteEvent.videoUrl().isNullOrBlank()
|
||||||
|
|
||||||
|
return (authoredByUser || clipOfUsersStream) &&
|
||||||
params.match(noteEvent, it.relays) &&
|
params.match(noteEvent, it.relays) &&
|
||||||
account.isAcceptable(it)
|
account.isAcceptable(it)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">You are using a public key and public keys are read-only. Login with a Private key to be able to boost posts</string>
|
<string name="login_with_a_private_key_to_be_able_to_boost_posts">You are using a public key and public keys are read-only. Login with a Private key to be able to boost posts</string>
|
||||||
<string name="login_with_a_private_key_to_like_posts">You are using a public key and public keys are read-only. Login with a Private key to like posts</string>
|
<string name="login_with_a_private_key_to_like_posts">You are using a public key and public keys are read-only. Login with a Private key to like posts</string>
|
||||||
<string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string>
|
<string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string>
|
||||||
|
<string name="chat_zap_amount_suffix">zapped %1$s sats</string>
|
||||||
|
<string name="chat_zap_anonymous">Anonymous</string>
|
||||||
|
<string name="chat_raid_is_raiding">is raiding</string>
|
||||||
|
<string name="chat_clip_created_a_clip">created a clip</string>
|
||||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps</string>
|
<string name="login_with_a_private_key_to_be_able_to_send_zaps">You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps</string>
|
||||||
<string name="login_with_a_private_key_to_be_able_to_follow">You are using a public key and public keys are read-only. Login with a Private key to be able to follow</string>
|
<string name="login_with_a_private_key_to_be_able_to_follow">You are using a public key and public keys are read-only. Login with a Private key to be able to follow</string>
|
||||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string>
|
<string name="login_with_a_private_key_to_be_able_to_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string>
|
||||||
|
|||||||
+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)
|
fun pinned() = tags.mapNotNull(PinnedEventTag::parse)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* zap.stream convention: a NIP-75 zap goal (kind 9041) is attached to a live stream
|
||||||
|
* via a flat tag `["goal", "<hex event id>"]` on the 30311 event.
|
||||||
|
*/
|
||||||
|
fun goalEventId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == GOAL_TAG && it[1].isNotEmpty() }?.get(1)
|
||||||
|
|
||||||
fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
|
fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
|
||||||
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
|
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
|
||||||
StatusTag.STATUS.ENDED
|
StatusTag.STATUS.ENDED
|
||||||
@@ -139,6 +145,7 @@ class LiveActivitiesEvent(
|
|||||||
companion object {
|
companion object {
|
||||||
const val KIND = 30311
|
const val KIND = 30311
|
||||||
const val ALT = "Live activity event"
|
const val ALT = "Live activity event"
|
||||||
|
const val GOAL_TAG = "goal"
|
||||||
|
|
||||||
suspend fun create(
|
suspend fun create(
|
||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
|
|||||||
@@ -165,9 +165,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
|||||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||||
@@ -436,7 +438,9 @@ class EventFactory {
|
|||||||
KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig)
|
KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
|
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
|
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
|||||||
+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