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:
@@ -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,
|
||||
)
|
||||
}
|
||||
+41
-8
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user