mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
feat: account for and display BOLT12 zaps everywhere lightning zaps are
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+3
@@ -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 =
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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<Bolt12ZapEntry>,
|
||||
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<Bolt12ZapEntry>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Color>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+3
-1
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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 -> {
|
||||
|
||||
+9
-3
@@ -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)) &&
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+13
-1
@@ -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<Event>.sumAmountsByUser(): List<ZapAmount> {
|
||||
val results = mutableMapOf<User, BigDecimal>()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+4
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+51
@@ -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,
|
||||
)
|
||||
@@ -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<HexKey, NutzapEntry>()
|
||||
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<String, Bolt12ZapEntry>()
|
||||
private set
|
||||
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
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<Note> {
|
||||
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)
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
+19
-6
@@ -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,
|
||||
|
||||
+27
-1
@@ -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<String, List<RoomZap>> = apply(RoomZap.from(event), nowSec, windowSec)
|
||||
|
||||
fun apply(
|
||||
event: Bolt12ZapEvent,
|
||||
nowSec: Long,
|
||||
windowSec: Long,
|
||||
): Map<String, List<RoomZap>> = apply(RoomZap.from(event), nowSec, windowSec)
|
||||
|
||||
private fun apply(
|
||||
incoming: RoomZap,
|
||||
nowSec: Long,
|
||||
windowSec: Long,
|
||||
): Map<String, List<RoomZap>> {
|
||||
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
|
||||
|
||||
+11
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user