From af09505fb34cd0d6ddd55460419447d912c35862 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:55:14 -0400 Subject: [PATCH] zaps: optimistic zap-total bump so the badge updates instantly on send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors ingestLocalViewEvent for view counts: sending a zap through the video modal now bumps that video's zap total immediately on the card + modal badge, instead of waiting for the relay receipt (delayed by the 2-min cache TTL, and dependent on the recipient's wallet actually publishing a 9735). - zapTotals store tracks optimisticSats separately from real receipt sats; getSnapshot/request return the sum. When the real receipt for the pointer later arrives, runBatch clears the optimistic portion so it's never double-counted. If the receipt never lands (custodial wallets often don't publish), the bump persists in-memory until reload — keeping the zapper's own view honest. - ZapController fires a new onZapSuccess({video, sats}) at the fully-success terminal point (sats = context.shares.total; both creator + platform shares tag the video, so the eventual relay total matches). - ModalManager derives the pointer via app.deriveVideoPointerInfo (the SAME key the cards/modal subscribe with) and calls ingestLocalVideoZap. Live-verified: a rendered card's badge went 0 sats -> 5K sats instantly on ingest, no relay round-trip. Test covers the bump + replace-on-real-receipt. Co-Authored-By: Claude Fable 5 --- js/ui/ModalManager.js | 16 ++++++++++++ js/ui/zapController.js | 18 ++++++++++++++ js/zapTotals.js | 51 ++++++++++++++++++++++++++++++++++++--- tests/zap-totals.test.mjs | 34 ++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js index 7ac2cc50..9104e420 100644 --- a/js/ui/ModalManager.js +++ b/js/ui/ModalManager.js @@ -13,6 +13,7 @@ import initEditModal from "./initEditModal.js"; import initDeleteModal from "./initDeleteModal.js"; import { EventDetailsModal } from "./components/EventDetailsModal.js"; import ZapController from "./zapController.js"; +import { ingestLocalVideoZap } from "../zapTotals.js"; import { nostrClient } from "../nostrClientFacade.js"; export default class ModalManager { @@ -271,6 +272,21 @@ export default class ModalManager { callbacks: { onSuccess: (message) => app.showSuccess(message), onError: (message) => app.showError(message), + // Optimistically bump the zapped video's total using the SAME pointer + // the cards/modal badge subscribe with, so the badge updates instantly. + onZapSuccess: ({ video, sats } = {}) => { + try { + const info = + video && typeof app.deriveVideoPointerInfo === "function" + ? app.deriveVideoPointerInfo(video) + : null; + if (info?.pointer && sats > 0) { + ingestLocalVideoZap(info.pointer, sats); + } + } catch (error) { + /* best-effort optimistic update */ + } + }, }, requestWalletPane: () => app.openWalletPane(), }); diff --git a/js/ui/zapController.js b/js/ui/zapController.js index 04754682..1d266692 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -85,6 +85,10 @@ export default class ZapController { this.callbacks = { onSuccess: typeof callbacks.onSuccess === "function" ? callbacks.onSuccess : null, onError: typeof callbacks.onError === "function" ? callbacks.onError : null, + // Fires with { video, sats } after a fully-successful send so the app can + // optimistically bump the video's zap total (badge updates instantly). + onZapSuccess: + typeof callbacks.onZapSuccess === "function" ? callbacks.onZapSuccess : null, }; this.requestWalletPane = typeof requestWalletPane === "function" ? requestWalletPane : null; @@ -377,6 +381,20 @@ export default class ZapController { this.videoModal?.setZapStatus(summary, "success"); this.notifySuccess("Zap sent successfully!"); + // Optimistically bump the video's zap total by the amount sent (both the + // creator and platform shares are tagged to the video, so the eventual + // relay total equals context.shares.total). Badge updates instantly. + if (this.callbacks.onZapSuccess) { + try { + this.callbacks.onZapSuccess({ + video: this.getCurrentVideo(), + sats: Math.max(0, Math.round(Number(context?.shares?.total) || 0)), + }); + } catch (error) { + userLogger.warn("[zap] onZapSuccess hook failed:", error); + } + } + this.resetRetryState(); this.modalZapCommentValue = ""; // Reset the whole form (amount + comment) after a successful send, matching diff --git a/js/zapTotals.js b/js/zapTotals.js index 910b4697..850979ba 100644 --- a/js/zapTotals.js +++ b/js/zapTotals.js @@ -89,7 +89,12 @@ export function createZapTotalsStore({ getTools = () => null, schedule = (fn, ms) => setTimeout(fn, ms), } = {}) { - const totals = new Map(); // key → { sats, receiptIds:Set, fetchedAt } + // 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 + const totals = new Map(); const pending = new Map(); // key → pointer const listeners = new Set(); let batchTimer = null; @@ -97,12 +102,15 @@ export function createZapTotalsStore({ const entryFor = (key) => { let entry = totals.get(key); if (!entry) { - entry = { sats: 0, receiptIds: new Set(), fetchedAt: 0 }; + entry = { sats: 0, optimisticSats: 0, receiptIds: new Set(), fetchedAt: 0 }; totals.set(key, entry); } return entry; }; + const totalOf = (entry) => + (entry?.sats || 0) + (entry?.optimisticSats || 0); + const emitChange = () => { for (const listener of listeners) { try { @@ -173,6 +181,7 @@ export function createZapTotalsStore({ } let changed = false; + const keysWithRealReceipt = new Set(); for (const event of Array.isArray(events) ? events : []) { if (!event || event.kind !== ZAP_RECEIPT_KIND || !event.id) { continue; @@ -187,6 +196,7 @@ export function createZapTotalsStore({ const sats = extractReceiptAmountSats(event, getTools()); for (const key of keys) { const entry = entryFor(key); + keysWithRealReceipt.add(key); if (entry.receiptIds.has(event.id)) { continue; } @@ -197,6 +207,15 @@ export function createZapTotalsStore({ changed = true; } } + // 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; + } + } if (changed || batch.size) { emitChange(); } @@ -206,7 +225,7 @@ export function createZapTotalsStore({ // Cached total only — never triggers a fetch. getSnapshot(pointer) { const key = pointerKey(pointer); - return key ? totals.get(key)?.sats || 0 : 0; + return key ? totalOf(totals.get(key)) : 0; }, // Cached total now, plus a scheduled batched fetch when unknown/stale. request(pointer) { @@ -224,7 +243,25 @@ export function createZapTotalsStore({ }, batchDelayMs); } } - return entry?.sats || 0; + return totalOf(entry); + }, + // Optimistic bump for a zap THIS client just sent (mirrors + // ingestLocalViewEvent): the sats show instantly on the card + modal, and + // fetchedAt is refreshed so we don't immediately re-fetch and double-count + // before the real receipt propagates. When the real receipt later lands, + // runBatch clears the optimistic portion. Persists in-memory until the real + // receipt arrives or the page reloads (receipts from custodial wallets may + // never be published — the bump keeps the zapper's own view honest). + ingestLocalZap(pointer, sats) { + const key = pointerKey(pointer); + const amount = Number.isFinite(sats) ? Math.max(0, Math.round(sats)) : 0; + if (!key || amount <= 0) { + return; + } + const entry = entryFor(key); + entry.optimisticSats += amount; + entry.fetchedAt = now(); + emitChange(); }, onChange(listener) { if (typeof listener !== "function") { @@ -277,6 +314,12 @@ export function onZapTotalsChanged(listener) { return store.onChange(listener); } +// Optimistic bump after this client sends a zap, so the card + modal badge +// update instantly (the relay receipt reconciles later). +export function ingestLocalVideoZap(pointer, sats) { + return store.ingestLocalZap(pointer, sats); +} + // The singleton store itself — exposes flush() for tests. export default store; diff --git a/tests/zap-totals.test.mjs b/tests/zap-totals.test.mjs index 08ecd586..051731d7 100644 --- a/tests/zap-totals.test.mjs +++ b/tests/zap-totals.test.mjs @@ -155,6 +155,40 @@ test("zero-receipt answers are cached (no immediate refetch) and listeners fire" assert.equal(scheduled.length, schedulesBefore + 1, "stale entry schedules again"); }); +test("optimistic ingest bumps instantly, then a real receipt replaces it (no double-count)", async () => { + let t = 1000; + let scripted = []; + const s = createZapTotalsStore({ + now: () => t, + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ list: async () => scripted }), + }), + schedule: (fn) => { fn(); return 1; }, + }); + let changes = 0; + s.onChange(() => { changes += 1; }); + + const pointer = { type: "a", value: A1 }; + // Optimistic bump shows immediately, no fetch needed. + s.ingestLocalZap(pointer, 2100); + assert.equal(s.getSnapshot(pointer), 2100, "optimistic sats visible instantly"); + assert.ok(changes >= 1, "change emitted for the badge"); + + // Later, past the fetch TTL, the real receipt for the same zap lands → + // replaces the optimistic portion (total stays 2100, not 4200). + t += 5 * 60 * 1000; + scripted = [receipt("real-1", [["a", A1], amountTag(2100000)])]; + s.request(pointer); // stale now → schedules; schedule() runs it inline + await s.flush(); + assert.equal( + s.getSnapshot(pointer), + 2100, + "real receipt replaced the optimistic bump — not added to it", + ); +}); + 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 },