feat(nutzap): dedicated cashu row in ReactionDetailGallery

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.
This commit is contained in:
Claude
2026-05-28 22:37:20 +00:00
parent ef8f49733a
commit 39f9dd334a
2 changed files with 152 additions and 0 deletions
@@ -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<NutzapEntry>,
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<NutzapEntry>,
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)
}
}
}
@@ -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)