diff --git a/js/zapTotals.js b/js/zapTotals.js index 850979ba..fd832e24 100644 --- a/js/zapTotals.js +++ b/js/zapTotals.js @@ -15,6 +15,7 @@ import { devLogger } from "./utils/logger.js"; export const ZAP_RECEIPT_KIND = 9735; +export const SENT_ZAPS_STORAGE_KEY = "bitvid:sentZaps:v1"; const FETCH_TTL_MS = 120000; const BATCH_DELAY_MS = 150; const MAX_POINTERS_PER_FILTER = 100; @@ -88,17 +89,53 @@ export function createZapTotalsStore({ getClient = () => null, getTools = () => null, schedule = (fn, ms) => setTimeout(fn, ms), + // Durable ledger of the user's OWN sent zaps (localStorage key), so their + // zapped videos keep the count + Most-Zapped rank across reloads even when + // the recipient's LNURL server never publishes a 9735 receipt (custodial + // wallets like Strike routinely don't). Pass null to disable (tests). + persistKey = SENT_ZAPS_STORAGE_KEY, } = {}) { // key → { sats, optimisticSats, receiptIds:Set, fetchedAt } // sats — summed from real relay receipts (deduped by event id) - // optimisticSats — this session's own just-sent zaps, shown instantly - // (like ingestLocalViewEvent), cleared once a real - // receipt for the pointer arrives so it's never double-counted + // optimisticSats — the user's OWN sent zaps (durable ledger + session + // bumps), shown instantly like ingestLocalViewEvent. + // Cleared once a real receipt for the pointer arrives, so + // if the recipient DOES publish, the relay becomes + // authoritative and the value is never double-counted. const totals = new Map(); const pending = new Map(); // key → pointer const listeners = new Set(); let batchTimer = null; + // --- durable sent-zap ledger (localStorage: { pointerKey: sats }) --- + const readLedger = () => { + if (!persistKey || typeof localStorage === "undefined") { + return {}; + } + try { + const raw = localStorage.getItem(persistKey); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } + }; + const writeLedger = (ledger) => { + if (!persistKey || typeof localStorage === "undefined") { + return; + } + try { + const keys = Object.keys(ledger || {}); + if (!keys.length) { + localStorage.removeItem(persistKey); + } else { + localStorage.setItem(persistKey, JSON.stringify(ledger)); + } + } catch (error) { + /* best-effort */ + } + }; + const entryFor = (key) => { let entry = totals.get(key); if (!entry) { @@ -108,6 +145,14 @@ export function createZapTotalsStore({ return entry; }; + // Seed the optimistic layer from the durable ledger at construction. + for (const [key, sats] of Object.entries(readLedger())) { + const amount = Number(sats); + if (typeof key === "string" && key && Number.isFinite(amount) && amount > 0) { + entryFor(key).optimisticSats = Math.round(amount); + } + } + const totalOf = (entry) => (entry?.sats || 0) + (entry?.optimisticSats || 0); @@ -208,12 +253,24 @@ export function createZapTotalsStore({ } } // A pointer that now has real receipts is authoritative — drop its - // optimistic bump so this session's just-sent zap isn't counted twice. - for (const key of keysWithRealReceipt) { - const entry = totals.get(key); - if (entry && entry.optimisticSats > 0) { - entry.optimisticSats = 0; - changed = true; + // optimistic bump AND its durable ledger entry, so once the relay actually + // reflects zaps for this video we trust it and never double-count. + if (keysWithRealReceipt.size) { + const ledger = readLedger(); + let ledgerChanged = false; + for (const key of keysWithRealReceipt) { + const entry = totals.get(key); + if (entry && entry.optimisticSats > 0) { + entry.optimisticSats = 0; + changed = true; + } + if (key in ledger) { + delete ledger[key]; + ledgerChanged = true; + } + } + if (ledgerChanged) { + writeLedger(ledger); } } if (changed || batch.size) { @@ -261,6 +318,11 @@ export function createZapTotalsStore({ const entry = entryFor(key); entry.optimisticSats += amount; entry.fetchedAt = now(); + // Persist to the durable ledger so the badge + Most-Zapped rank survive a + // reload even when no 9735 receipt is ever published for this zap. + const ledger = readLedger(); + ledger[key] = (Number(ledger[key]) || 0) + amount; + writeLedger(ledger); emitChange(); }, onChange(listener) { diff --git a/tests/zap-totals.test.mjs b/tests/zap-totals.test.mjs index 051731d7..e233697e 100644 --- a/tests/zap-totals.test.mjs +++ b/tests/zap-totals.test.mjs @@ -84,6 +84,7 @@ function makeStore({ receipts = [], now = () => 1000 } = {}) { const scheduled = []; const store = createZapTotalsStore({ now, + persistKey: null, // in-memory; the durable ledger has its own test getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), getClient: () => ({ relays: ["wss://relay.example"], @@ -160,6 +161,7 @@ test("optimistic ingest bumps instantly, then a real receipt replaces it (no dou let scripted = []; const s = createZapTotalsStore({ now: () => t, + persistKey: null, getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), getClient: () => ({ relays: ["wss://relay.example"], @@ -189,6 +191,44 @@ test("optimistic ingest bumps instantly, then a real receipt replaces it (no dou ); }); +test("durable ledger: sent zaps persist across reload; a real receipt prunes them", async () => { + const KEY = "bitvid:sentZaps:test"; + localStorage.removeItem(KEY); + const pointer = { type: "a", value: A1 }; + + // Session 1: send a zap. No receipt is ever published (Strike-style). + const s1 = createZapTotalsStore({ persistKey: KEY }); + s1.ingestLocalZap(pointer, 2100); + assert.equal(s1.getSnapshot(pointer), 2100); + + // Session 2 (reload): a fresh store seeds from the durable ledger — the zap + // still shows, so the badge + Most-Zapped rank survive with no relay receipt. + const s2 = createZapTotalsStore({ persistKey: KEY }); + assert.equal(s2.getSnapshot(pointer), 2100, "sent zap persisted across reload"); + + // Session 3: now a real receipt for this video DOES appear on relays → the + // relay becomes authoritative and the ledger entry is pruned (no double-count). + let scripted = [receipt("r-1", [["a", A1], amountTag(2100000)])]; + const s3 = createZapTotalsStore({ + persistKey: KEY, + now: () => 10 * 60 * 1000, // past the fetch TTL so the seeded entry is stale + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ list: async () => scripted }), + }), + schedule: (fn) => { fn(); return 1; }, + }); + s3.request(pointer); + await s3.flush(); + assert.equal(s3.getSnapshot(pointer), 2100, "relay receipt now the source (not 4200)"); + + // Session 4 (reload after prune): ledger cleared, so only relay data remains. + const s4 = createZapTotalsStore({ persistKey: KEY }); + assert.equal(s4.getSnapshot(pointer), 0, "ledger pruned once the relay had a receipt"); + localStorage.removeItem(KEY); +}); + test("most-zapped sorter: sats desc, recency tie-break, muted sinks", () => { const item = (id, author, sats, createdAt, muted = false) => ({ video: { id, pubkey: author, created_at: createdAt },