mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
8d45f25962
commit
af09505fb3
4 files changed
+115
-4
No files matched your search
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user