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,17 +1596,20 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
note: Note,
|
note: Note,
|
||||||
relay: NormalizedRelayUrl?,
|
relay: NormalizedRelayUrl?,
|
||||||
) {
|
) {
|
||||||
val address =
|
// Match zap.stream: only show zaps whose receiver is the live activity host.
|
||||||
event.tags
|
val hosts = event.zappedAuthor().toHashSet()
|
||||||
.asSequence()
|
if (hosts.isEmpty()) return
|
||||||
.mapNotNull(ATag::parseAddress)
|
|
||||||
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
|
|
||||||
?: return
|
|
||||||
|
|
||||||
// Match zap.stream: only show zaps whose receiver is the live activity host
|
// Route into every live-activity address this zap references (zap.stream uses one, but
|
||||||
if (event.zappedAuthor().none { it == address.pubKeyHex }) return
|
// a receipt could legitimately reference multiple simulcasted streams).
|
||||||
|
event.tags
|
||||||
getOrCreateLiveChannel(address).addNote(note, relay)
|
.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(
|
||||||
|
|||||||
+47
-49
@@ -20,21 +20,18 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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.model.Note
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.UserPicture
|
||||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
@@ -64,56 +62,56 @@ fun RenderChatClip(
|
|||||||
val authorHex = remember(clip) { clip.pubKey }
|
val authorHex = remember(clip) { clip.pubKey }
|
||||||
|
|
||||||
val accent = MaterialTheme.colorScheme.primary
|
val accent = MaterialTheme.colorScheme.primary
|
||||||
val containerColor = remember(accent) { accent.copy(alpha = 0.12f) }
|
val accentAlpha = 0.12f
|
||||||
|
|
||||||
Column(
|
StreamSystemCard(accent = accent, accentAlpha = accentAlpha) {
|
||||||
modifier =
|
val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = accentAlpha)) }
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
.clip(RoundedCornerShape(8.dp))
|
UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav)
|
||||||
.background(containerColor)
|
Spacer(StdHorzSpacer)
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user ->
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
if (user != null) {
|
||||||
) {
|
UsernameDisplay(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
baseUser = user,
|
||||||
UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav)
|
fontWeight = FontWeight.Bold,
|
||||||
Spacer(StdHorzSpacer)
|
accountViewModel = accountViewModel,
|
||||||
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(StdHorzSpacer)
|
|
||||||
Text(
|
|
||||||
text = stringRes(R.string.chat_clip_created_a_clip),
|
|
||||||
color = accent,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title.isNullOrBlank()) {
|
if (!title.isNullOrBlank()) {
|
||||||
Text(text = title)
|
Text(text = title)
|
||||||
}
|
}
|
||||||
|
|
||||||
val caption = clip.content
|
val caption = clip.content
|
||||||
if (caption.isNotBlank()) {
|
if (caption.isNotBlank()) {
|
||||||
Text(text = caption)
|
CrossfadeToDisplayComment(
|
||||||
}
|
comment = caption,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
nav = nav,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!videoUrl.isNullOrBlank()) {
|
if (!videoUrl.isNullOrBlank()) {
|
||||||
VideoView(
|
VideoView(
|
||||||
videoUri = videoUrl,
|
videoUri = videoUrl,
|
||||||
mimeType = null,
|
mimeType = null,
|
||||||
title = title,
|
title = title,
|
||||||
roundedCorner = true,
|
roundedCorner = true,
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.FillWidth,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-64
@@ -20,15 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
|
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -40,7 +36,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
@@ -73,74 +68,72 @@ fun RenderChatRaid(
|
|||||||
if (from == null || to == null) return
|
if (from == null || to == null) return
|
||||||
|
|
||||||
val accent = MaterialTheme.colorScheme.primary
|
val accent = MaterialTheme.colorScheme.primary
|
||||||
val containerColor = remember(accent) { accent.copy(alpha = 0.14f) }
|
|
||||||
val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) }
|
|
||||||
|
|
||||||
Row(
|
StreamSystemCard(
|
||||||
modifier =
|
accent = accent,
|
||||||
Modifier
|
accentAlpha = 0.14f,
|
||||||
.fillMaxWidth()
|
onClick = { nav.nav(to.toRoute()) },
|
||||||
.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),
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = 0.14f)) }
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav)
|
Row(
|
||||||
Spacer(StdHorzSpacer)
|
verticalAlignment = Alignment.Top,
|
||||||
LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user ->
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
if (user != null) {
|
) {
|
||||||
UsernameDisplay(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
baseUser = user,
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
fontWeight = FontWeight.Bold,
|
UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav)
|
||||||
accountViewModel = accountViewModel,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(StdHorzSpacer)
|
val message = raid.content
|
||||||
Text(
|
if (message.isNotBlank()) {
|
||||||
text = stringRes(R.string.chat_raid_is_raiding),
|
Spacer(Modifier.padding(top = 2.dp))
|
||||||
color = accent,
|
CrossfadeToDisplayComment(
|
||||||
fontWeight = FontWeight.Bold,
|
comment = message,
|
||||||
)
|
backgroundColor = backgroundColor,
|
||||||
Spacer(StdHorzSpacer)
|
nav = nav,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
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
|
Icon(
|
||||||
if (message.isNotBlank()) {
|
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
|
||||||
Spacer(Modifier.padding(top = 2.dp))
|
contentDescription = null,
|
||||||
CrossfadeToDisplayComment(
|
tint = accent,
|
||||||
comment = message,
|
modifier = Size20Modifier,
|
||||||
backgroundColor = backgroundColor,
|
)
|
||||||
nav = nav,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = accent,
|
|
||||||
modifier = Size20Modifier,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
-57
@@ -20,14 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -36,7 +33,6 @@ import androidx.compose.runtime.produceState
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
@@ -79,67 +75,57 @@ fun RenderChatZap(
|
|||||||
(zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS
|
(zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS
|
||||||
}
|
}
|
||||||
|
|
||||||
val containerColor =
|
val accentAlpha = if (isBigZap) 0.18f else 0.08f
|
||||||
if (isBigZap) {
|
|
||||||
BitcoinOrange.copy(alpha = 0.18f)
|
|
||||||
} else {
|
|
||||||
BitcoinOrange.copy(alpha = 0.08f)
|
|
||||||
}
|
|
||||||
|
|
||||||
val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) }
|
|
||||||
|
|
||||||
val amountText = card.amount ?: showAmountInteger(zapEvent.amount)
|
val amountText = card.amount ?: showAmountInteger(zapEvent.amount)
|
||||||
|
|
||||||
Row(
|
StreamSystemCard(accent = BitcoinOrange, accentAlpha = accentAlpha) {
|
||||||
modifier =
|
val backgroundColor = remember(accentAlpha) { mutableStateOf(BitcoinOrange.copy(alpha = accentAlpha)) }
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
Row(
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
modifier = Modifier,
|
||||||
.clip(RoundedCornerShape(8.dp))
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
.background(containerColor)
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
ZapIcon(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
if (isBigZap) Size24Modifier else Size20Modifier,
|
||||||
) {
|
BitcoinOrange,
|
||||||
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
val sender = card.user
|
|
||||||
if (sender != null) {
|
|
||||||
UserPicture(sender, Size20dp, Modifier, accountViewModel, nav)
|
|
||||||
Spacer(StdHorzSpacer)
|
Spacer(StdHorzSpacer)
|
||||||
UsernameDisplay(
|
|
||||||
baseUser = sender,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(R.string.chat_zap_anonymous),
|
text = stringRes(R.string.chat_zap_amount_suffix, amountText),
|
||||||
fontWeight = FontWeight.Bold,
|
color = BitcoinOrange,
|
||||||
|
fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(StdHorzSpacer)
|
card.comment?.takeIf { it.isNotBlank() }?.let { comment ->
|
||||||
Text(
|
Spacer(Modifier.padding(top = 2.dp))
|
||||||
text = stringRes(R.string.chat_zap_amount_suffix, amountText),
|
CrossfadeToDisplayComment(
|
||||||
color = BitcoinOrange,
|
comment = comment,
|
||||||
fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal,
|
backgroundColor = backgroundColor,
|
||||||
)
|
nav = nav,
|
||||||
}
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
card.comment?.takeIf { it.isNotBlank() }?.let { comment ->
|
}
|
||||||
Spacer(Modifier.padding(top = 2.dp))
|
|
||||||
CrossfadeToDisplayComment(
|
|
||||||
comment = comment,
|
|
||||||
backgroundColor = backgroundColor,
|
|
||||||
nav = nav,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+36
-16
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
|
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.ZapIcon
|
||||||
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
|
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.BitcoinOrange
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||||
@@ -63,9 +65,13 @@ import java.math.BigDecimal
|
|||||||
|
|
||||||
private const val TOP_ZAPPERS_LIMIT = 10
|
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(
|
private data class TopZapperEntry(
|
||||||
val zapperPubKey: HexKey,
|
val zapperPubKey: HexKey,
|
||||||
val totalSats: BigDecimal,
|
val totalSats: BigDecimal,
|
||||||
|
val isAnonymous: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,26 +143,37 @@ private fun ZapperPill(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
|
val clickModifier =
|
||||||
|
if (entry.isAnonymous) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.clickable { nav.nav(Route.Profile(entry.zapperPubKey)) }
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
modifier =
|
modifier = clickModifier,
|
||||||
Modifier.clickable {
|
|
||||||
nav.nav(Route.Profile(entry.zapperPubKey))
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
UserPicture(
|
if (entry.isAnonymous) {
|
||||||
userHex = entry.zapperPubKey,
|
Text(
|
||||||
size = Size24dp,
|
text = stringRes(R.string.chat_zap_anonymous),
|
||||||
accountViewModel = accountViewModel,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
nav = nav,
|
)
|
||||||
)
|
} else {
|
||||||
|
UserPicture(
|
||||||
|
userHex = entry.zapperPubKey,
|
||||||
|
size = Size24dp,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
|
)
|
||||||
|
}
|
||||||
ZapIcon(Size16Modifier, BitcoinOrange)
|
ZapIcon(Size16Modifier, BitcoinOrange)
|
||||||
Text(
|
Text(
|
||||||
text = showAmountInteger(entry.totalSats),
|
text = showAmountInteger(entry.totalSats),
|
||||||
@@ -171,25 +188,25 @@ private fun aggregateTopZappers(
|
|||||||
channel: LiveActivitiesChannel,
|
channel: LiveActivitiesChannel,
|
||||||
goalNote: Note?,
|
goalNote: Note?,
|
||||||
): List<TopZapperEntry> {
|
): 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>>()
|
val byReceipt = HashMap<HexKey, Pair<HexKey, BigDecimal>>()
|
||||||
|
|
||||||
// Stream-level zaps routed to the channel cache.
|
// Stream-level zaps routed to the channel cache.
|
||||||
channel.notes.forEach { _, note ->
|
channel.notes.forEach { _, note ->
|
||||||
val ev = note.event
|
val ev = note.event
|
||||||
if (ev is LnZapEvent) {
|
if (ev is LnZapEvent) {
|
||||||
val zapper = ev.zapRequest?.pubKey ?: return@forEach
|
val request = ev.zapRequest ?: return@forEach
|
||||||
val sats = ev.amount() ?: 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.
|
// Goal-scoped zaps attached to the goal note via #e.
|
||||||
goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) ->
|
goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) ->
|
||||||
val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach
|
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
|
val sats = receiptEv.amount() ?: return@forEach
|
||||||
byReceipt[receiptNote.idHex] = zapper to sats
|
byReceipt[receiptNote.idHex] = bucketKeyFor(request) to sats
|
||||||
}
|
}
|
||||||
|
|
||||||
if (byReceipt.isEmpty()) return emptyList()
|
if (byReceipt.isEmpty()) return emptyList()
|
||||||
@@ -203,6 +220,9 @@ private fun aggregateTopZappers(
|
|||||||
.asSequence()
|
.asSequence()
|
||||||
.sortedByDescending { it.value }
|
.sortedByDescending { it.value }
|
||||||
.take(TOP_ZAPPERS_LIMIT)
|
.take(TOP_ZAPPERS_LIMIT)
|
||||||
.map { TopZapperEntry(it.key, it.value) }
|
.map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY) }
|
||||||
.toList()
|
.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