From 39f9dd334abc53223aa7d387374e7e4d02ac54cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:37:20 +0000 Subject: [PATCH] feat(nutzap): dedicated cashu row in ReactionDetailGallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the nutzap UX integration. The expanded reaction view on a note now shows a cashu row between the lightning row and the onchain row, matching the rail visual hierarchy used elsewhere (zap chip popup → notifications card → reaction-detail gallery). New `NutzapGallery.kt` modelled on `OnchainZapGallery.kt`: - `WatchNutzapsAndRenderGallery` subscribes to the same per-note zap flow the lightning + onchain galleries already use. Memoizes on the nutzaps map reference (immutable per mutation) so a lightning zap arrival on the same note doesn't recompose this row, and renders only when at least one entry exists. - Cashu icon (CustomHashTagIcons.Cashu, tint=Unspecified) followed by a FlowRow of sender avatars with the claimed sat amount overlay. Unlike onchain, there's no UNVERIFIED/PENDING gating — every entry shows at full opacity because nutzap verification is the recipient wallet's job at redeem time (the reaction-detail gallery is just showing "who sent what to this note"). - Tapping an entry navigates to the sender's profile, same as the other galleries. Wired into `ReactionsRow.ReactionDetailGallery` between `WatchZapAndRenderGallery` and `WatchOnchainZapsAndRenderGallery`, so the three zap rails read top-to-bottom in the order they tend to surface for a given recipient (LN cheapest/easiest, cashu mint-mediated, onchain final-settlement). That closes the 4-phase nutzap UX work: - Phase 0: success toast on send (sendNutzap previously silent) - Phase 1: Note.nutzaps + LocalCache wiring; reaction-row counter and icon highlight come along for free via zapsAmount and isZappedBy extension. - Phase 3: notifications — NotificationFeedFilter kind add, NutzapUserSetCard for per-sender aggregates, cashu rail in MultiSetCard, NutzapUserSetCompose card renderer. - Phase 4 (this): dedicated cashu row in the expanded reaction view. --- .../amethyst/ui/note/NutzapGallery.kt | 151 ++++++++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 1 + 2 files changed, 152 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapGallery.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapGallery.kt new file mode 100644 index 0000000000..08bfa64b75 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NutzapGallery.kt @@ -0,0 +1,151 @@ +/* + * 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 + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.Cashu +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.model.NutzapEntry +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier +import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +private fun onNutzapEntryClick( + entry: NutzapEntry, + nav: INav, +) { + entry.source.author?.let { nav.nav(routeFor(it)) } +} + +@Composable +internal fun WatchNutzapsAndRenderGallery( + baseNote: Note, + nav: INav, + accountViewModel: AccountViewModel, +) { + // Same flow the lightning + onchain galleries subscribe to. The flow + // fires on every kind of zap mutation on this note, so memoize on the + // nutzap map reference (immutable; allocated fresh per mutation by + // Note.addNutzap) to keep recomposition off the hot lightning path. + val zapsState by observeNoteZaps(baseNote, accountViewModel) + val nutzapsMap = zapsState?.note?.nutzaps + val entries = + remember(nutzapsMap) { + nutzapsMap?.values?.toImmutableList() ?: persistentListOf() + } + + if (entries.isNotEmpty()) { + RenderNutzapGalleryRow(entries, nav, accountViewModel) + } +} + +@Composable +private fun RenderNutzapGalleryRow( + entries: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Row(Modifier.fillMaxWidth()) { + Box(modifier = WidthAuthorPictureModifier) { + // tint=Unspecified preserves the multi-tone cashu glyph, + // same convention as the notifications and zap-picker + // surfaces. Keeping the rail visually distinct from the + // lightning bolt above it is the whole point of this row. + Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = stringRes(R.string.nutzap), + modifier = Modifier.size(Size25dp).align(Alignment.TopEnd), + tint = Color.Unspecified, + ) + } + + NutzapAuthorGallery(entries, nav, accountViewModel) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun NutzapAuthorGallery( + entries: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Column(modifier = StdStartPadding) { + FlowRow { + entries.forEach { entry -> + NutzapEntryAvatar(entry, nav, accountViewModel) + } + } + } +} + +@Composable +private fun NutzapEntryAvatar( + entry: NutzapEntry, + nav: INav, + accountViewModel: AccountViewModel, +) { + val user = entry.source.author + val amountText = + remember(entry.claimedSats) { + if (entry.claimedSats > 0L) showAmount(BigDecimal.valueOf(entry.claimedSats)) else "" + } + + Box( + modifier = Size35Modifier.clickable { onNutzapEntryClick(entry, nav) }, + contentAlignment = Alignment.BottomCenter, + ) { + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( + user, + accountViewModel, + ) + + if (amountText.isNotEmpty()) { + CrossfadeToDisplayAmount(amountText) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 5e8f486e7d..cb96356fd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -557,6 +557,7 @@ private fun ReactionDetailGallery( ) { Column { WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) + WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel) WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)