feat: single click on like/zap chips opens the event's thread view

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
This commit is contained in:
Claude
2026-06-12 19:27:35 +00:00
parent b9595d91a9
commit eb5f7d5434
5 changed files with 153 additions and 26 deletions
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -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)
}
@@ -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<Note>,
nav: INav,
accountViewModel: AccountViewModel,
clickRoute: (Note) -> Route? = ::authorRouteFor,
) {
Column(modifier = StdStartPadding) {
FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } }
FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel, clickRoute) } }
}
}
@@ -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,
@@ -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)
}
@@ -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<Color>,
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,
)
}
@@ -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) {