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: 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) const contentStatsMap = ref>(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() 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 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', ) } } }