From eb5f7d543415dfbcb5e851cc01d00f57898d8b3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 19:27:35 +0000 Subject: [PATCH] feat: single click on like/zap chips opens the event's thread view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a zap, nutzap, or like chip in the notification galleries now navigates to that event's own thread, where anyone can reply, boost, zap, or share it — the sender's profile remains one tap away via their avatar in the thread header. Boost chips keep navigating to the profile. To make those threads render properly: - The thread master view now dispatches ReactionEvent (kind 7) through RenderReaction and NutzapEvent (kind 9321) through a new RenderNutzap transfer card; NoteCompose gains the NutzapEvent branch as well. - The master header shows the zap sender (from the embedded zap request) instead of the lightning provider that signed the receipt, including the avatar click target. The private-zap reply-via-DM fallback moves from the chip long-press into routeReplyTo, so every reply entry point — including the thread view's reply button — routes private zaps to the sender's DM room when we hold the decrypted sender, instead of a public composer that cannot tag them. https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa --- .../ui/navigation/routes/RouteMaker.kt | 20 ++++++ .../amethyst/ui/note/MultiSetCompose.kt | 35 +++++----- .../amethyst/ui/note/NoteCompose.kt | 6 ++ .../amethyst/ui/note/types/Nutzap.kt | 69 +++++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 49 ++++++++++--- 5 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt 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 48ef26c9b4..5c8dde3b3f 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 @@ -326,6 +327,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 46de0e9118..47f1c4300c 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,10 +81,10 @@ 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.navigation.routes.routeReplyTo -import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap @@ -106,7 +106,6 @@ import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.overPictureBackground import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.claimedSatsTotal import kotlinx.collections.immutable.ImmutableList @@ -259,7 +258,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) } } } } @@ -512,7 +513,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)) } + } } @OptIn(ExperimentalFoundationApi::class) @@ -529,18 +537,7 @@ private fun RenderState( onClick = { click(content, nav) }, onLongClick = { content.zapNote?.let { zap -> - nav.nav { - val request = (zap.event as? LnZapEvent)?.zapRequest - val sender = content.user - if (request?.isPrivateZap() == true && sender != null && sender.pubkeyHex != request.pubKey) { - // A public reply can't tag a private zapper without exposing - // them. We hold the decrypted sender (we are the recipient), - // so reply privately in their DM room instead. - routeToMessage(sender, null, accountViewModel = accountViewModel) - } else { - routeReplyTo(zap, accountViewModel.account) - } - } + nav.nav { routeReplyTo(zap, accountViewModel.account) } } }, ), @@ -642,9 +639,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) } } } } @@ -667,8 +665,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 38a8b318a0..54eee4fc18 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 @@ -156,6 +156,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 @@ -292,6 +293,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 @@ -1010,6 +1012,10 @@ private fun RenderNoteRow( RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) } + is NutzapEvent -> { + RenderNutzap(baseNote, backgroundColor, accountViewModel, nav) + } + is OnchainZapEvent -> { RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav) } 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..80200a9fea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt @@ -0,0 +1,69 @@ +/* + * 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.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +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 a transfer 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, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val nutzapEvent = note.event as? NutzapEvent ?: return + + val recipientKey = nutzapEvent.linkedPubKeys().firstOrNull() ?: return + + val card = + remember(note) { + ZapAmountCommentNotification( + user = note.author, + comment = nutzapEvent.content.ifBlank { null }, + amount = showAmount(BigDecimal(nutzapEvent.claimedSatsTotal())), + zapNote = note, + ) + } + + TransferCard( + card, + recipientKey, + backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} 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 0872ca7095..df87864236 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 @@ -173,6 +175,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 @@ -181,6 +184,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 @@ -199,6 +203,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 @@ -255,6 +260,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 @@ -293,6 +299,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 @@ -511,22 +518,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 -> @@ -715,8 +744,12 @@ private fun FullBleedNoteCompose( DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is LnZapEvent) { RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is NutzapEvent) { + RenderNutzap(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is OnchainZapEvent) { RenderOnchainZap(baseNote, backgroundColor, accountViewModel, 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) {