From 5e75fd1aae6b67a073730056f6473a09f9dfbb4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 23:00:42 +0000 Subject: [PATCH] feat: render likes, zaps, and nutzaps as gradient activity cards like onchain zaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the onchain zap card's visual language to the other interaction kinds. A shared ActivityCardFrame draws the rounded gradient wash, with a circular kind badge, sender → recipient avatars, and a kind pill on the first line; the makeItShort target post embeds between that line and the amount, exactly as in the onchain card, followed by the comment. - Lightning zaps: bitcoin orange, bolt badge, LIGHTNING pill, big sats amount, decrypted sender avatar. - Cashu nutzaps: bitcoin orange, cashu badge, CASHU pill. - Reactions: tinted with the Liked heart rose, heart badge (or the emoji itself for custom reactions), REACTION pill, no amount row. - Onchain zaps keep their card; the embedded target post moves inside it, between the header row and the amount. The body TransferCard and the trailing reaction emoji are replaced by the cards; TransferCard stays for its preview. https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa --- .../amethyst/ui/note/types/ActivityCard.kt | 175 ++++++++++++++++++ .../amethyst/ui/note/types/Nutzap.kt | 70 ++++--- .../amethyst/ui/note/types/OnchainZapEvent.kt | 3 +- .../amethyst/ui/note/types/Reaction.kt | 62 ++++--- .../amethyst/ui/note/types/ZapEvent.kt | 46 +++-- 5 files changed, 302 insertions(+), 54 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt new file mode 100644 index 0000000000..33fbbe195a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCard.kt @@ -0,0 +1,175 @@ +/* + * 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.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier + +/** + * Heart color of the Liked icon vector; tints reaction activity cards the way + * bitcoinColor tints the zap ones. + */ +val LikeTint = Color(0xFFCA395F) + +/** + * Gradient card frame shared by the reaction / zap / nutzap / onchain-zap + * renderings, mirroring the onchain card's look: rounded corners and a soft + * top-to-bottom wash of the kind's tint. + */ +@Composable +fun ActivityCardFrame( + tint: Color, + content: @Composable ColumnScope.() -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background( + Brush.linearGradient( + colors = + listOf( + tint.copy(alpha = 0.16f), + tint.copy(alpha = 0.04f), + ), + ), + ).padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), content = content) + } +} + +/** Circular kind badge: a small filled circle of the tint with a white glyph. */ +@Composable +fun ActivityBadge( + tint: Color, + icon: @Composable () -> Unit, +) { + Box( + modifier = + Modifier + .size(24.dp) + .clip(CircleShape) + .background(tint), + contentAlignment = Alignment.Center, + ) { + icon() + } +} + +/** Right-aligned kind label, the static cousin of the onchain card's dashed pill. */ +@Composable +fun ActivityPill( + label: String, + tint: Color, +) { + Box( + modifier = + Modifier + .border(1.4.dp, tint.copy(alpha = 0.7f), RoundedCornerShape(10.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = tint, + fontWeight = FontWeight.Bold, + ) + } +} + +/** First line of the card: badge, sender → recipient avatars, and the kind pill. */ +@Composable +fun ActivityHeaderRow( + tint: Color, + pillLabel: String?, + badge: @Composable () -> Unit, + senderAvatar: @Composable () -> Unit, + recipientAvatar: (@Composable () -> Unit)?, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + badge() + senderAvatar() + recipientAvatar?.let { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowForwardIos, + contentDescription = null, + tint = tint, + modifier = Size16Modifier, + ) + it() + } + + Spacer(Modifier.weight(1f)) + + pillLabel?.let { ActivityPill(it, tint) } + } +} + +/** Big amount line, matching the onchain card's typography. */ +@Composable +fun ActivityAmountRow( + amount: String, + tint: Color, +) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = amount, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold, + color = tint, + ) + Text( + text = "sats", + style = MaterialTheme.typography.titleSmall, + color = tint, + modifier = Modifier.padding(bottom = 6.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt index ac8b5da929..c7c1d08cf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nutzap.kt @@ -20,21 +20,31 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.hashtags.Cashu +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons 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.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor 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. + * Renders a NIP-61 nutzap (kind 9321) as an activity 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. */ @@ -48,25 +58,43 @@ fun RenderNutzap( ) { val nutzapEvent = note.event as? NutzapEvent ?: return - val recipientKey = nutzapEvent.linkedPubKeys().firstOrNull() ?: return + val recipientKey = nutzapEvent.linkedPubKeys().firstOrNull() + val orange = MaterialTheme.colorScheme.bitcoinColor - RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) + ActivityCardFrame(orange) { + ActivityHeaderRow( + tint = orange, + pillLabel = "CASHU", + badge = { + ActivityBadge(orange) { + Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + } + }, + senderAvatar = { + val sender = note.author + if (sender != null) { + UserPicture(sender, Size25dp, Modifier, accountViewModel, nav) + } else { + DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel) + } + }, + recipientAvatar = + recipientKey?.let { + { UserPicture(it, Size25dp, Modifier, accountViewModel, nav) } + }, + ) - val card = - remember(note) { - ZapAmountCommentNotification( - user = note.author, - comment = nutzapEvent.content.ifBlank { null }, - amount = showAmount(BigDecimal(nutzapEvent.claimedSatsTotal())), - zapNote = note, - ) + RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) + + ActivityAmountRow(showAmount(BigDecimal(nutzapEvent.claimedSatsTotal())), orange) + + nutzapEvent.content.ifBlank { null }?.let { + CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel) } - - TransferCard( - card, - recipientKey, - backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt index 4c9104cb61..7cb99cc8e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/OnchainZapEvent.kt @@ -99,7 +99,6 @@ fun RenderOnchainZap( val sender = note.author?.pubkeyHex ?: event.pubKey val recipient = event.recipient() ?: return - RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) val sats = event.claimedAmountInSats() ?: 0L val txid = event.txid() val message = event.content.takeIf { it.isNotBlank() } @@ -144,6 +143,8 @@ fun RenderOnchainZap( nav = nav, ) + RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) + AmountRow(sats = sats, orange = orange) ConfirmationPill(tx = tx, hasTxid = txid != null, orange = orange) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt index 5ffd2f8b9a..d9b959b9d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -20,16 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.LikedIcon +import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.note.RenderReaction as RenderReactionEmoji +/** + * Renders a kind 7 reaction as an activity card, like the zap kinds, tinted + * with the Liked heart color: badge + reactor → author on the first line and + * the reacted-to post quoted inside the card. + */ @Composable fun RenderReaction( note: Note, @@ -38,25 +48,35 @@ fun RenderReaction( accountViewModel: AccountViewModel, nav: INav, ) { - note.replyTo?.lastOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - makeItShort = true, - unPackReply = ReplyRenderType.NONE, - quotesLeft = quotesLeft - 1, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, + val reactionType = note.event?.content ?: "" + + ActivityCardFrame(LikeTint) { + ActivityHeaderRow( + tint = LikeTint, + pillLabel = "REACTION", + badge = { + if (reactionType == "+" || reactionType.isBlank()) { + ActivityBadge(LikeTint) { + LikedIcon(Modifier.size(16.dp), Color.White) + } + } else { + RenderReactionEmoji(reactionType) + } + }, + senderAvatar = { + val sender = note.author + if (sender != null) { + UserPicture(sender, Size25dp, Modifier, accountViewModel, nav) + } else { + DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel) + } + }, + recipientAvatar = + note.replyTo?.lastOrNull()?.author?.let { + { UserPicture(it, Size25dp, Modifier, accountViewModel, nav) } + }, ) + + RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) } - - // Reposts have trash in their contents. - val refactorReactionText = if (note.event?.content == "+") "❤" else note.event?.content ?: "" - - Text( - text = refactorReactionText, - maxLines = 1, - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt index 8bc9b50955..27a4f89786 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,6 +37,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache @@ -44,6 +46,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification @@ -99,20 +102,41 @@ fun RenderLnZap( ) { val zapEvent = note.event as? LnZapEvent ?: return - RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) - val card by parseAuthorCommentAndAmount(note, accountViewModel) + val recipientKey = zapEvent.zappedAuthor().firstOrNull() + val orange = MaterialTheme.colorScheme.bitcoinColor - val destinationKey = zapEvent.zappedAuthor().firstOrNull() ?: return + ActivityCardFrame(orange) { + ActivityHeaderRow( + tint = orange, + pillLabel = "LIGHTNING", + badge = { + ActivityBadge(orange) { + ZapIcon(Modifier.size(18.dp), Color.White) + } + }, + senderAvatar = { + val sender = card.user + if (sender != null) { + UserPicture(sender, Size25dp, Modifier, accountViewModel, nav) + } else { + DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel) + } + }, + recipientAvatar = + recipientKey?.let { + { UserPicture(it, Size25dp, Modifier, accountViewModel, nav) } + }, + ) - TransferCard( - card, - destinationKey, - backgroundColor, - Modifier, - accountViewModel, - nav, - ) + RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) + + card.amount?.let { ActivityAmountRow(it, orange) } + + card.comment?.let { + CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel) + } + } } /**