refactor(live-chat): polish NIP-53 chat renderers from self-review

- Extract a shared StreamSystemCard composable for the rounded accent
  container used by zap, raid, and clip cards so they share one visual
  language.
- Unify clip caption rendering through CrossfadeToDisplayComment so
  captions get the same NIP-19 / emoji treatment as zap and raid
  messages.
- Iterate every `a` tag when routing zap receipts into live-activity
  channels so a receipt referencing multiple simulcasted streams lands
  in each of them, and fold the host match into the same pass.
- Bucket anonymous and private zaps under a single sentinel key in the
  Top Zappers aggregator so an "Anonymous" chip shows once with the
  combined total instead of one chip per one-time pubkey.
- Add commonTest coverage for LiveActivitiesRaidEvent marker parsing,
  LiveActivitiesClipEvent tag parsing, and LiveActivitiesEvent.goalEventId().

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
This commit is contained in:
Claude
2026-04-20 23:29:05 +00:00
parent 003603fd81
commit 766274d0b2
9 changed files with 516 additions and 196 deletions
@@ -1596,18 +1596,21 @@ object LocalCache : ILocalCache, ICacheProvider {
note: Note,
relay: NormalizedRelayUrl?,
) {
val address =
// 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)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
?: return
// Match zap.stream: only show zaps whose receiver is the live activity host
if (event.zappedAuthor().none { it == address.pubKeyHex }) return
.filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex in hosts }
.distinct()
.forEach { address ->
getOrCreateLiveChannel(address).addNote(note, relay)
}
}
fun consume(
event: LnZapRequestEvent,
@@ -20,21 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.background
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -42,6 +39,7 @@ import com.vitorpamplona.amethyst.R
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
@@ -64,18 +62,12 @@ fun RenderChatClip(
val authorHex = remember(clip) { clip.pubKey }
val accent = MaterialTheme.colorScheme.primary
val containerColor = remember(accent) { accent.copy(alpha = 0.12f) }
val accentAlpha = 0.12f
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(containerColor)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
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)
@@ -102,7 +94,12 @@ fun RenderChatClip(
val caption = clip.content
if (caption.isNotBlank()) {
Text(text = caption)
CrossfadeToDisplayComment(
comment = caption,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel,
)
}
if (!videoUrl.isNullOrBlank()) {
@@ -116,4 +113,5 @@ fun RenderChatClip(
)
}
}
}
}
@@ -20,15 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material3.Icon
@@ -40,7 +36,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -73,18 +68,15 @@ fun RenderChatRaid(
if (from == null || to == null) return
val accent = MaterialTheme.colorScheme.primary
val containerColor = remember(accent) { accent.copy(alpha = 0.14f) }
val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) }
StreamSystemCard(
accent = accent,
accentAlpha = 0.14f,
onClick = { nav.nav(to.toRoute()) },
) {
val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = 0.14f)) }
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(containerColor)
.clickable { nav.nav(to.toRoute()) }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -142,6 +134,7 @@ fun RenderChatRaid(
modifier = Size20Modifier,
)
}
}
}
private fun Address.toRoute(): Route = Route.LiveActivityChannel(kind, pubKeyHex, dTag)
@@ -20,14 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.background
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -36,7 +33,6 @@ import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -79,25 +75,14 @@ fun RenderChatZap(
(zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS
}
val containerColor =
if (isBigZap) {
BitcoinOrange.copy(alpha = 0.18f)
} else {
BitcoinOrange.copy(alpha = 0.08f)
}
val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) }
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
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(containerColor)
.padding(horizontal = 10.dp, vertical = 6.dp),
modifier = Modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
@@ -143,4 +128,5 @@ fun RenderChatZap(
}
}
}
}
}
@@ -0,0 +1,65 @@
/*
* 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.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).
*
* Keeps the three renderers visually consistent so they clearly stand apart
* from regular chat bubbles. The content slot uses a BoxScope so callers can
* pick their own internal layout (Row / Column / whatever).
*/
@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,
)
}
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
@@ -53,6 +54,7 @@ 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
@@ -63,9 +65,13 @@ import java.math.BigDecimal
private const val TOP_ZAPPERS_LIMIT = 10
/** Sentinel key used to bucket every anonymous/private zap into a single leaderboard entry. */
private const val ANON_KEY = "anon"
private data class TopZapperEntry(
val zapperPubKey: HexKey,
val totalSats: BigDecimal,
val isAnonymous: Boolean,
)
/**
@@ -137,26 +143,37 @@ private fun ZapperPill(
accountViewModel: AccountViewModel,
nav: INav,
) {
val clickModifier =
if (entry.isAnonymous) {
Modifier
} else {
Modifier.clickable { nav.nav(Route.Profile(entry.zapperPubKey)) }
}
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier =
Modifier.clickable {
nav.nav(Route.Profile(entry.zapperPubKey))
},
modifier = clickModifier,
) {
Row(
modifier = Modifier.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.zapperPubKey,
size = Size24dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
ZapIcon(Size16Modifier, BitcoinOrange)
Text(
text = showAmountInteger(entry.totalSats),
@@ -171,25 +188,25 @@ private fun aggregateTopZappers(
channel: LiveActivitiesChannel,
goalNote: Note?,
): List<TopZapperEntry> {
// receiptId -> (zapperPubKey, sats) — dedupes a zap that appears via both #a and #e.
// receiptId -> (zapperBucketKey, sats) — dedupes a zap that appears via both #a and #e.
val byReceipt = HashMap<HexKey, Pair<HexKey, BigDecimal>>()
// Stream-level zaps routed to the channel cache.
channel.notes.forEach { _, note ->
val ev = note.event
if (ev is LnZapEvent) {
val zapper = ev.zapRequest?.pubKey ?: return@forEach
val request = ev.zapRequest ?: return@forEach
val sats = ev.amount() ?: return@forEach
byReceipt[note.idHex] = zapper to sats
byReceipt[note.idHex] = bucketKeyFor(request) to sats
}
}
// Goal-scoped zaps attached to the goal note via #e.
goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) ->
val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach
val zapper = (zapRequestNote.event as? LnZapRequestEvent)?.pubKey ?: return@forEach
val request = zapRequestNote.event as? LnZapRequestEvent ?: return@forEach
val sats = receiptEv.amount() ?: return@forEach
byReceipt[receiptNote.idHex] = zapper to sats
byReceipt[receiptNote.idHex] = bucketKeyFor(request) to sats
}
if (byReceipt.isEmpty()) return emptyList()
@@ -203,6 +220,9 @@ private fun aggregateTopZappers(
.asSequence()
.sortedByDescending { it.value }
.take(TOP_ZAPPERS_LIMIT)
.map { TopZapperEntry(it.key, it.value) }
.map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY) }
.toList()
}
/** Returns the aggregation key for a zap request: real pubkey, or the anon sentinel for any `anon`-tagged zap. */
private fun bucketKeyFor(request: LnZapRequestEvent): HexKey = if (request.tags.any { it.isNotEmpty() && it[0] == "anon" }) ANON_KEY else request.pubKey
@@ -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())
}
}