feat: auto-detect zap payments, show zapper profiles, fix algorithm
- ZapModal now polls LNURL verify URL for automatic payment detection instead of requiring manual "Done" click - Added lightning bolt celebration animation on zap success with random fun quotes - Show "Zapped by" section on film detail modal with profile pics, amounts, and avatar pills (like Primal/Yakihonne) - useNostr now subscribes to kind 9735 zap receipts per content, parses sender pubkey from embedded zap request, and fetches profiles - Fixed most-zapped algorithm to also parse the description tag in zap receipts for content matching (NIP-57 embedded zap requests) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -138,11 +138,40 @@
|
||||
</div>
|
||||
|
||||
<!-- Creator Attribution -->
|
||||
<div v-if="content.creator" class="flex items-center gap-3 mb-8 text-white/60 text-sm">
|
||||
<div v-if="content.creator" class="flex items-center gap-3 mb-6 text-white/60 text-sm">
|
||||
<span>Directed by</span>
|
||||
<span class="text-white font-medium">{{ content.creator }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Zaps Section -->
|
||||
<div v-if="zapsList.length > 0" class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="w-4 h-4 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-white">Zapped by</span>
|
||||
<span class="text-xs text-white/40">({{ totalZapSats.toLocaleString() }} sats)</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<div
|
||||
v-for="(zap, idx) in displayZaps"
|
||||
:key="idx"
|
||||
class="zap-avatar-pill"
|
||||
:title="getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats'"
|
||||
>
|
||||
<img
|
||||
:src="getZapperPicture(zap.pubkey)"
|
||||
:alt="getZapperName(zap.pubkey)"
|
||||
class="w-7 h-7 rounded-full object-cover"
|
||||
/>
|
||||
<span class="text-xs font-medium text-[#F7931A]">{{ formatZapAmount(zap.amount) }}</span>
|
||||
</div>
|
||||
<span v-if="zapsList.length > 8" class="text-xs text-white/40 ml-1">
|
||||
+{{ zapsList.length - 8 }} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-white/10 mb-6"></div>
|
||||
|
||||
@@ -245,6 +274,7 @@
|
||||
:isOpen="showZapModal"
|
||||
:content="content"
|
||||
@close="showZapModal = false"
|
||||
@zapped="handleZapped"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -347,6 +377,11 @@ const reactionCounts = computed(() => nostr.reactionCounts.value)
|
||||
const isLoadingComments = computed(() => nostr.isLoading.value)
|
||||
const commentCount = computed(() => nostr.commentCount.value)
|
||||
|
||||
// Zap data from relay
|
||||
const zapsList = computed(() => nostr.zaps.value)
|
||||
const displayZaps = computed(() => zapsList.value.slice(0, 8))
|
||||
const totalZapSats = computed(() => zapsList.value.reduce((sum, z) => sum + z.amount, 0))
|
||||
|
||||
// User's existing reaction read from relay (not local state)
|
||||
const userReaction = computed(() => nostr.userContentReaction.value)
|
||||
const hasVoted = computed(() => nostr.hasVotedOnContent.value)
|
||||
@@ -468,6 +503,29 @@ function handleZap() {
|
||||
showZapModal.value = true
|
||||
}
|
||||
|
||||
function handleZapped(_amount: number) {
|
||||
// The zap was confirmed — the relay subscription will pick up
|
||||
// the zap receipt automatically and update zapsList.
|
||||
}
|
||||
|
||||
function getZapperName(pubkey: string): string {
|
||||
const profile = nostr.profiles.value.get(pubkey)
|
||||
if (profile?.display_name) return profile.display_name
|
||||
if (profile?.name) return profile.name
|
||||
return pubkey.slice(0, 8) + '...'
|
||||
}
|
||||
|
||||
function getZapperPicture(pubkey: string): string {
|
||||
const profile = nostr.profiles.value.get(pubkey)
|
||||
return profile?.picture || `https://robohash.org/${pubkey}.png`
|
||||
}
|
||||
|
||||
function formatZapAmount(sats: number): string {
|
||||
if (sats >= 1_000_000) return (sats / 1_000_000).toFixed(1) + 'M'
|
||||
if (sats >= 1_000) return (sats / 1_000).toFixed(sats >= 10_000 ? 0 : 1) + 'k'
|
||||
return sats.toLocaleString()
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
const url = `${window.location.origin}/content/${props.content?.id}`
|
||||
if (navigator.share) {
|
||||
@@ -691,6 +749,23 @@ function openSubscriptionFromRental() {
|
||||
color: #F7931A;
|
||||
}
|
||||
|
||||
/* Zap avatar pill */
|
||||
.zap-avatar-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px 3px 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(247, 147, 26, 0.08);
|
||||
border: 1px solid rgba(247, 147, 26, 0.15);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.zap-avatar-pill:hover {
|
||||
background: rgba(247, 147, 26, 0.15);
|
||||
border-color: rgba(247, 147, 26, 0.3);
|
||||
}
|
||||
|
||||
/* Category Tags */
|
||||
.category-tag {
|
||||
display: inline-block;
|
||||
|
||||
@@ -125,29 +125,44 @@
|
||||
Open in wallet app
|
||||
</a>
|
||||
|
||||
<!-- Done Button -->
|
||||
<button
|
||||
@click="handleDone"
|
||||
class="w-full bg-white/5 hover:bg-white/10 text-white/70 rounded-xl px-4 py-3 text-sm font-medium transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<p class="text-xs text-white/40 animate-pulse">
|
||||
Waiting for payment confirmation...
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ─── SUCCESS STATE ─── -->
|
||||
<!-- ─── SUCCESS STATE: Lightning Celebration ─── -->
|
||||
<template v-if="paymentState === 'success'">
|
||||
<div class="text-center py-6">
|
||||
<div class="w-20 h-20 bg-[#F7931A]/20 rounded-full flex items-center justify-center mx-auto mb-5">
|
||||
<svg class="w-10 h-10 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
|
||||
</svg>
|
||||
<div class="text-center py-4 relative overflow-hidden">
|
||||
<!-- Animated lightning bolts background -->
|
||||
<div class="zap-celebration">
|
||||
<div v-for="i in 12" :key="i" class="zap-bolt" :style="getBoltStyle(i)">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glowing center icon -->
|
||||
<div class="relative z-10">
|
||||
<div class="zap-success-icon mx-auto mb-5">
|
||||
<svg class="w-12 h-12 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl font-bold text-white mb-2">ZAP!</h2>
|
||||
<p class="text-[#F7931A] text-lg font-semibold mb-1">
|
||||
{{ zapAmount?.toLocaleString() }} sats sent
|
||||
</p>
|
||||
<p class="text-white/40 text-sm mb-6">
|
||||
{{ successQuote }}
|
||||
</p>
|
||||
|
||||
<button @click="closeModal" class="zap-pay-button w-full flex items-center justify-center">
|
||||
Nice!
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Zap Sent!</h2>
|
||||
<p class="text-white/50 mb-6">You zapped {{ zapAmount?.toLocaleString() }} sats to the creator</p>
|
||||
<button @click="closeModal" class="zap-pay-button w-full flex items-center justify-center">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -158,7 +173,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import type { Content } from '../types/content'
|
||||
import { indeehubApiService } from '../services/indeehub-api.service'
|
||||
@@ -170,6 +185,7 @@ interface Props {
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'zapped', amount: number): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -179,6 +195,17 @@ type PaymentState = 'select' | 'invoice' | 'success'
|
||||
|
||||
const presetAmounts = [21, 100, 1000, 5000]
|
||||
|
||||
const successQuotes = [
|
||||
'The creator just felt a tingle in their wallet',
|
||||
'Proof of appreciation, verified on the Lightning Network',
|
||||
'Sats well spent. The creator thanks you!',
|
||||
'You just made someone\'s day brighter',
|
||||
'Value for value. This is the way.',
|
||||
'Stack sats, zap creators. Repeat.',
|
||||
'Money at the speed of light. Literally.',
|
||||
'Another brick in the Lightning wall!',
|
||||
]
|
||||
|
||||
const paymentState = ref<PaymentState>('select')
|
||||
const selectedAmount = ref<number>(100)
|
||||
const customAmount = ref<string>('')
|
||||
@@ -186,17 +213,22 @@ const customActive = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const creatorName = ref<string | null>(null)
|
||||
const successQuote = ref('')
|
||||
|
||||
// LNURL-pay data
|
||||
const lnurlCallback = ref<string | null>(null)
|
||||
const lnurlMinSats = ref(1)
|
||||
const lnurlMaxSats = ref(10_000_000)
|
||||
const verifyUrl = ref<string | null>(null)
|
||||
|
||||
// Invoice data
|
||||
const bolt11Invoice = ref('')
|
||||
const qrCodeDataUrl = ref('')
|
||||
const copyButtonText = ref('Copy Invoice')
|
||||
|
||||
// Polling
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** The effective amount to zap */
|
||||
const zapAmount = computed<number | null>(() => {
|
||||
if (customActive.value && customAmount.value) {
|
||||
@@ -216,6 +248,32 @@ function handleCustomInput() {
|
||||
customActive.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random style for each celebration lightning bolt
|
||||
*/
|
||||
function getBoltStyle(index: number) {
|
||||
const left = (index * 8.3) % 100
|
||||
const delay = (index * 0.15) % 2
|
||||
const size = 14 + (index % 4) * 6
|
||||
const duration = 1.5 + (index % 3) * 0.5
|
||||
return {
|
||||
left: `${left}%`,
|
||||
animationDelay: `${delay}s`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
animationDuration: `${duration}s`,
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(cleanup)
|
||||
|
||||
// Reset when modal opens/closes
|
||||
watch(() => props.isOpen, async (open) => {
|
||||
if (open) {
|
||||
@@ -229,9 +287,14 @@ watch(() => props.isOpen, async (open) => {
|
||||
copyButtonText.value = 'Copy Invoice'
|
||||
creatorName.value = props.content?.creator || null
|
||||
lnurlCallback.value = null
|
||||
verifyUrl.value = null
|
||||
successQuote.value = ''
|
||||
cleanup()
|
||||
|
||||
// Pre-resolve the creator's lightning address
|
||||
await resolveLightningAddress()
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -247,7 +310,6 @@ async function resolveLightningAddress() {
|
||||
if (!props.content) return
|
||||
|
||||
try {
|
||||
// Step 1: Get the project owner's filmmaker profile
|
||||
const projectId = props.content.id
|
||||
const ownerData = await indeehubApiService.get<{
|
||||
id: string
|
||||
@@ -260,7 +322,6 @@ async function resolveLightningAddress() {
|
||||
creatorName.value = ownerData.professionalName
|
||||
}
|
||||
|
||||
// Step 2: Fetch the filmmaker's lightning address
|
||||
const addressData = await indeehubApiService.get<{
|
||||
lightningAddress: string
|
||||
}>(`/filmmakers/${ownerData.id}/lightning-address`)
|
||||
@@ -268,7 +329,6 @@ async function resolveLightningAddress() {
|
||||
const lightningAddress = addressData?.lightningAddress
|
||||
if (!lightningAddress) return
|
||||
|
||||
// Parse lightning address: user@domain → https://domain/.well-known/lnurlp/user
|
||||
const [username, domain] = lightningAddress.split('@')
|
||||
if (!username || !domain) return
|
||||
|
||||
@@ -281,7 +341,6 @@ async function resolveLightningAddress() {
|
||||
if (lnurlData.status === 'ERROR') return
|
||||
|
||||
lnurlCallback.value = lnurlData.callback
|
||||
// LNURL amounts are in millisats
|
||||
lnurlMinSats.value = Math.ceil((lnurlData.minSendable || 1000) / 1000)
|
||||
lnurlMaxSats.value = Math.floor((lnurlData.maxSendable || 10_000_000_000) / 1000)
|
||||
} catch (err) {
|
||||
@@ -289,6 +348,37 @@ async function resolveLightningAddress() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the LNURL verify URL to detect payment.
|
||||
* The verify URL is returned alongside the invoice and responds
|
||||
* with { settled: true } once the invoice is paid.
|
||||
*/
|
||||
function startPolling() {
|
||||
if (!verifyUrl.value) return
|
||||
|
||||
const url = verifyUrl.value
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return
|
||||
|
||||
const data = await response.json()
|
||||
if (data.settled === true || data.preimage) {
|
||||
cleanup()
|
||||
showSuccess()
|
||||
}
|
||||
} catch {
|
||||
// Silently retry
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function showSuccess() {
|
||||
successQuote.value = successQuotes[Math.floor(Math.random() * successQuotes.length)]
|
||||
paymentState.value = 'success'
|
||||
emit('zapped', zapAmount.value || 0)
|
||||
}
|
||||
|
||||
async function handleZap() {
|
||||
if (!zapAmount.value || zapAmount.value < 1) return
|
||||
|
||||
@@ -301,7 +391,6 @@ async function handleZap() {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate amount against LNURL limits
|
||||
if (zapAmount.value < lnurlMinSats.value) {
|
||||
errorMessage.value = `Minimum zap is ${lnurlMinSats.value.toLocaleString()} sats`
|
||||
return
|
||||
@@ -330,8 +419,13 @@ async function handleZap() {
|
||||
}
|
||||
|
||||
bolt11Invoice.value = data.pr
|
||||
// Store verify URL for payment detection polling
|
||||
verifyUrl.value = data.verify || null
|
||||
await generateQRCode(data.pr)
|
||||
paymentState.value = 'invoice'
|
||||
|
||||
// Start polling for payment confirmation
|
||||
startPolling()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.message || 'Failed to create zap invoice. Please try again.'
|
||||
} finally {
|
||||
@@ -362,15 +456,13 @@ async function copyInvoice() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
paymentState.value = 'success'
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
cleanup()
|
||||
paymentState.value = 'select'
|
||||
errorMessage.value = null
|
||||
bolt11Invoice.value = ''
|
||||
qrCodeDataUrl.value = ''
|
||||
verifyUrl.value = null
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
@@ -493,6 +585,76 @@ function closeModal() {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ─── Success Celebration ─── */
|
||||
.zap-celebration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zap-bolt {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
color: rgba(247, 147, 26, 0.3);
|
||||
animation: zapFall linear infinite;
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
|
||||
.zap-bolt:nth-child(odd) {
|
||||
color: rgba(247, 147, 26, 0.15);
|
||||
}
|
||||
|
||||
.zap-bolt:nth-child(3n) {
|
||||
color: rgba(255, 200, 50, 0.25);
|
||||
}
|
||||
|
||||
@keyframes zapFall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(-15deg) scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(350px) rotate(15deg) scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.zap-success-icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(247, 147, 26, 0.25), rgba(247, 147, 26, 0.05));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: zapPulse 1.5s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 0 40px rgba(247, 147, 26, 0.3),
|
||||
0 0 80px rgba(247, 147, 26, 0.15);
|
||||
}
|
||||
|
||||
@keyframes zapPulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 40px rgba(247, 147, 26, 0.3),
|
||||
0 0 80px rgba(247, 147, 26, 0.15);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 60px rgba(247, 147, 26, 0.4),
|
||||
0 0 120px rgba(247, 147, 26, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal Transitions */
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
|
||||
@@ -129,13 +129,29 @@ function rebuildStats() {
|
||||
}
|
||||
|
||||
// Process zap receipts (kind 9735) from the EventStore.
|
||||
// Zap receipts reference content via the 'i' tag (external identifiers)
|
||||
// or via embedded zap request description.
|
||||
// Zap receipts reference content via:
|
||||
// 1. Direct 'i' tag on the receipt itself
|
||||
// 2. 'i' tag embedded in the zap request JSON (description tag)
|
||||
const zapReceipts = eventStore.getByFilters([{ kinds: [9735] }])
|
||||
if (zapReceipts) {
|
||||
for (const event of zapReceipts) {
|
||||
// Try to find the external content ID from the zap receipt tags
|
||||
const externalId = getTagValue(event, 'i')
|
||||
// Try direct 'i' tag first
|
||||
let externalId = getTagValue(event, 'i')
|
||||
|
||||
// Fallback: parse the embedded zap request from the description tag
|
||||
if (!externalId) {
|
||||
const descTag = event.tags.find((t) => t[0] === 'description')?.[1]
|
||||
if (descTag) {
|
||||
try {
|
||||
const zapRequest = JSON.parse(descTag)
|
||||
if (zapRequest.tags) {
|
||||
const iTag = zapRequest.tags.find((t: string[]) => t[0] === 'i')
|
||||
if (iTag) externalId = iTag[1]
|
||||
}
|
||||
} catch { /* not valid JSON */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (!externalId) continue
|
||||
|
||||
const stats = getOrCreate(externalId)
|
||||
|
||||
@@ -120,6 +120,8 @@ export function useNostr(contentId?: string) {
|
||||
const reactions = ref<NostrEvent[]>([])
|
||||
// Per-comment reactions: eventId -> NostrEvent[]
|
||||
const commentReactions = ref<Map<string, NostrEvent[]>>(new Map())
|
||||
// Zap receipts for the current content
|
||||
const zaps = ref<{ pubkey: string; amount: number; timestamp: number }[]>([])
|
||||
// User profiles
|
||||
const profiles = ref<Map<string, any>>(new Map())
|
||||
// Loading state
|
||||
@@ -170,9 +172,22 @@ export function useNostr(contentId?: string) {
|
||||
})
|
||||
subscriptions.push(reactionSub)
|
||||
|
||||
// Subscribe to zap receipts (kind 9735) for this content
|
||||
const zapSub = pool
|
||||
.subscription(currentRelays, [
|
||||
{ kinds: [9735], '#i': [externalId] },
|
||||
])
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe({
|
||||
next: () => refreshZaps(externalId),
|
||||
error: (err) => console.error('Zap subscription error:', err),
|
||||
})
|
||||
subscriptions.push(zapSub)
|
||||
|
||||
// Read existing data from store immediately
|
||||
refreshComments(externalId)
|
||||
refreshReactions(externalId)
|
||||
refreshZaps(externalId)
|
||||
|
||||
// Mark loading done after initial data window
|
||||
setTimeout(() => {
|
||||
@@ -213,6 +228,76 @@ export function useNostr(contentId?: string) {
|
||||
loadCommentReactions(allEvents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse sats from a BOLT11 invoice string.
|
||||
*/
|
||||
function parseBolt11Sats(bolt11: string): number {
|
||||
try {
|
||||
const match = bolt11.toLowerCase().match(/^lnbc(\d+)([munp]?)1/)
|
||||
if (!match) return 0
|
||||
const value = parseInt(match[1], 10)
|
||||
if (isNaN(value)) return 0
|
||||
switch (match[2]) {
|
||||
case 'm': return value * 100_000
|
||||
case 'u': return value * 100
|
||||
case 'n': return Math.round(value * 0.1)
|
||||
case 'p': return Math.round(value * 0.0001)
|
||||
case '': return value * 100_000_000
|
||||
default: return 0
|
||||
}
|
||||
} catch { return 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read zap receipts from the EventStore and extract sender info.
|
||||
*/
|
||||
function refreshZaps(externalId: string) {
|
||||
const events = eventStore.getByFilters([
|
||||
{ kinds: [9735], '#i': [externalId] },
|
||||
])
|
||||
if (!events) return
|
||||
|
||||
const zapList: { pubkey: string; amount: number; timestamp: number }[] = []
|
||||
const zapperPubkeys: string[] = []
|
||||
|
||||
for (const event of events) {
|
||||
// The zap receipt's pubkey is the LNURL provider, not the sender.
|
||||
// The sender's pubkey is in the embedded zap request (description tag).
|
||||
let senderPubkey = event.pubkey
|
||||
let amount = 0
|
||||
|
||||
// Try to extract sender from the description tag (NIP-57)
|
||||
const descTag = event.tags.find((t) => t[0] === 'description')?.[1]
|
||||
if (descTag) {
|
||||
try {
|
||||
const zapRequest = JSON.parse(descTag)
|
||||
if (zapRequest.pubkey) senderPubkey = zapRequest.pubkey
|
||||
} catch { /* not valid JSON */ }
|
||||
}
|
||||
|
||||
// Extract amount from bolt11 tag
|
||||
const bolt11 = event.tags.find((t) => t[0] === 'bolt11')?.[1]
|
||||
if (bolt11) {
|
||||
amount = parseBolt11Sats(bolt11)
|
||||
}
|
||||
|
||||
zapList.push({
|
||||
pubkey: senderPubkey,
|
||||
amount,
|
||||
timestamp: event.created_at,
|
||||
})
|
||||
zapperPubkeys.push(senderPubkey)
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
zapList.sort((a, b) => b.timestamp - a.timestamp)
|
||||
zaps.value = zapList
|
||||
|
||||
// Fetch profiles for zap senders
|
||||
const uniquePubkeys = [...new Set(zapperPubkeys)]
|
||||
fetchProfiles(uniquePubkeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read movie-level reactions from the EventStore.
|
||||
*/
|
||||
@@ -557,6 +642,7 @@ export function useNostr(contentId?: string) {
|
||||
allComments,
|
||||
reactions,
|
||||
commentReactions,
|
||||
zaps,
|
||||
profiles,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
Reference in New Issue
Block a user