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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user