From 33fb3a54b295ef5d797bed5cfc613ee9e87ef103 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 20:08:46 +0000 Subject: [PATCH] feat: account for and display BOLT12 zaps everywhere lightning zaps are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the receiving side of NIP-XX BOLT12 zaps (kind 9736) into every place a NIP-57 lightning zap is counted or shown. Sending is intentionally left for later. Modeled on the lightning-zap scheme (synchronous, the proof carries the amount, counted the moment it validates) rather than the onchain scheme (async chain backend, PENDING/CONFIRMED, CONFIRMED-only) — BOLT12 proof verification is a self-contained synchronous check, so no resolver/backend is needed. Model (commons): - Bolt12ZapEntry + Note.bolt12Zaps map keyed by the proof's invoice_payment_hash (the spec dedup key); addBolt12Zap/removeBolt12ZapBySource; folded into updateZapTotal (millisats → sats) alongside lightning/onchain/nutzap amounts; wired into clearChildLinks, moveAllReferencesTo, removeNote, hasZapsBoostsOrReactions, hasZapped, and the isZappedBy family. Ingestion (LocalCache): - consume(Bolt12ZapEvent): validate synchronously via Bolt12ZapValidator, then addBolt12Zap on the resolved targets (e / a / profile); computeReplyTo and live-activity channel routing branches; dispatch case. Subscriptions: added kind 9736 to every filter carrying LnZapEvent.KIND (notifications, replies/reactions to notes & addresses, profile received-zaps, live-activity goal + messages, nest room + collectors, notification dispatcher, shared NotificationKinds, app-functions). Aggregation / notifications: UserProfileZapsViewModel (mapper), NotificationSummaryState (both passes), NotificationFeedFilter (kinds, zap-receipt detection, payer author resolution, muted-thread + own-event gates), NotificationKinds own-event exception, ThreadAssembler.anchorsItsOwnThread, and the commons live-activity aggregators (RoomZapsState, LiveStreamTopZappers, NestViewModel). UI: RenderBolt12Zap standalone card (styled like the lightning card, labeled BOLT12) wired into NoteCompose + ThreadFeedView; Bolt12ZapGallery in the reactions row (payer avatars + amounts, unverified/compressed proofs dimmed); reaction-row counter gate; KindNames / KindDisplayName entries. Note: validated BOLT12 zaps are counted immediately; a compressed proof whose signatures aren't yet verifiable (pending lightning/bolts#1346 merkle reconstruction) is stored with cryptoVerified=false and dimmed in the gallery. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../amethyst/model/LocalCache.kt | 77 +++++++++ .../notifications/NotificationDispatcher.kt | 2 + .../FilterNotificationsToPubkey.kt | 3 + .../FilterRepliesAndReactionsToAddresses.kt | 2 + .../FilterRepliesAndReactionsToNotes.kt | 2 + .../amethyst/ui/note/Bolt12ZapGallery.kt | 151 ++++++++++++++++++ .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/ReactionsRow.kt | 4 +- .../amethyst/ui/note/types/Bolt12ZapEvent.kt | 100 ++++++++++++ .../FilterGoalForLiveActivity.kt | 3 +- .../FilterMessagesToLiveStream.kt | 2 + .../datasource/NestRoomFilterAssembler.kt | 2 + .../room/lifecycle/NestRoomEventCollectors.kt | 4 +- .../notifications/NotificationSummaryState.kt | 24 +++ .../dal/NotificationFeedFilter.kt | 12 +- .../FilterUserProfileZapReceived.kt | 3 +- .../zaps/dal/UserProfileZapsViewModel.kt | 14 +- .../screen/loggedIn/relays/KindDisplayName.kt | 2 + .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../appfunctions/AmethystAppFunctions.kt | 3 +- .../amethyst/commons/model/Bolt12ZapEntry.kt | 51 ++++++ .../amethyst/commons/model/Note.kt | 104 +++++++++++- .../amethyst/commons/model/ThreadAssembler.kt | 3 +- .../notifications/NotificationKinds.kt | 5 +- .../LiveStreamTopZappersViewModel.kt | 25 ++- .../commons/viewmodels/RoomZapsState.kt | 28 +++- .../commons/viewmodels/NestViewModel.kt | 11 ++ .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + 28 files changed, 627 insertions(+), 22 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3138eee1d8..fa5381d667 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -318,6 +318,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import com.vitorpamplona.quartz.utils.DualCase @@ -385,6 +388,13 @@ object LocalCache : ILocalCache, ICacheProvider { */ val onchainZapResolver = OnchainZapResolver(this) + /** + * NIP-XX BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification + * is synchronous (a self-contained `lnp` payer proof), so `consume(Bolt12ZapEvent)` + * validates inline and needs no async resolver. + */ + val bolt12ZapValidator = Bolt12ZapValidator() + /** * Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to * validate NIP-57 Appendix F. `null` skips the receipt-signer check (the @@ -1181,6 +1191,17 @@ object LocalCache : ILocalCache, ICacheProvider { } } + is Bolt12ZapEvent -> { + // NIP-XX BOLT12 zaps target an event (e), an addressable event (a), + // or just the recipient profile (p) — same shape as onchain zaps. + buildList { + event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } } + event.zappedAddress()?.let { coord -> + Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) } + } + } + } + is NutzapEvent -> { // The zapped event is carried in the kind:9321's `e` tags // (and optionally an `a` tag for addressables). Whichever @@ -2380,6 +2401,41 @@ object LocalCache : ILocalCache, ICacheProvider { return !alreadyLoaded } + fun consume( + event: Bolt12ZapEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val note = getOrCreateNote(event.id) + + // Already processed — still route it into any live-activity channel it references. + if (note.event != null) { + attachZapToLiveActivityChannel(event, note, relay) + return false + } + + if (!(wasVerified || justVerify(event))) return false + + // NIP-XX validation is fully synchronous: zap-event structure, the embedded + // kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed + // validation drops the zap entirely — it never contributes to a zap total. + val validation = bolt12ZapValidator.validate(event) + if (validation !is Bolt12ZapValidation.Valid) { + Log.w("ZP") { "dropping bolt12 zap ${event.id}: ${(validation as Bolt12ZapValidation.Invalid).reason}" } + return false + } + + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) + note.loadEvent(event, author, repliesTo) + repliesTo.forEach { + it.addBolt12Zap(note, validation.paymentHashHex, validation.amountMillisats, validation.proofCryptoVerified) + } + attachZapToLiveActivityChannel(event, note, relay) + refreshNewNoteObservers(note) + return true + } + /** * Consume a NIP-61 nutzap (kind 9321). Resolves the e-tagged target * note(s), parses the proof amounts once, and attaches a `NutzapEntry` @@ -2435,6 +2491,23 @@ object LocalCache : ILocalCache, ICacheProvider { } } + private fun attachZapToLiveActivityChannel( + event: Bolt12ZapEvent, + note: Note, + relay: NormalizedRelayUrl?, + ) { + // Only surface zaps whose recipient is the live activity host. + val host = event.recipient() ?: return + event.tags + .asSequence() + .mapNotNull(ATag::parseAddress) + .filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex == host } + .distinct() + .forEach { address -> + getOrCreateLiveChannel(address).addNote(note, relay) + } + } + fun consume( event: LnZapRequestEvent, relay: NormalizedRelayUrl?, @@ -4301,6 +4374,10 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is Bolt12ZapEvent -> { + consume(event, relay, wasVerified) + } + is NIP90StatusEvent -> { consumeRegularEvent(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 1f583ded83..47dd5258d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException @@ -102,6 +103,7 @@ class NotificationDispatcher( PrivateDmEvent.KIND, LnZapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, ReactionEvent.KIND, TextNoteEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 579eb13aa5..1842233e05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -58,6 +58,7 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Kinds that notify me about activity on MY messages inside a NIP-29 group — reactions, replies @@ -74,6 +75,7 @@ val GroupNotificationKinds = RepostEvent.KIND, GenericRepostEvent.KIND, LnZapEvent.KIND, + Bolt12ZapEvent.KIND, ReportEvent.KIND, ) @@ -85,6 +87,7 @@ val SummaryKinds = GenericRepostEvent.KIND, LnZapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, ) val NotificationsPerKeyKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 9015be8e3f..8041f4f964 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsToAddressesKinds1 = @@ -47,6 +48,7 @@ val RepliesAndReactionsToAddressesKinds1 = GenericRepostEvent.KIND, ReportEvent.KIND, LnZapEvent.KIND, + Bolt12ZapEvent.KIND, ZapPollEvent.KIND, CommentEvent.KIND, AttestationEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index f9a5052356..7d620418f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsKinds = @@ -55,6 +56,7 @@ val RepliesAndReactionsKinds = ReportEvent.KIND, LnZapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.kt new file mode 100644 index 0000000000..5f58edc3df --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Bolt12ZapGallery.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.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.draw.alpha +import com.vitorpamplona.amethyst.commons.model.Bolt12ZapEntry +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.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 onBolt12ZapEntryClick( + entry: Bolt12ZapEntry, + nav: INav, +) { + entry.source.author?.let { nav.nav(routeFor(it)) } +} + +/** + * Reactions-row gallery of the payers who BOLT12-zapped this note. Mirrors the + * onchain gallery but simpler: BOLT12 zaps are validated synchronously at consume + * time, so there is no async re-verification and no pending state — every entry + * here is already counted. `Note.addBolt12Zap` invalidates `flowSet.zaps`, so this + * refreshes on arrival; memoizing on the `bolt12Zaps` map reference keeps a busy + * lightning thread from churning it. + */ +@Composable +internal fun WatchBolt12ZapsAndRenderGallery( + baseNote: Note, + nav: INav, + accountViewModel: AccountViewModel, +) { + val zapsState by observeNoteZaps(baseNote, accountViewModel) + val bolt12ZapsMap = zapsState?.note?.bolt12Zaps + val entries = + remember(bolt12ZapsMap) { + bolt12ZapsMap?.values?.toImmutableList() ?: persistentListOf() + } + + if (entries.isNotEmpty()) { + RenderBolt12ZapGallery(entries, nav, accountViewModel) + } +} + +@Composable +private fun RenderBolt12ZapGallery( + entries: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Row(Modifier.fillMaxWidth()) { + Box(modifier = WidthAuthorPictureModifier) { + ZappedIcon( + modifier = Modifier.size(Size25dp).align(Alignment.TopEnd), + ) + } + + Bolt12ZapAuthorGallery(entries, nav, accountViewModel) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Bolt12ZapAuthorGallery( + entries: ImmutableList, + nav: INav, + accountViewModel: AccountViewModel, +) { + Column(modifier = StdStartPadding) { + FlowRow { + entries.forEach { entry -> + Bolt12ZapEntryRow(entry, nav, accountViewModel) + } + } + } +} + +@Composable +private fun Bolt12ZapEntryRow( + entry: Bolt12ZapEntry, + nav: INav, + accountViewModel: AccountViewModel, +) { + val user = entry.source.author + + // The amount is validated (checked against the proof's invoice_amount), so it + // is safe to show for any sender. A not-yet-crypto-verified (compressed) proof + // still dims its avatar so the viewer can tell it apart from a fully-verified one. + val avatarAlpha = if (entry.cryptoVerified) 1f else 0.6f + val amountText = + remember(entry.amountMillisats) { + val sats = entry.amountMillisats / 1000 + if (sats > 0L) showAmount(BigDecimal.valueOf(sats)) else "" + } + + Box( + modifier = Size35Modifier.clickable { onBolt12ZapEntryClick(entry, nav) }, + contentAlignment = Alignment.BottomCenter, + ) { + Box(modifier = Modifier.alpha(avatarAlpha)) { + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( + user, + accountViewModel, + ) + } + + if (amountText.isNotEmpty()) { + CrossfadeToDisplayAmount(amountText) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 854554860e..db3d6bf147 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -126,6 +126,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex +import com.vitorpamplona.amethyst.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent @@ -351,6 +352,7 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -1091,6 +1093,10 @@ private fun RenderNoteRow( RenderOnchainZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } + is Bolt12ZapEvent -> { + RenderBolt12Zap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + is LiveActivitiesClipEvent -> { RenderChatClip(baseNote, accountViewModel, nav) } 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 188994e900..1272b021c7 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 @@ -582,6 +582,7 @@ private fun ReactionDetailGallery( WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) WatchNutzapsAndRenderGallery(baseNote, nav, accountViewModel) WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel) + WatchBolt12ZapsAndRenderGallery(baseNote, nav, accountViewModel) WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel) if (relays.isNotEmpty()) { @@ -1509,7 +1510,8 @@ fun ObserveZapIconState( zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true || zapsState?.note?.nutzaps?.isNotEmpty() == true || - zapsState?.note?.onchainZaps?.isNotEmpty() == true + zapsState?.note?.onchainZaps?.isNotEmpty() == true || + zapsState?.note?.bolt12Zaps?.isNotEmpty() == true val wasZapped = if (hasZapData) { accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt new file mode 100644 index 0000000000..364836cc60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Bolt12ZapEvent.kt @@ -0,0 +1,100 @@ +/* + * 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.layout.size +import androidx.compose.material3.MaterialTheme +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.commons.ui.note.ActivityAmountRow +import com.vitorpamplona.amethyst.commons.ui.note.ActivityBadge +import com.vitorpamplona.amethyst.commons.ui.note.ActivityCardFrame +import com.vitorpamplona.amethyst.commons.ui.note.ActivityHeaderRow +import com.vitorpamplona.amethyst.model.Note +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.UserPicture +import com.vitorpamplona.amethyst.ui.note.ZapIcon +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.nipXXBolt12Zaps.zap.Bolt12ZapEvent +import java.text.NumberFormat + +/** + * Standalone card for a NIP-XX BOLT12 zap (kind 9736), styled like the NIP-57 + * lightning-zap card but labeled BOLT12. The sender is the `P` payer tag (or the + * event pubkey when anonymous); the amount comes straight off the validated + * `amount` tag — no LNURL provider or private-zap decryption is involved. + */ +@Composable +fun RenderBolt12Zap( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? Bolt12ZapEvent ?: return + + val senderKey = event.payer() + val recipientKey = event.recipient() + val amountSats = event.amount()?.div(1000) + val comment = event.content.takeIf { it.isNotBlank() } + + val orange = MaterialTheme.colorScheme.bitcoinColor + + ActivityCardFrame(orange) { cardBackground -> + ActivityHeaderRow( + tint = orange, + pillLabel = "BOLT12", + badge = { + ActivityBadge(orange) { + ZapIcon(Modifier.size(18.dp), Color.White) + } + }, + senderAvatar = { + if (senderKey != null) { + UserPicture(senderKey, Size25dp, Modifier, accountViewModel, nav) + } else { + // Anonymous zap — no attributable payer. + DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel) + } + }, + recipientAvatar = + recipientKey?.let { + { UserPicture(it, Size25dp, Modifier, accountViewModel, nav) } + }, + ) + + RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + + amountSats?.let { ActivityAmountRow(NumberFormat.getNumberInstance().format(it), orange) } + + comment?.let { + CrossfadeToDisplayComment(it, cardBackground, nav, accountViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt index 7880c23d9e..16ce66b533 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts @@ -56,7 +57,7 @@ fun filterGoalForLiveActivities( relay = relay, filter = Filter( - kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("e" to listOf(goalId)), limit = 200, since = since?.get(relay)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 93c9984855..12a8396a9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -46,6 +47,7 @@ fun filterMessagesToLiveActivities( LiveActivitiesClipEvent.KIND, LnZapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index 74432b8466..e14ee14a33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Per-room state for every wire subscription scoped to a single @@ -96,6 +97,7 @@ class NestRoomFilterSubAssembler( MeetingRoomPresenceEvent.KIND, ReactionEvent.KIND, LnZapEvent.KIND, + Bolt12ZapEvent.KIND, ), tags = mapOf("a" to listOf(key.note.idHex)), since = since?.get(relay)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index 4ac548b7a4..c1c2c8ef9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -168,7 +169,7 @@ private fun ZapsCollector( LaunchedEffect(viewModel, roomATag) { val filter = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("a" to listOf(roomATag)), ) LocalCache.observeNotes(filter).collect { notes -> @@ -176,6 +177,7 @@ private fun ZapsCollector( notes.forEach { note -> viewModel.onChatEvent(note) (note.event as? LnZapEvent)?.let { viewModel.onZapEvent(it, nowSec) } + (note.event as? Bolt12ZapEvent)?.let { viewModel.onZapEvent(it, nowSec) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 7c4cbbe909..6ab60f051e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -136,6 +137,17 @@ class NotificationSummaryState( } } + noteEvent is Bolt12ZapEvent -> { + if (noteEvent.isTaggedUser(currentUser)) { + val amount = noteEvent.amount() + if (amount != null) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + BigDecimal.valueOf(amount / 1000) + takenIntoAccount.add(noteEvent.id) + } + } + } + noteEvent is BaseThreadedEvent && noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser -> { @@ -222,6 +234,18 @@ class NotificationSummaryState( } } + noteEvent is Bolt12ZapEvent -> { + if (noteEvent.isTaggedUser(currentUser)) { + val amount = noteEvent.amount() + if (amount != null) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + BigDecimal.valueOf(amount / 1000) + takenIntoAccount.add(noteEvent.id) + hasNewElements = true + } + } + } + noteEvent is BaseThreadedEvent && noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 7f2e2ca265..f49c2c198c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -163,6 +164,7 @@ class NotificationFeedFilter( LnZapEvent.KIND, NutzapEvent.KIND, OnchainZapEvent.KIND, + Bolt12ZapEvent.KIND, LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, PollEvent.KIND, @@ -264,7 +266,8 @@ class NotificationFeedFilter( // on the user marks it as theirs. val targetsZapReceipt = event.hasScopeKind(LnZapEvent.KIND.toString()) || - note.replyTo?.any { it.event is LnZapEvent } == true + event.hasScopeKind(Bolt12ZapEvent.KIND.toString()) || + note.replyTo?.any { it.event is LnZapEvent || it.event is Bolt12ZapEvent } == true if (targetsZapReceipt && event.isTaggedUser(authorHex)) { return true @@ -424,6 +427,9 @@ class NotificationFeedFilter( } else { noteEvent.pubKey } + } else if (noteEvent is Bolt12ZapEvent) { + // The sender is the `P` payer tag; anonymous zaps sign with an ephemeral key. + noteEvent.payer() ?: noteEvent.pubKey } else { if (it is AddressableNote) { it.address.pubKeyHex @@ -434,7 +440,7 @@ class NotificationFeedFilter( // Reactions/zaps/reposts target a note via `replyTo`, not via thread-root tags, // so isNotInMutedThread on the wrapper event misses them. - if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || + if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent ) { val target = it.replyTo?.lastOrNull() @@ -492,7 +498,7 @@ class NotificationFeedFilter( // relevance check (tagsAnEventByUser is skipped below); it still scopes // to genuine replies, so unrelated channel chatter never leaks through. return noteEvent?.kind in NOTIFICATION_KINDS && - (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && + (noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) && (isChessEvent || isConcord || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index 2cc37d239b..13f7d1f4d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -27,8 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent -val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND) +val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND) fun filterUserProfileZapsReceived( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 6ec8803b4b..5a8292d3c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -54,7 +55,7 @@ class UserProfileZapsViewModel( ) : ViewModel() { val zapsToUser = Filter( - kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), ) @@ -105,6 +106,16 @@ class UserProfileZapsViewModel( ) } + private fun mapBolt12Zap(event: Bolt12ZapEvent): ZapAmount { + // The payer is the `P` tag; anonymous zaps fall back to the event pubkey. + // amount() is in millisats — divide to sats for the profile total. + val amountSats = (event.amount() ?: 0L) / 1000 + return ZapAmount( + LocalCache.getOrCreateUser(event.payer() ?: event.pubKey), + BigDecimal(amountSats), + ) + } + suspend fun List.sumAmountsByUser(): List { val results = mutableMapOf() @@ -113,6 +124,7 @@ class UserProfileZapsViewModel( when (zapEvent) { is LnZapEvent -> mapRequest(zapEvent) is OnchainZapEvent -> mapOnchainZap(zapEvent) + is Bolt12ZapEvent -> mapBolt12Zap(zapEvent) else -> null } if (zapAmount != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt index cdd630e3c0..59108d02b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -169,6 +169,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */ @Suppress("DEPRECATION") @@ -261,6 +262,7 @@ fun kindDisplayName(kind: Int): Int = LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats LiveActivitiesEvent.KIND -> R.string.kind_live_streams LnZapEvent.KIND -> R.string.kind_zaps + Bolt12ZapEvent.KIND -> R.string.kind_zaps LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response LnZapPrivateEvent.KIND -> R.string.kind_private_zaps diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 2f92fbe65f..264741738b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -162,6 +162,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex +import com.vitorpamplona.amethyst.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage @@ -348,6 +349,7 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -919,6 +921,8 @@ private fun FullBleedNoteCompose( RenderNutzap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is OnchainZapEvent) { RenderOnchainZap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) + } else if (noteEvent is Bolt12ZapEvent) { + RenderBolt12Zap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is ReactionEvent) { RenderReaction(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index a9b1ef80cd..2cd7d8c514 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async @@ -776,7 +777,7 @@ class AmethystAppFunctions { val filter = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("p" to listOf(myPub)), since = sinceSecs, limit = 500, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt new file mode 100644 index 0000000000..5a5cf7c015 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Bolt12ZapEntry.kt @@ -0,0 +1,51 @@ +/* + * 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.commons.model + +import androidx.compose.runtime.Stable + +/** + * Per-payment NIP-XX BOLT12 zap entry attached to a target Note. + * + * Unlike NIP-BC onchain zaps (which carry an async chain-verification state + * machine), a BOLT12 zap is validated **synchronously** at consumption time — + * the `lnp` payer proof is a self-contained cryptographic settlement proof — so + * every entry stored here has already passed [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator] + * and its amount is counted directly, the same way a NIP-57 lightning zap + * receipt's amount is. + * + * @property source The kind:9736 Bolt12ZapEvent note. `source.author` is the + * payer shown in the reactions gallery and notifications card + * (the `P` tag; an anonymous zap uses an ephemeral key). + * @property amountMillisats The validated amount in **millisatoshis** (the + * `amount` tag, checked against the proof's `invoice_amount`). + * @property cryptoVerified True when the payer proof's signatures were fully + * verified. False when the zap is structurally valid and bound + * to its intent but the proof is compressed and its signatures + * can't yet be checked (pending the lightning/bolts#1346 merkle + * reconstruction). The UI SHOULD label the latter as unverified. + */ +@Stable +data class Bolt12ZapEntry( + val source: Note, + val amountMillisats: Long, + val cryptoVerified: Boolean, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 802ba935b7..435718deef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -169,6 +169,7 @@ open class Note( removeLabel(note) removeNutzap(note) removeOnchainZapBySource(note) + removeBolt12ZapBySource(note) } var poll: PollResponsesCache? = null @@ -249,6 +250,22 @@ open class Note( var nutzaps = mapOf() private set + /** + * NIP-XX BOLT12 zaps (kind 9736) targeting this note. + * Key: the payer proof's `invoice_payment_hash` (hex) — the spec's dedup key, + * so two zap events proving the same settled payment collapse to one entry. + * Value: entry with the source Bolt12ZapEvent note (so `source.author` is the + * payer), the validated amount in millisats, and whether the proof's crypto + * was fully verified. Every entry here has already passed the synchronous + * `Bolt12ZapValidator`, so all are counted by `updateZapTotal` (there is no + * async pending state like onchain zaps have). + * + * `@Volatile` for the same cross-thread visibility reason as [onchainZaps]. + */ + @Volatile + var bolt12Zaps = mapOf() + private set + var zapPayments = mapOf() private set @@ -376,7 +393,8 @@ open class Note( zaps.isNotEmpty() || boosts.isNotEmpty() || onchainZaps.isNotEmpty() || - nutzaps.isNotEmpty() + nutzaps.isNotEmpty() || + bolt12Zaps.isNotEmpty() fun countReactions(): Int { var total = 0 @@ -408,7 +426,7 @@ open class Note( fun clearChildLinks(): List { val repliesChanged = replies.isNotEmpty() val reactionsChanged = reactions.isNotEmpty() - val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() + val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() || bolt12Zaps.isNotEmpty() val boostsChanged = boosts.isNotEmpty() val reportsChanged = reports.isNotEmpty() val labelsChanged = labels.isNotEmpty() @@ -424,7 +442,8 @@ open class Note( zapPayments.keys + zapPayments.values.filterNotNull() + nutzaps.values.map { it.source } + - onchainZaps.values.map { it.source } + onchainZaps.values.map { it.source } + + bolt12Zaps.values.map { it.source } replies = listOf() reactions = mapOf() @@ -435,6 +454,7 @@ open class Note( onchainZaps = mapOf() onchainZapResolved = false nutzaps = mapOf() + bolt12Zaps = mapOf() zapPayments = mapOf() zapsAmount = BigDecimal(0) relays = listOf() @@ -713,6 +733,59 @@ open class Note( } } + private fun innerAddBolt12Zap( + paymentHashHex: String, + entry: Bolt12ZapEntry, + ): Boolean = + syncLock.withLock { + val existing = bolt12Zaps[paymentHashHex] + if (existing != null) { + // Same settled payment (dedup by invoice_payment_hash) — a relay echo. + if (entry == existing) return@withLock false + // Prefer a fully crypto-verified entry; never let an unverified + // (compressed-proof) republish overwrite a verified one. + if (!entry.cryptoVerified && existing.cryptoVerified) return@withLock false + } + bolt12Zaps = bolt12Zaps + Pair(paymentHashHex, entry) + return@withLock true + } + + private fun innerRemoveBolt12ZapBySource(source: Note): Boolean = + syncLock.withLock { + val newMap = bolt12Zaps.filterValues { it.source != source } + if (newMap.size == bolt12Zaps.size) return@withLock false + bolt12Zaps = newMap + return@withLock true + } + + /** + * Register a NIP-XX BOLT12 zap targeting this note. [source] is the kind:9736 + * event's own note — `source.author` is the payer shown in the reactions + * gallery and notifications. [amountMillisats] and [cryptoVerified] come from + * the synchronous [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator] + * verdict; the caller MUST only call this for a `Valid` result. Deduplicated by + * [paymentHashHex] (the proof's `invoice_payment_hash`). + */ + fun addBolt12Zap( + source: Note, + paymentHashHex: String, + amountMillisats: Long, + cryptoVerified: Boolean, + ) { + if (innerAddBolt12Zap(paymentHashHex, Bolt12ZapEntry(source, amountMillisats, cryptoVerified))) { + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + + /** Detach every BOLT12-zap entry contributed by [source] — used when the source note is pruned or deleted. */ + fun removeBolt12ZapBySource(source: Note) { + if (innerRemoveBolt12ZapBySource(source)) { + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + private fun innerAddZapPayment( zapPaymentRequest: Note, zapPayment: Note?, @@ -906,6 +979,7 @@ open class Note( // zap requests). if (isNutzappedBy(user, afterTimeInSeconds)) return true if (isOnchainZappedBy(user, afterTimeInSeconds)) return true + if (isBolt12ZappedBy(user, afterTimeInSeconds)) return true val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps) if (first) return true @@ -933,6 +1007,15 @@ open class Note( entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds } + private fun isBolt12ZappedBy( + user: User, + afterTimeInSeconds: Long, + ): Boolean = + bolt12Zaps.values.any { entry -> + val sourceEvent = entry.source.event ?: return@any false + entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds + } + /** * Extra sats to add on top of [zapsAmount] for the reaction-row * counter when the signed-in user has outgoing onchain zaps on @@ -1019,6 +1102,13 @@ open class Note( sumOfAmounts += BigDecimal(entry.claimedSats) } + // NIP-XX BOLT12 zaps — validated synchronously at consume time (the `lnp` + // payer proof is a self-contained settlement proof), so every stored entry + // counts, converting its millisat amount to sats like the lightning path. + bolt12Zaps.values.forEach { entry -> + sumOfAmounts += BigDecimal(entry.amountMillisats / 1000) + } + zapsAmount = sumOfAmounts } @@ -1186,7 +1276,8 @@ open class Note( fun hasZapped(loggedIn: User): Boolean = zaps.any { it.key.author == loggedIn } || - nutzaps.values.any { it.source.author == loggedIn } + nutzaps.values.any { it.source.author == loggedIn } || + bolt12Zaps.values.any { it.source.author == loggedIn } fun hasReacted( loggedIn: User, @@ -1251,6 +1342,10 @@ open class Note( note.addOnchainZap(entry.source, txid, entry.claimedSats, entry.verifiedSats, entry.status) entry.source.replyTo = entry.source.replyTo?.replace(this, note) } + bolt12Zaps.forEach { (paymentHash, entry) -> + note.addBolt12Zap(entry.source, paymentHash, entry.amountMillisats, entry.cryptoVerified) + entry.source.replyTo = entry.source.replyTo?.replace(this, note) + } zapPayments.forEach { note.addZapPayment(it.key, it.value) it.key.replyTo = it.key.replyTo?.replace(this, note) @@ -1271,6 +1366,7 @@ open class Note( zaps = emptyMap() nutzaps = emptyMap() onchainZaps = emptyMap() + bolt12Zaps = emptyMap() zapPayments = emptyMap() labels = emptyMap() zapsAmount = BigDecimal(0) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index fa37cf253b..ddffadc87c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet @@ -182,7 +183,7 @@ class ThreadAssembler( */ fun Event?.anchorsItsOwnThread(): Boolean = when (this) { - is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent -> true + is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent, is Bolt12ZapEvent -> true else -> false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt index ee651383e9..9fc760dd29 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * Nostr event kinds that can generate a notification when they tag the @@ -70,6 +71,7 @@ object NotificationKinds { NutzapEvent.KIND, // 9321 — NIP-61 Cashu nutzap LnZapEvent.KIND, // 9735 — NIP-57 zap receipt OnchainZapEvent.KIND, // 8333 — onchain zap + Bolt12ZapEvent.KIND, // 9736 — NIP-XX BOLT12 zap // NIP-17 file-header messages (encrypted file DMs) ChatMessageEncryptedFileHeaderEvent.KIND, ) @@ -114,7 +116,8 @@ object NotificationKinds { if (event.pubKey == myPubKeyHex && event !is LnZapEvent && event !is NutzapEvent && - event !is OnchainZapEvent + event !is OnchainZapEvent && + event !is Bolt12ZapEvent ) { return false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt index 66a790c31a..51e0ccfb18 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -144,6 +145,11 @@ class LiveStreamTopZappersViewModel( goalContributions[it.receiptId] = it } } + goal?.bolt12Zaps?.values?.forEach { entry -> + contributionFromStreamZap(entry.source)?.let { + goalContributions[it.receiptId] = it + } + } } } @@ -152,12 +158,19 @@ class LiveStreamTopZappersViewModel( _topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit) } - private fun contributionFromStreamZap(note: Note): ZapContribution? { - val ev = note.event as? LnZapEvent ?: return null - val request = ev.zapRequest ?: return null - val sats = ev.amount()?.toLong() ?: return null - return ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) - } + private fun contributionFromStreamZap(note: Note): ZapContribution? = + when (val ev = note.event) { + is LnZapEvent -> { + val request = ev.zapRequest ?: return null + val sats = ev.amount()?.toLong() ?: return null + ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) + } + is Bolt12ZapEvent -> { + val sats = ev.amount()?.div(1000) ?: return null + ZapContribution(note.idHex, ev.payer() ?: ev.pubKey, ev.isAnonymous(), sats) + } + else -> null + } private fun contributionFromGoalZap( zapRequestNote: Note, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt index 89deb9d672..052d1d09ac 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent /** * One in-flight kind-9735 zap to render as a floating overlay on the @@ -61,6 +62,20 @@ data class RoomZap( amountSats = event.amount?.toLong(), createdAtSec = event.createdAt, ) + + /** + * Project a kind-9736 [Bolt12ZapEvent] into a [RoomZap]. The zapper is + * the `P` payer tag (or the event pubkey for an anonymous zap); the amount + * is the `amount` tag in millisats converted to sats. + */ + fun from(event: Bolt12ZapEvent): RoomZap = + RoomZap( + eventId = event.id, + sourcePubkey = event.payer() ?: event.pubKey, + targetPubkey = event.recipient(), + amountSats = event.amount()?.div(1000), + createdAtSec = event.createdAt, + ) } } @@ -82,8 +97,19 @@ class RoomZapsAggregator { event: LnZapEvent, nowSec: Long, windowSec: Long, + ): Map> = apply(RoomZap.from(event), nowSec, windowSec) + + fun apply( + event: Bolt12ZapEvent, + nowSec: Long, + windowSec: Long, + ): Map> = apply(RoomZap.from(event), nowSec, windowSec) + + private fun apply( + incoming: RoomZap, + nowSec: Long, + windowSec: Long, ): Map> { - val incoming = RoomZap.from(event) // Dedup: a relay re-delivery (or LocalCache.observeNotes's // full-list re-emit) of the same receipt must not stack. byEventId[incoming.eventId] = incoming diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 0cd319ccd1..39e7d8743f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker import com.vitorpamplona.nestsclient.transport.WebTransportFactory import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -636,6 +637,16 @@ class NestViewModel( _recentZaps.value = zapsAgg.apply(event, nowSec, windowSec) } + /** Apply one kind-9736 BOLT12 zap to the same sliding-window aggregator. */ + fun onZapEvent( + event: Bolt12ZapEvent, + nowSec: Long, + windowSec: Long = REACTION_WINDOW_SEC, + ) { + if (closed) return + _recentZaps.value = zapsAgg.apply(event, nowSec, windowSec) + } + /** * Drop zaps older than the staleness threshold. Platform layer * drives this on a 1-s tick — the same cadence as [evictReactions] diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 25d3a9bf8e..b5a13b6e88 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -303,6 +303,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent @@ -539,6 +540,7 @@ object KindNames { RelayAddMemberEvent.KIND to KindName("Relay Add Member", "43"), RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"), OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"), + Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "XX"), PutUserEvent.KIND to KindName("Group Put User", "29"), RemoveUserEvent.KIND to KindName("Group Remove User", "29"), EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"),