zaps: optimistic zap-total bump so the badge updates instantly on send

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-04 14:55:14 -04:00
1 parent 8d45f25962
commit af09505fb3
4 files changed
+115 -4

No files matched your search

+16
View File
@@ -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(),
});
+18
View File
@@ -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
+47 -4
View File
@@ -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;
+34
View File
@@ -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 },