diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 49b7f9766c..48fb62e212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 374ff41b74..cc1e97ba06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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, 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 48413f0545..70aff34cba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapUserSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapUserSetCompose.kt index 393c8e86ae..c40ef4da95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapUserSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapUserSetCompose.kt @@ -84,6 +84,7 @@ fun NutzapUserSetCompose( user = note.author, comment = event?.content?.ifBlank { null }, amount = showAmount(java.math.BigDecimal(sats)), + zapNote = note, ) }.toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 70c44937c0..7df304cae5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt new file mode 100644 index 0000000000..33fbbe195a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt @@ -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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt new file mode 100644 index 0000000000..8271e033b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt @@ -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(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt new file mode 100644 index 0000000000..c7c1d08cf9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt @@ -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, + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt index a4b6075330..7cb99cc8e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt @@ -90,6 +90,7 @@ private const val MEMPOOL_TX_URL = "https://mempool.space/tx/" @Composable fun RenderOnchainZap( note: Note, + quotesLeft: Int, backgroundColor: MutableState, 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt index f29e023151..d9b959b9d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -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, - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 4ca9f66c3e..874936fe20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -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(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt index 004c007e58..3330a012fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt @@ -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, + 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, 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, + 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 = + produceState(initialValue = null, key1 = zapNote) { + value = accountViewModel.innerDecryptAmountMessage(zapNote)?.user + } + @Composable private fun parseAuthorCommentAndAmount( zapEventNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 20ca0b4d6f..a9b2c09304 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 8036eb2e4a..08d7d01ae6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 199d457730..6174025d61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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) { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt new file mode 100644 index 0000000000..786c4eda28 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt @@ -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)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index 657922e00b..485069cbb9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -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 { val map = hashMapOf() val set = hashSetOf() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.kt index c64e7e160d..015eba1f64 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.kt @@ -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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt new file mode 100644 index 0000000000..e676dd6dc6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt @@ -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, + ) : 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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index 21a8aca772..b334dd73ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -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) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestAnonTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestAnonTagTest.kt new file mode 100644 index 0000000000..3de89a1d2d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestAnonTagTest.kt @@ -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") + } +}