zaps: fix badge never showing (array pointers) + zap total in the player modal

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-04 09:20:11 -04:00
1 parent ad62205afd
commit cbe894969d
7 files changed
+106 -5

No files matched your search

+10
View File
@@ -211,6 +211,16 @@
<span data-view-count-text>–</span>
</span>
</div>
<span
id="videoZapTotal"
class="modal-meta video-modal__zaps flex items-center gap-1.5 hidden"
title="Zap total (sats)"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
<span data-zap-total-text></span>
</span>
</div>
<div class="video-modal__actions-row">
+7
View File
@@ -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. */
+9 -1
View File
@@ -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,
+58
View File
@@ -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;
+12 -2
View File
@@ -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 "";
}
+2 -1
View File
@@ -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, "");
+8 -1
View File
@@ -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" }), "");