diff --git a/src/components/ContentDetailModal.vue b/src/components/ContentDetailModal.vue index 7c2a8a2..a61af1b 100644 --- a/src/components/ContentDetailModal.vue +++ b/src/components/ContentDetailModal.vue @@ -100,6 +100,14 @@ {{ reactionCounts.negative }} + + + + + + Zap + + @@ -232,6 +240,12 @@ @success="handleRentalSuccess" @openSubscription="openSubscriptionFromRental" /> + + @@ -246,6 +260,7 @@ import type { Content } from '../types/content' import VideoPlayer from './VideoPlayer.vue' import SubscriptionModal from './SubscriptionModal.vue' import RentalModal from './RentalModal.vue' +import ZapModal from './ZapModal.vue' import CommentNode from './CommentNode.vue' interface Props { @@ -271,6 +286,7 @@ const isInMyList = ref(false) const showVideoPlayer = ref(false) const showSubscriptionModal = ref(false) const showRentalModal = ref(false) +const showZapModal = ref(false) const relayConnected = ref(true) // ── Rental access state ────────────────────────────────────────────── @@ -444,6 +460,14 @@ async function handleDislike() { } } +function handleZap() { + if (!isNostrLoggedIn.value && !isAuthenticated.value) { + emit('openAuth') + return + } + showZapModal.value = true +} + function handleShare() { const url = `${window.location.origin}/content/${props.content?.id}` if (navigator.share) { @@ -656,6 +680,17 @@ function openSubscriptionFromRental() { color: white; } +.action-btn-zap { + color: #F7931A; + border-color: rgba(247, 147, 26, 0.25); +} + +.action-btn-zap:hover { + background: rgba(247, 147, 26, 0.15); + border-color: rgba(247, 147, 26, 0.4); + color: #F7931A; +} + /* Category Tags */ .category-tag { display: inline-block; diff --git a/src/components/KeysModal.vue b/src/components/KeysModal.vue index 3984d25..d371a8a 100644 --- a/src/components/KeysModal.vue +++ b/src/components/KeysModal.vue @@ -15,7 +15,7 @@ - + diff --git a/src/components/ZapModal.vue b/src/components/ZapModal.vue new file mode 100644 index 0000000..1b150f5 --- /dev/null +++ b/src/components/ZapModal.vue @@ -0,0 +1,508 @@ + + + + + + + + + + + + + + + + + + + + + Zap {{ content?.title }} + Send sats to the creator via Lightning + + + + + To: + {{ creatorName }} + + + + + + + + + {{ amount.toLocaleString() }} + sats + + + + + + Custom Amount + + + sats + + + + + + {{ errorMessage }} + + + + + + + + Zap {{ zapAmount ? zapAmount.toLocaleString() : '0' }} sats + Creating invoice... + + + + + + + Pay with Lightning + Scan the QR code or copy the invoice + + + + + + Generating QR... + + + + + + + + + + {{ zapAmount?.toLocaleString() }} sats + + + + + + + + + {{ copyButtonText }} + + + + + Open in wallet app + + + + + Done + + + + + + + + + + + + + Zap Sent! + You zapped {{ zapAmount?.toLocaleString() }} sats to the creator + + Done + + + + + + + + + + + + + diff --git a/src/composables/useAccounts.ts b/src/composables/useAccounts.ts index 542aacd..afb1545 100644 --- a/src/composables/useAccounts.ts +++ b/src/composables/useAccounts.ts @@ -277,14 +277,23 @@ export function useAccounts() { * Whether the active account holds a local private key * (generated sovereign identity or imported nsec). * When true, the user can view/export their keys. + * + * Uses `instanceof` for reliable detection in minified production + * builds (constructor.name is mangled by bundlers). Falls back to + * checking for the presence of a secret key on the signer. */ const hasPrivateKey = computed(() => { const acct = activeAccount.value if (!acct) return false - // PrivateKeyAccount has type 'local' and stores the secret key - // in its signer. Check the account type name set by applesauce. - const typeName = acct.constructor?.name ?? acct.type ?? '' - return typeName === 'PrivateKeyAccount' || typeName === 'local' + + // Reliable check: instanceof survives minification + if (acct instanceof Accounts.PrivateKeyAccount) return true + + // Fallback: check if the account's signer holds a secret key + const signer = (acct as any).signer ?? (acct as any)._signer + if (signer?.key || signer?.secretKey || signer?._key) return true + + return false }) /** diff --git a/src/composables/useContentDiscovery.ts b/src/composables/useContentDiscovery.ts index 06ff551..b2cb234 100644 --- a/src/composables/useContentDiscovery.ts +++ b/src/composables/useContentDiscovery.ts @@ -26,11 +26,44 @@ export interface ContentStats { minusCount: number commentCount: number reviewCount: number + zapCount: number + zapAmountSats: number recentEvents: NostrEvent[] } const SEVEN_DAYS = 7 * 24 * 60 * 60 +/** + * Decode the amount in satoshis from a BOLT11 invoice string. + * BOLT11 encodes the amount after 'lnbc' with a multiplier suffix: + * m = milli (0.001), u = micro (0.000001), n = nano, p = pico + * Returns 0 if the amount cannot be parsed. + */ +function decodeBolt11Amount(bolt11: string): number { + try { + const lower = bolt11.toLowerCase() + // Match: lnbc1... + const match = lower.match(/^lnbc(\d+)([munp]?)1/) + if (!match) return 0 + + const value = parseInt(match[1], 10) + if (isNaN(value)) return 0 + + const multiplier = match[2] + // Convert BTC amount to sats (1 BTC = 100,000,000 sats) + switch (multiplier) { + case 'm': return value * 100_000 // milli-BTC + case 'u': return value * 100 // micro-BTC + case 'n': return Math.round(value * 0.1) // nano-BTC + case 'p': return Math.round(value * 0.0001) // pico-BTC + case '': return value * 100_000_000 // whole BTC + default: return 0 + } + } catch { + return 0 + } +} + // --- Shared Module State (singleton) --- const activeAlgorithm = ref(null) @@ -57,6 +90,8 @@ function rebuildStats() { minusCount: 0, commentCount: 0, reviewCount: 0, + zapCount: 0, + zapAmountSats: 0, recentEvents: [], } map.set(id, stats) @@ -93,6 +128,30 @@ 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. + 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') + if (!externalId) continue + + const stats = getOrCreate(externalId) + stats.zapCount++ + + // Extract amount from the bolt11 tag if present + const bolt11 = getTagValue(event, 'bolt11') + if (bolt11) { + const sats = decodeBolt11Amount(bolt11) + if (sats > 0) stats.zapAmountSats += sats + } + + stats.recentEvents.push(event) + } + } + contentStatsMap.value = map } @@ -129,6 +188,17 @@ function initSubscriptions() { }) subscriptionRefs.push(commentSub) + // Subscribe to zap receipts (kind 9735) that reference external web content. + // These are published by LNURL providers when a zap is paid. + const zapSub = pool + .subscription(currentRelays, [{ kinds: [9735] }]) + .pipe(onlyEvents(), mapEventsToStore(eventStore)) + .subscribe({ + next: () => rebuildStats(), + error: (err) => console.error('Discovery zap subscription error:', err), + }) + subscriptionRefs.push(zapSub) + // Initial build from any already-cached events rebuildStats() @@ -145,6 +215,8 @@ const EMPTY_STATS: ContentStats = { minusCount: 0, commentCount: 0, reviewCount: 0, + zapCount: 0, + zapAmountSats: 0, recentEvents: [], } @@ -263,11 +335,20 @@ function sortContentEntries( ) } - case 'most-zapped': + case 'most-zapped': { + // Sort by total sats zapped first, then by zap count, + // falling back to positive reactions if no zap data exists. + const zapScore = (stats: ContentStats) => + stats.zapAmountSats > 0 + ? stats.zapAmountSats + : stats.zapCount > 0 + ? stats.zapCount * 100 // Weight each zap receipt as 100 sats + : stats.plusCount // Fallback to likes if no zaps yet return [...entries].sort( (a: [Content, ContentStats], b: [Content, ContentStats]) => - b[1].plusCount - a[1].plusCount, + zapScore(b[1]) - zapScore(a[1]), ) + } case 'most-reviews': return [...entries].sort(
Send sats to the creator via Lightning
Scan the QR code or copy the invoice
You zapped {{ zapAmount?.toLocaleString() }} sats to the creator