feat(nutzap): notifications card surface

Phase 3 of the nutzap UX integration. Inbound NIP-61 nutzaps
(kind 9321) now appear in the notifications feed alongside
lightning zaps, boosts, likes, and onchain zaps — but with the
cashu icon so the rail is visible at a glance.

Pieces:

- NotificationFeedFilter.NOTIFICATION_KINDS now includes
  NutzapEvent.KIND. The existing tagsAnEventByUser logic falls
  through to its `return true` default for nutzap (it's not a
  BaseNoteEvent / ReactionEvent / Repost / Git / Highlight), and
  the kind:9321's `p` tag carries the recipient so isTaggedUser
  matches.

- CardFeedContentState gains a parallel nutzap grouping pass.
  Nutzaps targeting a specific note feed into nutzapsPerEvent
  and roll into the per-note MultiSetCard; nutzaps without an
  e-tag target (recipient-only) feed into nutzapsPerUser and
  surface as a NutzapUserSetCard per sender per day.

- MultiSetCard extended with `nutzapEvents: ImmutableList<Note>`,
  and its min/max createdAt cover the cashu rail too. Adding a
  new field at the end with a `persistentListOf()` default keeps
  the existing constructor sites compatible.

- New NutzapUserSetCard for the per-sender aggregate. Wraps raw
  kind:9321 Notes (no request/response pair like LN), keyed by
  pubkey+createdAt with an "N" suffix so it never collides with
  the LN ZapUserSetCard.

- MultiSetCompose: new RenderNutzapGallery row renders the cashu
  icon (CustomHashTagIcons.Cashu, tint=Unspecified to preserve
  the brand colour) followed by the same AuthorGalleryZaps the
  lightning rail uses. Each kind:9321 is mapped to a
  ZapAmountCommentNotification with the claimed sat total and
  the event content as the comment.

- New NutzapUserSetCompose modelled on ZapUserSetCompose, also
  wired into CardFeedView's card switch.

The user-facing result: the notifications screen now shows a
"X sent you Y sats via cashu" card with the cashu icon, exactly
the same shape as the lightning version, and the per-note
multi-card grows a cashu rail when the note has cashu zaps.

Phase 4 next: the dedicated cashu line in ReactionDetailGallery,
modelled on the existing onchain row.
This commit is contained in:
Claude
2026-05-28 22:35:15 +00:00
parent 12ed86627c
commit ef8f49733a
6 changed files with 313 additions and 4 deletions
@@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -64,6 +65,8 @@ import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
@@ -101,8 +104,11 @@ 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.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.claimedSatsTotal
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
@@ -182,6 +188,10 @@ private fun Galeries(
DecryptAndRenderZapGallery(multiSetCard, backgroundColor, accountViewModel, nav)
}
if (multiSetCard.nutzapEvents.isNotEmpty()) {
RenderNutzapGallery(multiSetCard.nutzapEvents, backgroundColor, accountViewModel, nav)
}
if (multiSetCard.boostEvents.isNotEmpty()) {
RenderBoostGallery(multiSetCard.boostEvents, nav, accountViewModel)
}
@@ -340,6 +350,53 @@ fun RenderZapGallery(
}
}
@Composable
fun RenderNutzapGallery(
nutzapEvents: ImmutableList<Note>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Convert each kind:9321 Note into the same shape AuthorGalleryZaps
// already renders for lightning. Amount comes from the parsed proof
// total (lazy via NutzapEvent.claimedSatsTotal), comment from the
// event's content. No NIP-44 decryption needed — nutzap doesn't
// have a private variant the way NIP-57 does.
val nutzapAuthorComments: ImmutableList<ZapAmountCommentNotification> =
remember(nutzapEvents) {
nutzapEvents
.map { note ->
val event = note.event as? NutzapEvent
val sats = event?.claimedSatsTotal() ?: 0L
ZapAmountCommentNotification(
user = note.author,
comment = event?.content?.ifBlank { null },
amount = showAmount(java.math.BigDecimal(sats)),
)
}.toImmutableList()
}
Row(Modifier.fillMaxWidth()) {
Box(
modifier = WidthAuthorPictureModifier,
) {
// CustomHashTagIcons.Cashu is a multi-tone branded glyph;
// tint=Unspecified keeps the brand colours instead of
// flattening to onBackground (which would lose the cashu-
// orange distinguishing the row from the lightning-bolt
// row above it).
Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = stringRes(R.string.nutzap),
modifier = Modifier.size(Size20dp).align(Alignment.TopEnd),
tint = Color.Unspecified,
)
}
AuthorGalleryZaps(nutzapAuthorComments, backgroundColor, nav, accountViewModel)
}
}
@Composable
fun RenderBoostGallery(
boostEvents: ImmutableList<Note>,
@@ -0,0 +1,153 @@
/*
* 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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
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.screen.loggedIn.notifications.NutzapUserSetCard
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.claimedSatsTotal
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Per-sender NIP-61 nutzap aggregate card — mirrors [ZapUserSetCompose] but
* renders the cashu icon instead of the lightning bolt so the user sees the
* rail at a glance. Built from the kind:9321 events directly: amount comes
* from each event's parsed proof total, sender from the event's pubkey.
* No NIP-44 decryption needed — nutzap is plaintext over the wire.
*/
@Composable
fun NutzapUserSetCompose(
nutzapSetCard: NutzapUserSetCard,
isInnerNote: Boolean = false,
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val backgroundColor =
calculateBackgroundColor(
createdAt = nutzapSetCard.createdAt,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
)
val authorComments: ImmutableList<ZapAmountCommentNotification> =
remember(nutzapSetCard.nutzapEvents) {
nutzapSetCard.nutzapEvents
.map { note ->
val event = note.event as? NutzapEvent
val sats = event?.claimedSatsTotal() ?: 0L
ZapAmountCommentNotification(
user = note.author,
comment = event?.content?.ifBlank { null },
amount = showAmount(java.math.BigDecimal(sats)),
)
}.toImmutableList()
}
Column(
modifier =
Modifier
.background(backgroundColor.value)
.clickable {
nav.nav(routeFor(nutzapSetCard.user))
},
) {
Row(
modifier =
Modifier.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp,
),
) {
if (!isInnerNote) {
Box(
modifier = Size55Modifier,
) {
// tint=Unspecified so the multi-tone cashu glyph keeps its
// brand colours, the same way it does in the zap chip popup.
Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = stringRes(R.string.nutzap),
modifier = Modifier.size(Size25dp).align(Alignment.TopEnd),
tint = Color.Unspecified,
)
}
}
Column(modifier = Modifier) {
Row(Modifier.fillMaxWidth()) {
AuthorGalleryZaps(authorComments, backgroundColor, nav, accountViewModel)
}
Spacer(DoubleVertSpacer)
Row(
Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
UserPicture(
nutzapSetCard.user,
Size55dp,
accountViewModel = accountViewModel,
nav = nav,
)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(nutzapSetCard.user, accountViewModel = accountViewModel)
}
AboutDisplay(nutzapSetCard.user, accountViewModel)
}
}
Spacer(DoubleVertSpacer)
}
}
}
}
@@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.collections.immutable.ImmutableList
@@ -237,17 +238,46 @@ class CardFeedContentState(
}
}
// NIP-61 nutzaps — parallel to the lightning-zap grouping above.
// For nutzaps targeting a specific note, accumulate into
// [nutzapsPerEvent] so the MultiSetCard can show a cashu rail
// alongside the lightning rail. For nutzaps with no e-tagged
// target (just the recipient's p-tag), accumulate into
// [nutzapsPerUser] so they surface as a NutzapUserSetCard,
// mirroring how user-targeted lightning zaps become a
// ZapUserSetCard.
val nutzapsPerUser = mutableMapOf<User, MutableList<Note>>()
val nutzapsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
.filter { it.event is NutzapEvent }
.forEach { nutzapNote ->
val zappedPost = nutzapNote.replyTo?.lastOrNull()
if (zappedPost != null) {
nutzapsPerEvent
.getOrPut(zappedPost) { mutableListOf() }
.add(nutzapNote)
} else {
val sender = nutzapNote.author
if (sender != null) {
nutzapsPerUser
.getOrPut(sender) { mutableListOf() }
.add(nutzapNote)
}
}
}
val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys + nutzapsPerEvent.keys
val multiCards =
allBaseNotes.flatMap { baseNote ->
val boostsInCard = boostsPerEvent[baseNote] ?: emptyList()
val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList()
val zapsInCard = zapsPerEvent[baseNote] ?: emptyList()
val nutzapsInCard = nutzapsPerEvent[baseNote] ?: emptyList()
val singleList =
(boostsInCard + zapsInCard.map { it.response } + reactionsInCard).groupBy {
(boostsInCard + zapsInCard.map { it.response } + reactionsInCard + nutzapsInCard).groupBy {
sdf.format(
Instant
.ofEpochSecond(it.createdAt() ?: 0L)
@@ -271,6 +301,7 @@ class CardFeedContentState(
boostsInCard.filter { it in chunk }.toImmutableList(),
reactionsInCard.filter { it in chunk }.toImmutableList(),
zapsInCard.filter { it.response in chunk }.toImmutableList(),
nutzapsInCard.filter { it in chunk }.toImmutableList(),
)
}
}.flatten()
@@ -299,6 +330,32 @@ class CardFeedContentState(
}
}.flatten()
// Per-sender nutzap aggregate cards — mirror userZaps but render
// with a cashu icon so the user sees the rail at a glance. Cards
// are bucketed by yyyy-MM-dd so a sender's daily run rolls up.
val userNutzaps =
nutzapsPerUser
.map { user ->
val byDay =
user.value.groupBy {
sdf.format(
Instant
.ofEpochSecond(it.createdAt() ?: 0L)
.atZone(ZoneId.systemDefault())
.toLocalDateTime(),
)
}
byDay.values.map { nutzaps ->
NutzapUserSetCard(
user.key,
nutzaps
.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
.toImmutableList(),
)
}
}.flatten()
val textNoteCards =
notes
.filter {
@@ -316,7 +373,7 @@ class CardFeedContentState(
}
}
return (multiCards + textNoteCards + userZaps)
return (multiCards + textNoteCards + userZaps + userNutzaps)
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
@@ -60,18 +61,40 @@ class ZapUserSetCard(
override fun id() = user.pubkeyHex + "U" + createdAt
}
/**
* Per-sender NIP-61 nutzap aggregate, parallel to [ZapUserSetCard].
* Carries raw kind:9321 [Note]s rather than [CombinedZap] pairs because
* a nutzap is a single event (no request/receipt split like lightning).
* Rendered with a cashu icon so the user can tell at a glance that it's
* a cashu rail, not a lightning rail.
*/
@Immutable
class NutzapUserSetCard(
val user: User,
val nutzapEvents: ImmutableList<Note>,
) : Card {
val createdAt = nutzapEvents.maxOfOrNull { it.createdAt() ?: 0L } ?: 0L
override fun createdAt(): Long = createdAt
// Suffix differs from ZapUserSetCard so the two never collide on id.
override fun id() = user.pubkeyHex + "N" + createdAt
}
@Immutable
class MultiSetCard(
val note: Note,
val boostEvents: ImmutableList<Note>,
val likeEvents: ImmutableList<Note>,
val zapEvents: ImmutableList<CombinedZap>,
val nutzapEvents: ImmutableList<Note> = persistentListOf(),
) : Card {
val maxCreatedAt =
maxOf(
zapEvents.maxOfOrNull { it.createdAt() ?: 0L } ?: 0L,
likeEvents.maxOfOrNull { it.createdAt() ?: 0L } ?: 0L,
boostEvents.maxOfOrNull { it.createdAt() ?: 0L } ?: 0L,
nutzapEvents.maxOfOrNull { it.createdAt() ?: 0L } ?: 0L,
)
val minCreatedAt =
@@ -79,6 +102,7 @@ class MultiSetCard(
zapEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
nutzapEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
)
val likeEventsByType =
@@ -127,11 +151,16 @@ fun Card.containsEventId(eventId: String): Boolean =
zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId }
}
is NutzapUserSetCard -> {
nutzapEvents.any { it.idHex == eventId }
}
is MultiSetCard -> {
note.idHex == eventId ||
zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId } ||
likeEvents.any { it.idHex == eventId } ||
boostEvents.any { it.idHex == eventId }
boostEvents.any { it.idHex == eventId } ||
nutzapEvents.any { it.idHex == eventId }
}
else -> {
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NutzapUserSetCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -290,6 +291,16 @@ private fun RenderCardItem(
)
}
is NutzapUserSetCard -> {
NutzapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead,
)
}
is MultiSetCard -> {
MultiSetCompose(
item,
@@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -128,6 +129,7 @@ class NotificationFeedFilter(
ReactionEvent.KIND,
RepostEvent.KIND,
LnZapEvent.KIND,
NutzapEvent.KIND,
OnchainZapEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
PictureEvent.KIND,