diff --git a/components/video-modal.html b/components/video-modal.html
index f04c6704..6eaafa51 100644
--- a/components/video-modal.html
+++ b/components/video-modal.html
@@ -211,6 +211,16 @@
–
+
+
+
+
diff --git a/css/tailwind.source.css b/css/tailwind.source.css
index db4417a3..4392010a 100644
--- a/css/tailwind.source.css
+++ b/css/tailwind.source.css
@@ -2916,6 +2916,13 @@
font-variant-numeric: tabular-nums;
}
+ /* Same treatment in the player modal: orange sats on the far side of the
+ meta row (the row is justify-between, views stay left). */
+ .video-modal__zaps {
+ color: var(--color-zap);
+ font-variant-numeric: tabular-nums;
+ }
+
/* #43: always-visible active-identity pill. Rendered after the header in
flow and pulled up with a negative block-start margin so it sits half-on /
half-off the header divider — every pane shows who you're acting as. */
diff --git a/js/app.js b/js/app.js
index 328b0105..2c6d2b16 100644
--- a/js/app.js
+++ b/js/app.js
@@ -119,7 +119,12 @@ import {
formatViewCount,
ingestLocalViewEvent,
} from "./viewCounter.js";
-import { initZapTotals } from "./zapTotals.js";
+import {
+ initZapTotals,
+ requestVideoZapTotal,
+ getVideoZapTotalSnapshot,
+ onZapTotalsChanged,
+} from "./zapTotals.js";
import {
formatAbsoluteTimestamp as formatAbsoluteTimestampUtil,
formatAbsoluteDateWithOrdinal as formatAbsoluteDateWithOrdinalUtil,
@@ -699,6 +704,9 @@ class Application {
unsubscribeFromVideoViewCount,
formatViewCount,
ingestLocalViewEvent,
+ requestVideoZapTotal,
+ getVideoZapTotalSnapshot,
+ onZapTotalsChanged,
pointerArrayToKey,
pointerKey: pointerKey,
getCanonicalDesignSystemMode,
diff --git a/js/app/modalCoordinator.js b/js/app/modalCoordinator.js
index cf844aa7..6b02cec0 100644
--- a/js/app/modalCoordinator.js
+++ b/js/app/modalCoordinator.js
@@ -25,6 +25,9 @@ export function createModalCoordinator(deps) {
unsubscribeFromVideoViewCount,
formatViewCount,
ingestLocalViewEvent,
+ requestVideoZapTotal,
+ getVideoZapTotalSnapshot,
+ onZapTotalsChanged,
pointerArrayToKey,
pointerKey,
getCanonicalDesignSystemMode,
@@ -117,12 +120,66 @@ export function createModalCoordinator(deps) {
}
}
this.modalViewCountUnsub = null;
+ this.teardownModalZapTotal();
if (this.videoModal) {
this.videoModal.updateViewCountLabel("\u2013 views");
this.videoModal.setViewCountPointer(null);
}
},
+ teardownModalZapTotal() {
+ if (typeof this.modalZapTotalUnsub === "function") {
+ try {
+ this.modalZapTotalUnsub();
+ } catch (error) {
+ devLogger.warn("[zapTotals] Failed to tear down modal listener:", error);
+ }
+ }
+ this.modalZapTotalUnsub = null;
+ const el =
+ typeof document !== "undefined"
+ ? document.getElementById("videoZapTotal")
+ : null;
+ if (el) {
+ el.classList.add("hidden");
+ const text = el.querySelector("[data-zap-total-text]");
+ if (text) {
+ text.textContent = "";
+ }
+ }
+ },
+
+ // Orange sats badge on the modal's meta row (right side; views stay left).
+ // Mirrors the card badge: cached total now (also schedules the batched
+ // receipt fetch), re-rendered when a batch lands, hidden while zero.
+ subscribeModalZapTotal(pointer) {
+ if (typeof requestVideoZapTotal !== "function") {
+ return;
+ }
+ const el =
+ typeof document !== "undefined"
+ ? document.getElementById("videoZapTotal")
+ : null;
+ if (!el || !pointer) {
+ return;
+ }
+ const render = () => {
+ const sats = getVideoZapTotalSnapshot(pointer);
+ const text = el.querySelector("[data-zap-total-text]");
+ if (Number.isFinite(sats) && sats > 0) {
+ if (text) {
+ text.textContent = `${formatViewCount(sats)} sats`;
+ }
+ el.classList.remove("hidden");
+ } else {
+ el.classList.add("hidden");
+ }
+ };
+ requestVideoZapTotal(pointer);
+ render();
+ this.modalZapTotalUnsub = onZapTotalsChanged(render);
+ },
+
subscribeModalViewCount(pointer, pointerKey) {
const viewEl = this.videoModal?.getViewCountElement() || null;
if (!viewEl) {
@@ -139,6 +196,7 @@ export function createModalCoordinator(deps) {
this.videoModal.updateViewCountLabel("Loading views\u2026");
this.videoModal.setViewCountPointer(pointerKey);
}
+ this.subscribeModalZapTotal(pointer);
try {
const token = subscribeToVideoViewCount(pointer, ({ total, status, partial }) => {
const latestViewEl = this.videoModal?.getViewCountElement() || null;
diff --git a/js/zapTotals.js b/js/zapTotals.js
index d570983d..b7b1f4fd 100644
--- a/js/zapTotals.js
+++ b/js/zapTotals.js
@@ -29,9 +29,19 @@ function tagValues(event, name) {
.map((tag) => tag[1]);
}
+// Accepts BOTH pointer shapes used in the app: the canonical tag-style array
+// ["a"|"e", value, relay?] (what deriveVideoPointerInfo/resolveVideoPointer
+// produce) and the object form { type, value }.
export function pointerKey(pointer) {
- const type = pointer?.type === "e" ? "e" : pointer?.type === "a" ? "a" : "";
- const value = typeof pointer?.value === "string" ? pointer.value.trim() : "";
+ let type = "";
+ let value = "";
+ if (Array.isArray(pointer)) {
+ type = pointer[0] === "a" || pointer[0] === "e" ? pointer[0] : "";
+ value = typeof pointer[1] === "string" ? pointer[1].trim() : "";
+ } else if (pointer && typeof pointer === "object") {
+ type = pointer.type === "a" || pointer.type === "e" ? pointer.type : "";
+ value = typeof pointer.value === "string" ? pointer.value.trim() : "";
+ }
if (!type || !value) {
return "";
}
diff --git a/tests/video-card-zap-totals.test.mjs b/tests/video-card-zap-totals.test.mjs
index 4e2fc713..009f233f 100644
--- a/tests/video-card-zap-totals.test.mjs
+++ b/tests/video-card-zap-totals.test.mjs
@@ -60,7 +60,8 @@ test("binder: zero stays hidden, receipts reveal and format, prune drops detache
const badge = card.querySelector("[data-zap-total]");
const wrapper = card.querySelector(".video-card__zaps");
- const pointerInfo = { key: `a:${A1}`, pointer: { type: "a", value: A1 } };
+ // Tag-style ARRAY pointer — the shape VideoListView actually passes.
+ const pointerInfo = { key: `a:${A1}`, pointer: ["a", A1] };
binder.bind(card, pointerInfo);
assert.equal(wrapper.classList.contains("hidden"), true, "unknown total stays hidden");
assert.equal(badge.textContent, "");
diff --git a/tests/zap-totals.test.mjs b/tests/zap-totals.test.mjs
index acbc5744..08ecd586 100644
--- a/tests/zap-totals.test.mjs
+++ b/tests/zap-totals.test.mjs
@@ -65,7 +65,14 @@ test("amount extraction: bolt11 first, zap-request amount fallback, garbage →
);
});
-test("pointerKey normalizes and rejects unusable pointers", () => {
+test("pointerKey accepts BOTH app shapes and rejects unusable pointers", () => {
+ // Tag-style ARRAY — what deriveVideoPointerInfo/resolveVideoPointer actually
+ // produce (regression: the object-only version silently zeroed every card
+ // badge and the Most Zapped ranking).
+ assert.equal(pointerKey(["a", A1]), `a:${A1}`);
+ assert.equal(pointerKey(["e", "evt1", "wss://relay.example"]), "e:evt1");
+ assert.equal(pointerKey(["x", "nope"]), "");
+ // Object form.
assert.equal(pointerKey({ type: "a", value: A1 }), `a:${A1}`);
assert.equal(pointerKey({ type: "e", value: "evt1" }), "e:evt1");
assert.equal(pointerKey({ type: "x", value: "nope" }), "");