From cbe894969d4a86d48c1376a54cc47bdee7bd7007 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:20:11 -0400 Subject: [PATCH] zaps: fix badge never showing (array pointers) + zap total in the player modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zap-totals store's pointerKey only understood {type,value} objects, but deriveVideoPointerInfo produces tag-style ARRAYS (["a", value, relay?]) — so every card-badge lookup keyed to nothing, no receipt fetch was ever scheduled, and the Most Zapped ranking metric silently read 0 for every video. pointerKey now normalizes both shapes; tests use the array shape the app actually passes. Also adds the orange sats badge to the video player modal's meta row (right side via the row's existing justify-between; views stay left), driven by the same cache/change-signal as the cards and torn down with the view-count subscription. Co-Authored-By: Claude Fable 5 --- components/video-modal.html | 10 +++++ css/tailwind.source.css | 7 ++++ js/app.js | 10 ++++- js/app/modalCoordinator.js | 58 ++++++++++++++++++++++++++++ js/zapTotals.js | 14 ++++++- tests/video-card-zap-totals.test.mjs | 3 +- tests/zap-totals.test.mjs | 9 ++++- 7 files changed, 106 insertions(+), 5 deletions(-) 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" }), "");