Files
indee-demo/src/composables/useContentDiscovery.ts
Dorian 66db9376ed feat: enhance zap functionality with stats tracking and pubkey support
- Added a new endpoint in ZapsController to retrieve zap statistics by project IDs, including total counts, amounts, and recent zapper pubkeys.
- Updated ZapsService to record zap statistics, including optional zapper pubkey for tracking who zapped.
- Enhanced CreateZapInvoiceDto to include an optional zapperPubkey field.
- Modified frontend components to display zap stats and integrate with the new backend functionality, improving user engagement and transparency.

These changes improve the overall zap experience by providing detailed insights into zap activities and enhancing the tracking of contributors.
2026-02-14 15:35:59 +00:00

424 lines
13 KiB
TypeScript

import { ref, computed } from 'vue'
import { COMMENT_KIND } from 'applesauce-common/helpers'
import { mapEventsToStore } from 'applesauce-core'
import { onlyEvents } from 'applesauce-relay'
import type { NostrEvent } from 'applesauce-core/helpers/event'
import type { Subscription } from 'rxjs'
import { eventStore, pool, APP_RELAYS } from '../lib/nostr'
import { TASTEMAKER_PUBKEYS } from '../data/testPersonas'
import { getExternalContentId } from './useNostr'
import type { Content } from '../types/content'
// --- Algorithm Definitions ---
export const ALGORITHMS = [
{ id: 'popularity', label: 'Popularity' },
{ id: 'trending', label: 'Trending' },
{ id: 'most-zapped', label: 'Most Zapped' },
{ id: 'most-reviews', label: 'Most Reviews' },
{ id: 'tastemaker', label: 'Tastemaker' },
] as const
export type AlgorithmId = (typeof ALGORITHMS)[number]['id'] | null
export interface ContentStats {
plusCount: number
minusCount: number
commentCount: number
reviewCount: number
zapCount: number
zapAmountSats: number
/** Pubkeys of recent zappers (for avatar stack on cards); max 5 */
recentZapperPubkeys: string[]
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)
const contentStatsMap = ref<Map<string, ContentStats>>(new Map())
const isDiscoveryLoading = ref(false)
let subscriptionRefs: Subscription[] = []
let initialized = false
function getTagValue(event: NostrEvent, tagName: string): string | undefined {
return event.tags.find((t) => t[0] === tagName)?.[1]
}
/** If externalId is a URL like .../content/ID, return ID so we can index by both. */
function bareContentIdFromExternal(externalId: string): string | null {
const match = externalId.match(/\/content\/([^/]+)$/)
return match ? match[1] : null
}
/**
* Rebuild the stats map from current EventStore data
*/
function rebuildStats() {
const map = new Map<string, ContentStats>()
const getOrCreate = (id: string): ContentStats => {
let stats = map.get(id)
if (!stats) {
stats = {
plusCount: 0,
minusCount: 0,
commentCount: 0,
reviewCount: 0,
zapCount: 0,
zapAmountSats: 0,
recentZapperPubkeys: [],
recentEvents: [],
}
map.set(id, stats)
// Index by bare content id too (e.g. project id) so getStats(content.id) finds it
const bare = bareContentIdFromExternal(id)
if (bare && bare !== id) map.set(bare, stats)
}
return stats
}
// Process reactions from the EventStore
const reactions = eventStore.getByFilters([{ kinds: [17], '#k': ['web'] }])
if (reactions) {
for (const event of reactions) {
const externalId = getTagValue(event, 'i')
if (!externalId) continue
const stats = getOrCreate(externalId)
if (event.content === '+') stats.plusCount++
else if (event.content === '-') stats.minusCount++
stats.recentEvents.push(event)
}
}
// Process comments from the EventStore
const comments = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#K': ['web'] },
])
if (comments) {
for (const event of comments) {
const externalId = getTagValue(event, 'I')
if (!externalId) continue
const stats = getOrCreate(externalId)
stats.commentCount++
// Top-level comments also have #i (direct ref to external content)
if (getTagValue(event, 'i')) stats.reviewCount++
stats.recentEvents.push(event)
}
}
// Process zap receipts (kind 9735) from the EventStore.
// 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 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)
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
}
// Sender pubkey for avatar stack (NIP-57: in description zap request)
let senderPubkey = event.pubkey
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 { /* ignore */ }
}
if (
stats.recentZapperPubkeys.length < 5 &&
!stats.recentZapperPubkeys.includes(senderPubkey)
) {
stats.recentZapperPubkeys.push(senderPubkey)
}
stats.recentEvents.push(event)
}
}
contentStatsMap.value = map
}
/**
* Start relay subscriptions for global discovery data.
* Only runs once; subsequent calls are no-ops.
*/
function initSubscriptions() {
if (initialized) return
initialized = true
isDiscoveryLoading.value = true
const currentRelays = APP_RELAYS
// Subscribe to all web reactions (kind 17)
const reactionSub = pool
.subscription(currentRelays, [{ kinds: [17], '#k': ['web'] }])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery reaction subscription error:', err),
})
subscriptionRefs.push(reactionSub)
// Subscribe to all web comments (kind 1111)
const commentSub = pool
.subscription(currentRelays, [
{ kinds: [COMMENT_KIND], '#K': ['web'] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery comment subscription error:', err),
})
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()
// Mark loading done after initial data window
setTimeout(() => {
isDiscoveryLoading.value = false
}, 2000)
}
// --- Public Composable ---
const EMPTY_STATS: ContentStats = {
plusCount: 0,
minusCount: 0,
commentCount: 0,
reviewCount: 0,
zapCount: 0,
zapAmountSats: 0,
recentZapperPubkeys: [],
recentEvents: [],
}
/**
* Composable providing discovery algorithm state and content sorting.
*
* Shared state: `activeAlgorithm` and `contentStatsMap` are singletons
* so that the header, mobile nav, and browse view all see the same state.
*/
export function useContentDiscovery() {
// Ensure relay subscriptions are running
initSubscriptions()
/**
* Sort an array of Content items by the active algorithm.
* Returns content as-is when no filter is active (null).
*/
const sortContent = computed(() => {
return (contents: Content[], overrideAlgo?: ActiveAlgorithmId): Content[] => {
if (!contents || contents.length === 0) return contents
const effectiveAlgo: ActiveAlgorithmId | null = overrideAlgo ?? activeAlgorithm.value
if (!effectiveAlgo) return contents
const statsMap = contentStatsMap.value
// Build entries array: [Content, stats] for each content item (same dual-key as getStats)
const withStats: [Content, ContentStats][] = contents.map((c) => {
const externalId = getExternalContentId(c.id)
const stats = statsMap.get(externalId) || statsMap.get(c.id) || EMPTY_STATS
return [c, stats]
})
// Sort entries
const sortedEntries = sortContentEntries(withStats, effectiveAlgo)
return sortedEntries.map(([content]) => content)
}
})
/**
* The label for the currently active algorithm, or null if none.
*/
const activeAlgorithmLabel = computed(() => {
if (!activeAlgorithm.value) return null
return ALGORITHMS.find((a) => a.id === activeAlgorithm.value)?.label ?? null
})
/**
* Whether a filter is currently active
*/
const isFilterActive = computed(() => activeAlgorithm.value !== null)
/**
* Set algorithm. Clicking the same algorithm again toggles it off.
*/
function setAlgorithm(algo: AlgorithmId) {
if (activeAlgorithm.value === algo) {
activeAlgorithm.value = null
} else {
activeAlgorithm.value = algo
}
}
/**
* Get stats for a specific content item (Nostr discovery).
* Lookup by full external URL and by bare content id so relay data matches either format.
*/
function getStats(contentId: string): ContentStats {
const externalId = getExternalContentId(contentId)
return (
contentStatsMap.value.get(externalId) ||
contentStatsMap.value.get(contentId) ||
EMPTY_STATS
)
}
return {
// State
activeAlgorithm,
activeAlgorithmLabel,
isFilterActive,
algorithms: ALGORITHMS,
contentStatsMap,
isDiscoveryLoading,
// Methods
sortContent,
setAlgorithm,
getStats,
}
}
// --- Internal sorting helper for Content tuples ---
type ActiveAlgorithmId = Exclude<AlgorithmId, null>
function sortContentEntries(
entries: [Content, ContentStats][],
algo: ActiveAlgorithmId,
): [Content, ContentStats][] {
const now = Math.floor(Date.now() / 1000)
switch (algo) {
case 'popularity':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
(b[1].plusCount - b[1].minusCount + b[1].commentCount) -
(a[1].plusCount - a[1].minusCount + a[1].commentCount),
)
case 'trending': {
const trendScore = (stats: ContentStats) => {
let score = 0
for (const e of stats.recentEvents) {
score += e.created_at > now - SEVEN_DAYS ? 2 : 1
}
return score
}
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
trendScore(b[1]) - trendScore(a[1]),
)
}
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]) =>
zapScore(b[1]) - zapScore(a[1]),
)
}
case 'most-reviews':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
b[1].reviewCount - a[1].reviewCount,
)
case 'tastemaker': {
if (TASTEMAKER_PUBKEYS.length === 0) {
return sortContentEntries(entries, 'popularity')
}
const filtered = entries.filter(([, stats]) =>
stats.recentEvents.some((e) => TASTEMAKER_PUBKEYS.includes(e.pubkey)),
)
return sortContentEntries(
filtered.length > 0 ? filtered : entries,
'popularity',
)
}
}
}