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:
@@ -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,
|
||||
|
||||
+15
-17
@@ -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(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-15
@@ -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)
|
||||
|
||||
+6
-20
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -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,
|
||||
)
|
||||
}
|
||||
+30
-10
@@ -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
|
||||
|
||||
+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