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

Add live stream top zappers leaderboard and NIP-53 event types
This commit is contained in:
Vitor Pamplona
2026-04-20 21:11:00 -04:00
committed by GitHub
28 changed files with 1969 additions and 42 deletions
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.clip
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-53 live stream clip (zap.stream convention, kind 1313).
*
* A clip is a standalone highlight produced from an ongoing or past live stream.
* The event carries:
* - `a` -> the source stream address (kind 30311)
* - `p` -> the stream host's pubkey
* - `r` -> direct playable video URL (MP4/HLS)
* - `title` -> clip title
* - `alt` -> NIP-31 fallback text
*
* `content` is an optional free-text caption.
*/
@Immutable
class LiveActivitiesClipEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
PubKeyHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints(): List<PubKeyHint> = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys(): List<HexKey> = tags.mapNotNull(PTag::parseKey)
fun activity(): ATag? =
tags
.asSequence()
.mapNotNull(ATag::parse)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
fun activityAddress(): Address? = activity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
fun host(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey)
fun videoUrl(): String? = tags.firstNotNullOfOrNull(ReferenceTag::parse)
fun title(): String? = tags.firstNotNullOfOrNull(TitleTag::parse)
companion object {
const val KIND = 1313
const val ALT = "Live activity clip"
/**
* Builds an event template for a clip. Typically published by the clip-authoring backend
* on behalf of a viewer, but can also be published directly by a client.
*/
fun build(
activity: EventHintBundle<LiveActivitiesEvent>,
videoUrl: String,
title: String,
host: HexKey = activity.event.pubKey,
caption: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesClipEvent>.() -> Unit = {},
) = eventTemplate(KIND, caption, createdAt) {
aTag(ATag(activity.event.kind, activity.event.pubKey, activity.event.dTag(), activity.relay))
add(PTag.assemble(host, null))
add(ReferenceTag.assemble(videoUrl))
add(TitleTag.assemble(title))
add(AltTag.assemble(ALT))
initializer()
}
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.raid
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-53 live stream raid (zap.stream convention, kind 1312).
*
* A raid is authored by the source streamer to redirect viewers to another live
* stream. The event carries two `a` tags referencing NIP-53 Live Activities
* (kind 30311) differentiated by NIP-10-style markers at position 3:
* - "root" -> the source stream (the raid is being sent FROM)
* - "mention" -> the target stream (the raid is being sent TO)
*
* The `content` is a free-text raid message from the source streamer.
*/
@Immutable
class LiveActivitiesRaidEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
fun fromActivity(): ATag? = findActivity(MARKER_ROOT)
fun toActivity(): ATag? = findActivity(MARKER_MENTION)
fun fromAddress(): Address? = fromActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
fun toAddress(): Address? = toActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
private fun findActivity(marker: String): ATag? =
tags
.asSequence()
.filter { it.size > 3 && it[0] == ATag.TAG_NAME && it[3] == marker }
.mapNotNull(ATag::parse)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
companion object {
const val KIND = 1312
const val ALT = "Live activity raid"
const val MARKER_ROOT = "root"
const val MARKER_MENTION = "mention"
/**
* Builds an event template for a raid. Sender must be the host of the `from` stream.
*
* @param from source stream (the one currently live that is being ended/redirected)
* @param to target stream (where viewers should be redirected)
* @param message raid announcement text
*/
fun build(
from: EventHintBundle<LiveActivitiesEvent>,
to: EventHintBundle<LiveActivitiesEvent>,
message: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesRaidEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
addMarkedATag(from, MARKER_ROOT)
addMarkedATag(to, MARKER_MENTION)
initializer()
}
private fun TagArrayBuilder<LiveActivitiesRaidEvent>.addMarkedATag(
bundle: EventHintBundle<LiveActivitiesEvent>,
marker: String,
) {
val relayUrl = bundle.relay?.url.orEmpty()
val addressId =
Address.assemble(
LiveActivitiesEvent.KIND,
bundle.event.pubKey,
bundle.event.dTag(),
)
add(arrayOf(ATag.TAG_NAME, addressId, relayUrl, marker))
}
}
}
@@ -117,6 +117,12 @@ class LiveActivitiesEvent(
fun pinned() = tags.mapNotNull(PinnedEventTag::parse)
/**
* zap.stream convention: a NIP-75 zap goal (kind 9041) is attached to a live stream
* via a flat tag `["goal", "<hex event id>"]` on the 30311 event.
*/
fun goalEventId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == GOAL_TAG && it[1].isNotEmpty() }?.get(1)
fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
StatusTag.STATUS.ENDED
@@ -139,6 +145,7 @@ class LiveActivitiesEvent(
companion object {
const val KIND = 30311
const val ALT = "Live activity event"
const val GOAL_TAG = "goal"
suspend fun create(
signer: NostrSigner,
@@ -165,9 +165,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -436,7 +438,9 @@ class EventFactory {
KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig)
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.clip
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesClipEventTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val viewerAuthor = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
private val dummySig = "0".repeat(128)
@Test
fun parsesZapStreamShapedClip() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$host:stream-d", "wss://relay.example"),
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
arrayOf("alt", "Live stream clip"),
),
content = "Check this out",
sig = dummySig,
)
val activity = assertNotNull(event.activity())
assertEquals(LiveActivitiesEvent.KIND, activity.kind)
assertEquals(host, activity.pubKeyHex)
assertEquals("stream-d", activity.dTag)
assertEquals(host, event.host())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
assertEquals("Nice moment", event.title())
assertEquals("Check this out", event.content)
}
@Test
fun ignoresClipLackingStreamReference() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
),
content = "",
sig = dummySig,
)
assertNull(event.activity())
assertNull(event.activityAddress())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
}
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.raid
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesRaidEventTest {
private val sourceHost = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val targetHost = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
private val author = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
private val dummySig = "0".repeat(128)
@Test
fun parsesRootAndMentionAddresses() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:source-d", "wss://relay.example", "root"),
arrayOf("a", "${LiveActivitiesEvent.KIND}:$targetHost:target-d", "", "mention"),
),
content = "Heading over to stream!",
sig = dummySig,
)
val from = assertNotNull(event.fromAddress())
assertEquals(LiveActivitiesEvent.KIND, from.kind)
assertEquals(sourceHost, from.pubKeyHex)
assertEquals("source-d", from.dTag)
val to = assertNotNull(event.toAddress())
assertEquals(LiveActivitiesEvent.KIND, to.kind)
assertEquals(targetHost, to.pubKeyHex)
assertEquals("target-d", to.dTag)
}
@Test
fun ignoresUnmarkedOrWrongKindATags() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
// Wrong marker
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:x", "", "reply"),
// Wrong kind
arrayOf("a", "30023:$sourceHost:article", "", "root"),
// No marker at all
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:y"),
),
content = "",
sig = dummySig,
)
assertNull(event.fromAddress())
assertNull(event.toAddress())
}
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.streaming
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class LiveActivitiesEventGoalTagTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val goalId = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
private val dummySig = "0".repeat(128)
@Test
fun readsZapStreamGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("title", "My stream"),
arrayOf("goal", goalId),
),
content = "",
sig = dummySig,
)
assertEquals(goalId, event.goalEventId())
}
@Test
fun returnsNullWhenNoGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags = arrayOf(arrayOf("d", "stream-d")),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
@Test
fun returnsNullWhenGoalTagEmpty() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("goal", ""),
),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
}