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 }} + + + + + + + + + + + + + + + + + + + + + + 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(