feat: add zap button with lightning invoice modal and fix keys detection

- Add Zap button to film detail modal with LNURL-pay invoice generation
- Create ZapModal with 4 preset amounts, custom input, and QR code display
- Fix hasPrivateKey detection for production builds (use instanceof)
- Fix KeysModal header centering (pr-8 → px-8)
- Update most-zapped algorithm to track real Nostr zap receipts (kind 9735)
- Add BOLT11 amount decoder and zap receipt relay subscription

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-14 12:27:34 +00:00
parent 2a16802404
commit 8f1a28e825
5 changed files with 640 additions and 7 deletions

View File

@@ -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: lnbc<amount><multiplier>1...
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<AlgorithmId>(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(