Merge pull request #3202 from vitorpamplona/claude/sweet-shannon-smjotq

Redesign activity cards (reactions, zaps, nutzaps) with unified UI
This commit is contained in:
Vitor Pamplona
2026-06-13 11:28:38 -04:00
committed by GitHub
21 changed files with 1353 additions and 61 deletions
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -328,6 +329,25 @@ fun routeReplyTo(
}
}
is LnZapEvent -> {
// A public reply can't tag a private zapper without exposing them.
// When we hold the decrypted sender (we are the zap recipient), reply
// in their DM room instead of the public comment composer.
val request = noteEvent.zapRequest
val privateSender =
if (request?.isPrivateZap() == true) {
account.privateZapsDecryptionCache.cachedPrivateZap(request)?.pubKey
} else {
null
}
if (privateSender != null) {
routeToMessage(ChatroomKey(setOf(privateSender)), null, account = account)
} else {
Route.GenericCommentPost(replyTo = note.idHex)
}
}
else -> {
Route.GenericCommentPost(replyTo = note.idHex)
}
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.authorRouteFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
@@ -256,7 +257,9 @@ fun RenderLikeGallery(
}
}
AuthorGallery(likeEvents, nav, accountViewModel)
// Opens the reaction's own thread (where it can be replied to,
// boosted, or zapped) instead of the reactor's profile.
AuthorGallery(likeEvents, nav, accountViewModel) { Route.Note(it.idHex) }
}
}
}
@@ -372,6 +375,7 @@ fun RenderNutzapGallery(
user = note.author,
comment = event?.content?.ifBlank { null },
amount = showAmount(java.math.BigDecimal(sats)),
zapNote = note,
)
}.toImmutableList()
}
@@ -469,6 +473,9 @@ data class ZapAmountCommentNotification(
val user: User?,
val comment: String?,
val amount: String?,
// The zap receipt (kind 9735) note, when available, so the chip can offer
// actions that target the zap itself (e.g. replying to it).
val zapNote: Note? = null,
)
@Composable
@@ -485,6 +492,7 @@ private fun ParseAuthorCommentAndAmount(
user = zapRequest.author,
comment = null,
amount = null,
zapNote = zapEvent,
),
)
}
@@ -504,7 +512,14 @@ fun click(
content: ZapAmountCommentNotification,
nav: INav,
) {
content.user?.let { nav.nav(routeFor(it)) }
val zapNote = content.zapNote
if (zapNote != null) {
// Opens the zap's own thread, where anyone can reply, boost,
// zap, or share it. The sender's profile is one tap away there.
nav.nav(Route.Note(zapNote.idHex))
} else {
content.user?.let { nav.nav(routeFor(it)) }
}
}
@Composable
@@ -614,9 +629,10 @@ fun AuthorGallery(
authorNotes: ImmutableList<Note>,
nav: INav,
accountViewModel: AccountViewModel,
clickRoute: (Note) -> Route? = ::authorRouteFor,
) {
Column(modifier = StdStartPadding) {
FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } }
FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel, clickRoute) } }
}
}
@@ -639,8 +655,9 @@ private fun BoxedAuthor(
note: Note,
nav: INav,
accountViewModel: AccountViewModel,
clickRoute: (Note) -> Route? = ::authorRouteFor,
) {
Box(modifier = Size35Modifier.clickable(onClick = { authorRouteFor(note)?.let { nav.nav(it) } })) {
Box(modifier = Size35Modifier.clickable(onClick = { clickRoute(note)?.let { nav.nav(it) } })) {
WatchAuthorWithBlank(note, Size35Modifier, accountViewModel) { author ->
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
author,
@@ -157,6 +157,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryRespo
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent
import com.vitorpamplona.amethyst.ui.note.types.RenderNutzap
import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode
@@ -187,6 +188,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
@@ -292,6 +294,7 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
@@ -1012,11 +1015,15 @@ private fun RenderNoteRow(
}
is LnZapEvent -> {
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
RenderLnZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is NutzapEvent -> {
RenderNutzap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is OnchainZapEvent -> {
RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav)
RenderOnchainZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is LiveActivitiesClipEvent -> {
@@ -1720,7 +1727,22 @@ fun FirstUserInfoRow(
val isDraft = baseNote.isDraft()
val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified
if (showAuthorPicture) {
// Zap receipts are signed by the recipient's lightning provider; show the
// sender from the embedded zap request instead of the service key.
val zapSender =
if (baseNote.event is LnZapEvent) {
observeZapSender(baseNote, accountViewModel).value
} else {
null
}
if (zapSender != null) {
if (showAuthorPicture) {
UserPicture(zapSender, Size25dp, accountViewModel = accountViewModel, nav = nav)
Spacer(HalfPadding)
}
UsernameDisplay(zapSender, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
} else if (showAuthorPicture) {
NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav)
Spacer(HalfPadding)
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
@@ -84,6 +84,7 @@ fun NutzapUserSetCompose(
user = note.author,
comment = event?.content?.ifBlank { null },
amount = showAmount(java.math.BigDecimal(sats)),
zapNote = note,
)
}.toImmutableList()
}
@@ -80,6 +80,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
@@ -96,6 +97,7 @@ import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
@@ -269,9 +271,27 @@ open class CommentPostViewModel :
open fun reply(post: Note) {
this.replyingTo = post
this.externalIdentity = (post.event as? CommentEvent)?.scope()
(post.event as? LnZapEvent)?.let { zap ->
notifying = listOfNotNull(zapSenderToNotify(zap))
}
observeCommunityRules(post)
}
/**
* Zap receipts (kind 9735) are signed by the recipient's lightning provider, so
* the reply tags built from the receipt alone would notify the custodian instead
* of the person who zapped. The actual sender is the author of the embedded zap
* request. Requests carrying an `anon` tag (anonymous or private zaps) are signed
* by an ephemeral key: skip those — tagging the throwaway key is useless, and
* tagging the decrypted sender would publicly expose a private zapper.
*/
private fun zapSenderToNotify(zapEvent: LnZapEvent): User? {
val request = zapEvent.zapRequest ?: return null
if (request.hasAnonTag()) return null
if (request.pubKey == account.signer.pubKey) return null
return LocalCache.checkGetOrCreateUser(request.pubKey)
}
/**
* Subscribes to the latest [CommunityRulesEvent] when [target] is a NIP-72
* community, and clears the rules state otherwise. Re-evaluates the current
@@ -433,6 +453,17 @@ open class CommentPostViewModel :
notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } +
draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) }
// Replies to zaps notify the zap sender through a plain p tag (the receipt's
// author keys above are the lightning provider). Restore the sender chip only
// if the draft still tags them — its absence means the user removed it.
(replyingTo?.event as? LnZapEvent)?.let { zap ->
zapSenderToNotify(zap)?.let { sender ->
if (draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) {
notifying = (notifying ?: emptyList()) + sender
}
}
}
if (forwardZapTo.value.items.isNotEmpty()) {
wantsForwardZapTo = true
}
@@ -529,6 +560,15 @@ open class CommentPostViewModel :
null
}
}
} else if (replyingToEvent is LnZapEvent) {
val sender = zapSenderToNotify(replyingToEvent)
// notifying starts with the sender; a missing entry means the
// user removed the chip, so respect that and don't tag them.
if (sender != null && notifying?.contains(sender) != false) {
listOf(sender.toPTag())
} else {
emptyList()
}
} else {
emptyList()
}
@@ -0,0 +1,175 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
/**
* Heart color of the Liked icon vector; tints reaction activity cards the way
* bitcoinColor tints the zap ones.
*/
val LikeTint = Color(0xFFCA395F)
/**
* Gradient card frame shared by the reaction / zap / nutzap / onchain-zap
* renderings, mirroring the onchain card's look: rounded corners and a soft
* top-to-bottom wash of the kind's tint.
*/
@Composable
fun ActivityCardFrame(
tint: Color,
content: @Composable ColumnScope.() -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(
Brush.linearGradient(
colors =
listOf(
tint.copy(alpha = 0.16f),
tint.copy(alpha = 0.04f),
),
),
).padding(horizontal = 12.dp, vertical = 10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), content = content)
}
}
/** Circular kind badge: a small filled circle of the tint with a white glyph. */
@Composable
fun ActivityBadge(
tint: Color,
icon: @Composable () -> Unit,
) {
Box(
modifier =
Modifier
.size(24.dp)
.clip(CircleShape)
.background(tint),
contentAlignment = Alignment.Center,
) {
icon()
}
}
/** Right-aligned kind label, the static cousin of the onchain card's dashed pill. */
@Composable
fun ActivityPill(
label: String,
tint: Color,
) {
Box(
modifier =
Modifier
.border(1.4.dp, tint.copy(alpha = 0.7f), RoundedCornerShape(10.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = tint,
fontWeight = FontWeight.Bold,
)
}
}
/** First line of the card: badge, sender → recipient avatars, and the kind pill. */
@Composable
fun ActivityHeaderRow(
tint: Color,
pillLabel: String?,
badge: @Composable () -> Unit,
senderAvatar: @Composable () -> Unit,
recipientAvatar: (@Composable () -> Unit)?,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
badge()
senderAvatar()
recipientAvatar?.let {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowForwardIos,
contentDescription = null,
tint = tint,
modifier = Size16Modifier,
)
it()
}
Spacer(Modifier.weight(1f))
pillLabel?.let { ActivityPill(it, tint) }
}
}
/** Big amount line, matching the onchain card's typography. */
@Composable
fun ActivityAmountRow(
amount: String,
tint: Color,
) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = amount,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold,
color = tint,
)
Text(
text = "sats",
style = MaterialTheme.typography.titleSmall,
color = tint,
modifier = Modifier.padding(bottom = 6.dp),
)
}
}
@@ -0,0 +1,225 @@
/*
* 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.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
/**
* Design-time fixtures for the activity cards (reaction / lightning zap /
* cashu nutzap / onchain zap). The notes are built by hand and never consumed
* into [LocalCache], except for the users behind the avatars.
*/
private object ActivityCardPreviewData {
val senderHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val authorHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b"
const val SIG = "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
const val TIME = 1_700_000_000L
val targetId = "a".repeat(64)
fun targetNote(): Note =
Note(targetId).apply {
event =
TextNoteEvent(
targetId,
authorHex,
TIME,
emptyArray(),
"Just finished the new release — zaps, nests, and a faster feed. What should we build next?",
SIG,
)
author = LocalCache.getOrCreateUser(authorHex)
replyTo = emptyList()
}
fun reactionNote(reaction: String): Note {
val id = "b".repeat(64)
return Note(id).apply {
event =
ReactionEvent(
id,
senderHex,
TIME,
arrayOf(arrayOf("e", targetId), arrayOf("p", authorHex)),
reaction,
SIG,
)
author = LocalCache.getOrCreateUser(senderHex)
replyTo = listOf(targetNote())
}
}
fun nutzapNote(): Note {
val id = "c".repeat(64)
return Note(id).apply {
event =
NutzapEvent(
id,
senderHex,
TIME,
arrayOf(
arrayOf("proof", """{"amount":2100,"id":"00ad268c4d1f5826","secret":"s","C":"c"}"""),
arrayOf("u", "https://mint.example.com"),
arrayOf("e", targetId),
arrayOf("p", authorHex),
),
"Have some cashu, great post!",
SIG,
)
author = LocalCache.getOrCreateUser(senderHex)
replyTo = listOf(targetNote())
}
}
fun lnZapNote(): Note {
val id = "d".repeat(64)
// Receipts are signed by the lightning provider; the preview skips the
// embedded request and feeds the decrypted card to RenderLnZapCard directly.
return Note(id).apply {
event = null
author = null
replyTo = listOf(targetNote())
}
}
fun onchainZapNote(): Note {
val id = "e".repeat(64)
return Note(id).apply {
event =
OnchainZapEvent(
id,
senderHex,
TIME,
arrayOf(
arrayOf("i", "bitcoin:tx:" + "ab".repeat(32)),
arrayOf("amount", "150000"),
arrayOf("e", targetId),
arrayOf("p", authorHex),
),
"Welcome to the timechain!",
SIG,
)
author = LocalCache.getOrCreateUser(senderHex)
replyTo = listOf(targetNote())
}
}
}
@Preview
@Composable
fun LikeActivityCardPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderReaction(
note = ActivityCardPreviewData.reactionNote("+"),
quotesLeft = 1,
backgroundColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@Preview
@Composable
fun EmojiReactionActivityCardPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderReaction(
note = ActivityCardPreviewData.reactionNote("🤙"),
quotesLeft = 1,
backgroundColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@Preview
@Composable
fun LnZapActivityCardPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderLnZapCard(
note = ActivityCardPreviewData.lnZapNote(),
card =
ZapAmountCommentNotification(
user = LocalCache.getOrCreateUser(ActivityCardPreviewData.senderHex),
comment = "Great post! ⚡",
amount = "100k",
),
recipientKey = ActivityCardPreviewData.authorHex,
quotesLeft = 1,
backgroundColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@Preview
@Composable
fun NutzapActivityCardPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderNutzap(
note = ActivityCardPreviewData.nutzapNote(),
quotesLeft = 1,
backgroundColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@Preview
@Composable
fun OnchainZapActivityCardPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderOnchainZap(
note = ActivityCardPreviewData.onchainZapNote(),
quotesLeft = 1,
backgroundColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@@ -0,0 +1,100 @@
/*
* 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.note.types
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.claimedSatsTotal
import java.math.BigDecimal
/**
* Renders a NIP-61 nutzap (kind 9321) as an activity card, like lightning zaps.
* Unlike kind 9735 receipts, the nutzap is signed by the sender, so [Note.author]
* is already the right person to attribute.
*/
@Composable
fun RenderNutzap(
note: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val nutzapEvent = note.event as? NutzapEvent ?: return
val recipientKey = nutzapEvent.linkedPubKeys().firstOrNull()
val orange = MaterialTheme.colorScheme.bitcoinColor
ActivityCardFrame(orange) {
ActivityHeaderRow(
tint = orange,
pillLabel = "CASHU",
badge = {
ActivityBadge(orange) {
Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp),
)
}
},
senderAvatar = {
val sender = note.author
if (sender != null) {
UserPicture(sender, Size25dp, Modifier, accountViewModel, nav)
} else {
DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel)
}
},
recipientAvatar =
recipientKey?.let {
{ UserPicture(it, Size25dp, Modifier, accountViewModel, nav) }
},
)
RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav)
ActivityAmountRow(showAmount(BigDecimal(nutzapEvent.claimedSatsTotal())), orange)
nutzapEvent.content.ifBlank { null }?.let {
CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel)
}
}
}
@@ -90,6 +90,7 @@ private const val MEMPOOL_TX_URL = "https://mempool.space/tx/"
@Composable
fun RenderOnchainZap(
note: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
@@ -97,6 +98,7 @@ fun RenderOnchainZap(
val event = note.event as? OnchainZapEvent ?: return
val sender = note.author?.pubkeyHex ?: event.pubKey
val recipient = event.recipient() ?: return
val sats = event.claimedAmountInSats() ?: 0L
val txid = event.txid()
val message = event.content.takeIf { it.isNotBlank() }
@@ -141,6 +143,8 @@ fun RenderOnchainZap(
nav = nav,
)
RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav)
AmountRow(sats = sats, orange = orange)
ConfirmationPill(tx = tx, hasTxid = txid != null, orange = orange)
@@ -20,16 +20,26 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.ui.note.LikedIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.note.RenderReaction as RenderReactionEmoji
/**
* Renders a kind 7 reaction as an activity card, like the zap kinds, tinted
* with the Liked heart color: badge + reactor → author on the first line and
* the reacted-to post quoted inside the card.
*/
@Composable
fun RenderReaction(
note: Note,
@@ -38,24 +48,35 @@ fun RenderReaction(
accountViewModel: AccountViewModel,
nav: INav,
) {
note.replyTo?.lastOrNull()?.let {
NoteCompose(
it,
modifier = Modifier,
isBoostedNote = true,
unPackReply = ReplyRenderType.NONE,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
val reactionType = note.event?.content ?: ""
ActivityCardFrame(LikeTint) {
ActivityHeaderRow(
tint = LikeTint,
pillLabel = "REACTION",
badge = {
if (reactionType == "+" || reactionType.isBlank()) {
ActivityBadge(LikeTint) {
LikedIcon(Modifier.size(16.dp), Color.White)
}
} else {
RenderReactionEmoji(reactionType)
}
},
senderAvatar = {
val sender = note.author
if (sender != null) {
UserPicture(sender, Size25dp, Modifier, accountViewModel, nav)
} else {
DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel)
}
},
recipientAvatar =
note.replyTo?.lastOrNull()?.author?.let {
{ UserPicture(it, Size25dp, Modifier, accountViewModel, nav) }
},
)
RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav)
}
// Reposts have trash in their contents.
val refactorReactionText = if (note.event?.content == "+") "❤" else note.event?.content ?: ""
Text(
text = refactorReactionText,
maxLines = 1,
)
}
@@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip73ExternalIds.scope
@@ -115,7 +116,15 @@ fun RenderTextEvent(
}
ReplyRenderType.LINE -> {
val parentAuthor = replyingDirectlyTo.author
// Zap receipts are signed by the recipient's lightning provider;
// label the reply with the zap sender instead of the service key.
val zapSender =
if (replyingDirectlyTo.event is LnZapEvent) {
observeZapSender(replyingDirectlyTo, accountViewModel).value
} else {
null
}
val parentAuthor = zapSender ?: replyingDirectlyTo.author
if (parentAuthor != null) {
ReplyToLabel(
parentAuthorDisplay = parentAuthor.toBestDisplayName(),
@@ -23,10 +23,12 @@ package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -35,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
@@ -43,6 +46,8 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapIcon
@@ -60,9 +65,37 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
/**
* Shows the post a zap targets above the transfer card, mirroring how
* reactions and reposts embed their target.
*/
@Composable
fun RenderZappedPost(
zapNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
zapNote.replyTo?.lastOrNull()?.let {
NoteCompose(
it,
modifier = Modifier,
isBoostedNote = true,
makeItShort = true,
unPackReply = ReplyRenderType.NONE,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
fun RenderLnZap(
note: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
@@ -71,18 +104,76 @@ fun RenderLnZap(
val card by parseAuthorCommentAndAmount(note, accountViewModel)
val destinationKey = zapEvent.zappedAuthor().firstOrNull() ?: return
TransferCard(
card,
destinationKey,
backgroundColor,
Modifier,
accountViewModel,
nav,
RenderLnZapCard(
note = note,
card = card,
recipientKey = zapEvent.zappedAuthor().firstOrNull(),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun RenderLnZapCard(
note: Note,
card: ZapAmountCommentNotification,
recipientKey: String?,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val orange = MaterialTheme.colorScheme.bitcoinColor
ActivityCardFrame(orange) {
ActivityHeaderRow(
tint = orange,
pillLabel = "LIGHTNING",
badge = {
ActivityBadge(orange) {
ZapIcon(Modifier.size(18.dp), Color.White)
}
},
senderAvatar = {
val sender = card.user
if (sender != null) {
UserPicture(sender, Size25dp, Modifier, accountViewModel, nav)
} else {
DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel)
}
},
recipientAvatar =
recipientKey?.let {
{ UserPicture(it, Size25dp, Modifier, accountViewModel, nav) }
},
)
RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav)
card.amount?.let { ActivityAmountRow(it, orange) }
card.comment?.let {
CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel)
}
}
}
/**
* Resolves the sender of a zap receipt — the author of the embedded kind 9734
* request, decrypted when it is a private zap — since the receipt itself is
* signed by the recipient's lightning provider, not by the sender.
*/
@Composable
fun observeZapSender(
zapNote: Note,
accountViewModel: AccountViewModel,
): State<User?> =
produceState<User?>(initialValue = null, key1 = zapNote) {
value = accountViewModel.innerDecryptAmountMessage(zapNote)?.user
}
@Composable
private fun parseAuthorCommentAndAmount(
zapEventNote: Note,
@@ -718,6 +718,7 @@ class AccountViewModel(
?.content
?.ifBlank { null },
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
it.response,
)
}.toMutableMap()
@@ -733,7 +734,7 @@ class AccountViewModel(
}
}
results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info }
results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info.copy(zapNote = decrypted.zapEvent) }
onNewState(initialResults.values.toImmutableList())
}
@@ -750,6 +751,7 @@ class AccountViewModel(
LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author,
cachedPrivateRequest.content.ifBlank { null },
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
it.response,
)
} else {
ZapAmountCommentNotification(
@@ -758,6 +760,7 @@ class AccountViewModel(
?.content
?.ifBlank { null },
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
it.response,
)
}
} else {
@@ -767,6 +770,7 @@ class AccountViewModel(
?.content
?.ifBlank { null },
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
it.response,
)
}
}.toImmutableList()
@@ -784,6 +788,7 @@ class AccountViewModel(
LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author,
cachedPrivateRequest.content.ifBlank { null },
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
it.second,
)
} else {
ZapAmountCommentNotification(
@@ -792,6 +797,7 @@ class AccountViewModel(
?.content
?.ifBlank { null },
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
it.second,
)
}
} else {
@@ -801,6 +807,7 @@ class AccountViewModel(
?.content
?.ifBlank { null },
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
it.second,
)
}
}.toImmutableList()
@@ -823,6 +830,7 @@ class AccountViewModel(
?.content
?.ifBlank { null },
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
it.second,
)
}.toMutableMap()
@@ -841,7 +849,7 @@ class AccountViewModel(
}
}
decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info }
decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info.copy(zapNote = decrypted.zapEvent) }
onNewState(initialResults.values.toImmutableList())
}
@@ -853,7 +861,7 @@ class AccountViewModel(
onNewState: (ZapAmountCommentNotification?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
onNewState(innerDecryptAmountMessage(zapRequest, zapEvent))
onNewState(innerDecryptAmountMessage(zapRequest, zapEvent)?.copy(zapNote = zapEvent))
}
}
@@ -861,7 +869,7 @@ class AccountViewModel(
val zapEvent = zapNote.event as? LnZapEvent ?: return null
val zapRequest = zapEvent.zapRequest ?: return null
return innerDecryptAmountMessage(zapRequest, zapEvent)
return innerDecryptAmountMessage(zapRequest, zapEvent)?.copy(zapNote = zapNote)
}
suspend fun innerDecryptAmountMessage(
@@ -168,6 +168,29 @@ class NotificationFeedFilter(
return true
}
if (event is CommentEvent) {
// NIP-22 comments carry their root/parent authors as tags, so a
// reply to the user's reaction stays detectable even when the
// reaction event is not in the local cache (the replyTo-author
// check above needs it loaded).
if (event.rootAuthorKeys().contains(authorHex) || event.replyAuthorKeys().contains(authorHex)) {
return true
}
// Replies to the user's zaps: the receipt — and therefore the
// author tags above — is signed by the recipient's lightning
// provider, not the zapper. The `k` tag (or the cached parent)
// proves the comment targets a zap; the reply's explicit p tag
// on the user marks it as theirs.
val targetsZapReceipt =
event.hasScopeKind(LnZapEvent.KIND.toString()) ||
note.replyTo?.any { it.event is LnZapEvent } == true
if (targetsZapReceipt && event.isTaggedUser(authorHex)) {
return true
}
}
if ((event is TextNoteEvent || event is CommentEvent)) {
val community =
event
@@ -106,6 +106,8 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.RenderApproveButton
import com.vitorpamplona.amethyst.ui.note.RenderDraft
import com.vitorpamplona.amethyst.ui.note.RenderRepost
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
@@ -174,6 +176,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist
import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderNutzap
import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode
@@ -182,6 +185,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayDiscovery
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest
@@ -200,6 +204,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader
import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -256,6 +261,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -294,6 +300,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -513,22 +520,44 @@ private fun FullBleedNoteCompose(
) {
val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
// Zap receipts are signed by the recipient's lightning provider; show the
// sender from the embedded zap request instead of the service key.
val zapSender =
if (noteEvent is LnZapEvent) {
observeZapSender(baseNote, accountViewModel).value
} else {
null
}
Row(
modifier =
Modifier
.padding(start = 12.dp, end = 12.dp)
.clickable(onClick = { baseNote.author?.let { nav.nav(routeFor(it)) } }),
.clickable(onClick = { (zapSender ?: baseNote.author)?.let { nav.nav(routeFor(it)) } }),
) {
NoteAuthorPicture(
baseNote = baseNote,
size = Size55dp,
accountViewModel = accountViewModel,
nav = nav,
)
if (zapSender != null) {
UserPicture(
user = zapSender,
size = Size55dp,
accountViewModel = accountViewModel,
nav = nav,
)
} else {
NoteAuthorPicture(
baseNote = baseNote,
size = Size55dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
if (zapSender != null) {
UsernameDisplay(zapSender, Modifier.weight(1f), accountViewModel = accountViewModel)
} else {
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
}
if (isDraft) {
ObserveDraftEvent(baseNote, accountViewModel) { draftNote ->
@@ -716,9 +745,13 @@ private fun FullBleedNoteCompose(
} else if (noteEvent is AdvertisedRelayListEvent) {
DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is LnZapEvent) {
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
RenderLnZap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is NutzapEvent) {
RenderNutzap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is OnchainZapEvent) {
RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav)
RenderOnchainZap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is ReactionEvent) {
RenderReaction(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is SearchRelayListEvent) {
DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is BlockedRelayListEvent) {
@@ -0,0 +1,129 @@
/*
* 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.notifications.dal
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The "Curated" (Selected) notification mode gates every event through
* [NotificationFeedFilter.tagsAnEventByUser]. Replies to the user's likes and
* zaps are kind:1111 comments whose relevance must be detectable without the
* parent event in the local cache: reaction parents via the NIP-22 root/reply
* author tags, zap parents via the `k` tag plus the explicit p tag (the
* receipt itself is signed by the lightning provider, not the zapper).
*/
class NotificationTagsAnEventByUserTest {
private val me = "1".repeat(64)
private val wallet = "2".repeat(64)
private val replier = "3".repeat(64)
private val reactionId = "a".repeat(64)
private val zapId = "b".repeat(64)
private val replyId = "c".repeat(64)
private val sig = "f".repeat(128)
@Test
fun `reply to my reaction is relevant via nip22 author tags without the parent in cache`() {
val reply =
CommentEvent(
replyId,
replier,
1000,
arrayOf(
arrayOf("E", reactionId, "", me),
arrayOf("K", "7"),
arrayOf("P", me),
arrayOf("e", reactionId, "", me),
arrayOf("k", "7"),
arrayOf("p", me),
),
"lol same",
sig,
)
// Parent reaction NOT in cache: replyTo resolves to an empty note (no author).
val note =
Note(replyId).apply {
event = reply
replyTo = listOf(Note(reactionId))
}
assertTrue(NotificationFeedFilter.tagsAnEventByUser(note, me))
}
@Test
fun `reply to my zap is relevant via k tag and explicit p tag without the receipt in cache`() {
val reply =
CommentEvent(
replyId,
replier,
1000,
arrayOf(
arrayOf("E", zapId, "", wallet),
arrayOf("K", LnZapEvent.KIND.toString()),
arrayOf("P", wallet),
arrayOf("e", zapId, "", wallet),
arrayOf("k", LnZapEvent.KIND.toString()),
arrayOf("p", wallet),
arrayOf("p", me),
),
"thanks for the zap!",
sig,
)
val note =
Note(replyId).apply {
event = reply
replyTo = listOf(Note(zapId))
}
assertTrue(NotificationFeedFilter.tagsAnEventByUser(note, me))
}
@Test
fun `reply to someone else's zap that does not tag me stays irrelevant`() {
val reply =
CommentEvent(
replyId,
replier,
1000,
arrayOf(
arrayOf("E", zapId, "", wallet),
arrayOf("K", LnZapEvent.KIND.toString()),
arrayOf("P", wallet),
arrayOf("e", zapId, "", wallet),
arrayOf("k", LnZapEvent.KIND.toString()),
arrayOf("p", wallet),
),
"nice zap",
sig,
)
val note =
Note(replyId).apply {
event = reply
replyTo = listOf(Note(zapId))
}
assertFalse(NotificationFeedFilter.tagsAnEventByUser(note, me))
}
}
@@ -25,8 +25,13 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
@@ -43,6 +48,11 @@ class ThreadAssembler(
if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) return note
// Reactions and zaps anchor their own thread: their replyTo points at the
// post they reacted to, which lives in a different conversation. Stop here
// instead of climbing across that bridge into the targeted post's thread.
if (noteEvent.anchorsItsOwnThread()) return note
testedNotes.add(note)
val markedAsRoot =
@@ -107,14 +117,23 @@ class ThreadAssembler(
return if (note.event != null) {
val thread = OnlyLatestVersionSet()
val threadRoot = searchRoot(note, thread) ?: note
if (note.event.anchorsItsOwnThread()) {
// Reactions and zaps anchor their own conversation: the event is
// the root and only its reply subtree is loaded — not the thread
// of the post it targeted (that post is embedded in the event's
// card). Replies and comments (kind 1 / 1111) keep loading the
// full parent thread below.
loadDown(note, thread)
} else {
val threadRoot = searchRoot(note, thread) ?: note
loadUp(note, thread)
loadUp(note, thread)
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
}
ThreadInfo(
root = note,
@@ -135,7 +154,12 @@ class ThreadAssembler(
if (note !in thread) {
thread.add(note)
note.replyTo?.forEach { loadUp(it, thread) }
// A reaction/zap is a thread boundary: include the node itself, but do
// not climb its replyTo — that edge bridges into the targeted post's
// separate conversation (which must not be pulled into this thread).
if (!note.event.anchorsItsOwnThread()) {
note.replyTo?.forEach { loadUp(it, thread) }
}
}
}
@@ -151,6 +175,20 @@ class ThreadAssembler(
}
}
/**
* Reactions and zaps keep the post they target in [Note.replyTo] so the card can
* embed it, but that edge is a cross-reference, not a thread parent: it points
* into a different conversation. For thread traversal they behave as roots — their
* thread is the event plus its own reply subtree, never the thread of the post
* they reacted to. Used by [ThreadAssembler] (root search, upward load) and
* [ThreadLevelCalculator] (reply level), mirroring how reposts are treated.
*/
fun Event?.anchorsItsOwnThread(): Boolean =
when (this) {
is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent -> true
else -> false
}
class OnlyLatestVersionSet : MutableSet<Note> {
val map = hashMapOf<Address, Long>()
val set = hashSetOf<Note>()
@@ -61,7 +61,11 @@ object ThreadLevelCalculator {
val noteAuthor = note.author
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
note.event is RepostEvent ||
note.event is GenericRepostEvent ||
note.event.anchorsItsOwnThread() ||
replyTo == null ||
replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";",
@@ -120,7 +124,11 @@ object ThreadLevelCalculator {
): Int {
val replyTo = note.replyTo
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
note.event is RepostEvent ||
note.event is GenericRepostEvent ||
note.event.anchorsItsOwnThread() ||
replyTo == null ||
replyTo.isEmpty()
) {
cachedLevels[note] = 0
return 0
@@ -0,0 +1,194 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Thread anchoring rules: replies and comments (kind 1 / 1111) load the full
* parent thread; reactions and zaps anchor their own thread — the event is the
* root and only its reply subtree is included, not the conversation of the
* post they target.
*/
class ThreadAssemblerTest {
private val threadRootId = "9".repeat(64)
private val originalId = "a".repeat(64)
private val zapId = "b".repeat(64)
private val reactionId = "c".repeat(64)
private val replyToZapId = "d".repeat(64)
private val replyToReactionId = "8".repeat(64)
private val authorKey = "e".repeat(64)
private val sig = "f".repeat(128)
// Thread C: root <- original. `original` is a reply nested inside a thread,
// standing in for "a reply on another thread" that gets liked/zapped.
private val threadRoot =
Note(threadRootId).apply {
event = TextNoteEvent(threadRootId, authorKey, 999, arrayOf(arrayOf("t", "test")), "thread root", sig)
replyTo = emptyList()
}
private val original =
Note(originalId).apply {
event =
TextNoteEvent(
originalId,
authorKey,
1000,
arrayOf(arrayOf("e", threadRootId, "", "root")),
"the zapped post",
sig,
)
replyTo = listOf(threadRoot)
}
private val zap =
Note(zapId).apply {
event = LnZapEvent(zapId, authorKey, 1001, arrayOf(arrayOf("e", originalId), arrayOf("p", authorKey)), "", sig)
replyTo = listOf(original)
}
private val reaction =
Note(reactionId).apply {
event = ReactionEvent(reactionId, authorKey, 1001, arrayOf(arrayOf("e", originalId), arrayOf("p", authorKey)), "+", sig)
replyTo = listOf(original)
}
private val replyToZap =
Note(replyToZapId).apply {
event = CommentEvent(replyToZapId, authorKey, 1002, arrayOf(arrayOf("E", zapId), arrayOf("e", zapId)), "nice zap!", sig)
replyTo = listOf(zap)
}
private val replyToReaction =
Note(replyToReactionId).apply {
event = CommentEvent(replyToReactionId, authorKey, 1002, arrayOf(arrayOf("E", reactionId), arrayOf("e", reactionId)), "lol same", sig)
replyTo = listOf(reaction)
}
private val cache =
StubCache(
mapOf(
threadRootId to threadRoot,
originalId to original,
zapId to zap,
reactionId to reaction,
replyToZapId to replyToZap,
replyToReactionId to replyToReaction,
),
)
init {
// Mirrors LocalCache: replies link bidirectionally, but zaps and
// reactions are credited via addZap/addReaction, not addReply, so the
// original note's `replies` does not contain them.
threadRoot.addReply(original)
zap.addReply(replyToZap)
reaction.addReply(replyToReaction)
}
@Test
fun zapAnchorsItsOwnThread() {
val info = ThreadAssembler(cache).findThreadFor(zapId)!!
assertEquals(zap, info.root)
assertTrue(info.allNotes.contains(zap))
assertTrue(info.allNotes.contains(replyToZap), "the zap's replies belong to its thread")
assertFalse(info.allNotes.contains(original), "the zapped post's thread must not be loaded")
}
@Test
fun reactionAnchorsItsOwnThread() {
val info = ThreadAssembler(cache).findThreadFor(reactionId)!!
assertEquals(reaction, info.root)
assertTrue(info.allNotes.contains(reaction))
assertFalse(info.allNotes.contains(original), "the liked post's thread must not be loaded")
}
@Test
fun commentReplyingToAZapAnchorsAtTheZapNotTheZappedThread() {
val info = ThreadAssembler(cache).findThreadFor(replyToZapId)!!
assertTrue(info.allNotes.contains(replyToZap))
assertTrue(info.allNotes.contains(zap), "the zap is the root of this thread")
assertFalse(info.allNotes.contains(original), "the zapped post's conversation must not load")
assertFalse(info.allNotes.contains(threadRoot), "...nor its thread root")
}
@Test
fun commentReplyingToALikeAnchorsAtTheLikeNotTheLikedThread() {
// The user's scenario: reply A to like L, where L liked a reply (original)
// nested in thread C. Opening A's thread must show {L, A}, never thread C.
val info = ThreadAssembler(cache).findThreadFor(replyToReactionId)!!
assertTrue(info.allNotes.contains(replyToReaction))
assertTrue(info.allNotes.contains(reaction), "the like is the root of this thread")
assertFalse(info.allNotes.contains(original), "the liked post's conversation must not load")
assertFalse(info.allNotes.contains(threadRoot), "...nor its thread root")
}
@Test
fun replyLevelStopsAtTheReactionBoundary() {
// Without the boundary the level would climb reaction -> original -> root,
// burying the reply several levels deep; it must be a direct child of the like.
assertEquals(0, ThreadLevelCalculator.replyLevel(reaction))
assertEquals(1, ThreadLevelCalculator.replyLevel(replyToReaction))
assertEquals(0, ThreadLevelCalculator.replyLevel(zap))
assertEquals(1, ThreadLevelCalculator.replyLevel(replyToZap))
}
private class StubCache(
private val notesById: Map<HexKey, Note>,
) : ICacheProvider {
override fun getAnyChannel(note: Note): Channel? = null
override fun getUserIfExists(pubkey: HexKey): User? = null
override fun countUsers(predicate: (String, User) -> Boolean): Int = 0
override fun getNoteIfExists(hexKey: HexKey): Note? = notesById[hexKey]
override fun checkGetOrCreateNote(hexKey: HexKey): Note? = notesById[hexKey]
override fun getOrCreateAddressableNote(key: Address): AddressableNote = error("not used by ThreadAssembler in this test")
override fun getEventStream(): ICacheEventStream = error("not used by ThreadAssembler in this test")
override fun hasBeenDeleted(event: Any): Boolean = false
override fun getOrCreateUser(pubkey: HexKey): User? = null
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
}
@@ -69,6 +69,13 @@ class LnZapRequestEvent(
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
/**
* True when the request carries an `anon` tag — a private zap (encrypted payload)
* or an anonymous zap (blank value). In both cases [pubKey] is an ephemeral
* throwaway key, not the sender's real identity.
*/
fun hasAnonTag() = tags.any { t -> t.isNotEmpty() && t[0] == "anon" }
fun getAnonTag(): String {
val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
@@ -169,7 +176,10 @@ class LnZapRequestEvent(
}
LnZapEvent.ZapType.ANONYMOUS -> {
tags += arrayOf(arrayOf("anon", ""))
// Valueless `anon` tag: a blank-valued one (`["anon", ""]`) is the
// marker for an *unsigned private* zap and would make the throwaway
// signer encrypt the message instead of keeping it public.
tags += arrayOf(arrayOf("anon"))
NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message)
}
@@ -0,0 +1,124 @@
/*
* 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.nip57Zaps
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* `hasAnonTag()` is the privacy gate used when replying to a zap: only requests
* without an `anon` tag are signed by the sender's real key, so only those may
* be p-tagged in a public reply. Anonymous (valueless `anon`) and private
* (encrypted `anon`) requests use throwaway keys — tagging anything derived
* from them is either useless or, for the decrypted sender, a doxxing risk.
*/
class LnZapRequestAnonTagTest {
private val signer =
NostrSignerInternal(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val receiverPubKey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/"))
private val zappedEvent =
Event(
id = "a".repeat(64),
pubKey = receiverPubKey,
createdAt = 1000L,
kind = 1,
tags = emptyArray(),
content = "Hello world",
sig = "b".repeat(128),
)
private suspend fun request(zapType: LnZapEvent.ZapType) =
LnZapRequestEvent.create(
zappedEvent = zappedEvent,
relays = relays,
signer = signer,
pollOption = null,
message = "",
zapType = zapType,
toUserPubHex = null,
)
@Test
fun `public zap request has no anon tag and is signed by the sender`() =
runTest {
val zapRequest = request(LnZapEvent.ZapType.PUBLIC)
assertFalse(zapRequest.hasAnonTag())
assertFalse(zapRequest.isPrivateZap())
assertEquals(signer.pubKey, zapRequest.pubKey)
}
@Test
fun `anonymous zap request has anon tag but is not private`() =
runTest {
val zapRequest = request(LnZapEvent.ZapType.ANONYMOUS)
assertTrue(zapRequest.hasAnonTag())
assertFalse(zapRequest.isPrivateZap())
assertFalse(zapRequest.pubKey == signer.pubKey, "anonymous zaps must be signed by a throwaway key")
}
/**
* Regression test for the user-only (profile zap) overload: its ANONYMOUS
* branch used a blank-valued `anon` tag, which [NostrSignerInternal] treats
* as an unsigned *private* zap and encrypts — silently turning a public
* anonymous comment into an encrypted one nobody but the recipient can read.
*/
@Test
fun `anonymous profile zap request keeps the message public`() =
runTest {
val zapRequest =
LnZapRequestEvent.create(
userHex = receiverPubKey,
relays = relays,
signer = signer,
message = "great work",
zapType = LnZapEvent.ZapType.ANONYMOUS,
)
assertTrue(zapRequest.hasAnonTag())
assertFalse(zapRequest.isPrivateZap(), "anonymous zaps must not be encrypted as private zaps")
assertEquals("great work", zapRequest.content)
assertFalse(zapRequest.pubKey == signer.pubKey, "anonymous zaps must be signed by a throwaway key")
}
@Test
fun `private zap request has anon tag, is private, and hides the sender key`() =
runTest {
val zapRequest = request(LnZapEvent.ZapType.PRIVATE)
assertTrue(zapRequest.hasAnonTag())
assertTrue(zapRequest.isPrivateZap())
assertFalse(zapRequest.pubKey == signer.pubKey, "private zaps must be signed by an ephemeral key")
}
}