diff --git a/src/components/ContentDetailModal.vue b/src/components/ContentDetailModal.vue index a61af1b..ce6d914 100644 --- a/src/components/ContentDetailModal.vue +++ b/src/components/ContentDetailModal.vue @@ -138,11 +138,40 @@ -
+
Directed by {{ content.creator }}
+ +
+
+ + + + Zapped by + ({{ totalZapSats.toLocaleString() }} sats) +
+
+
+ + {{ formatZapAmount(zap.amount) }} +
+ + +{{ zapsList.length - 8 }} more + +
+
+
@@ -245,6 +274,7 @@ :isOpen="showZapModal" :content="content" @close="showZapModal = false" + @zapped="handleZapped" />
@@ -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; diff --git a/src/components/ZapModal.vue b/src/components/ZapModal.vue index 425556c..fbea8ea 100644 --- a/src/components/ZapModal.vue +++ b/src/components/ZapModal.vue @@ -125,29 +125,44 @@ Open in wallet app - - +

+ Waiting for payment confirmation... +

- + @@ -158,7 +173,7 @@ @@ -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 { diff --git a/src/composables/useContentDiscovery.ts b/src/composables/useContentDiscovery.ts index b2cb234..66919e0 100644 --- a/src/composables/useContentDiscovery.ts +++ b/src/composables/useContentDiscovery.ts @@ -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) diff --git a/src/composables/useNostr.ts b/src/composables/useNostr.ts index 7668311..3d66af3 100644 --- a/src/composables/useNostr.ts +++ b/src/composables/useNostr.ts @@ -120,6 +120,8 @@ export function useNostr(contentId?: string) { const reactions = ref([]) // Per-comment reactions: eventId -> NostrEvent[] const commentReactions = ref>(new Map()) + // Zap receipts for the current content + const zaps = ref<{ pubkey: string; amount: number; timestamp: number }[]>([]) // User profiles const profiles = ref>(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,