cards: orange right-aligned zap-total badge next to view counts (#47 follow-up)

Video cards show the total sats from the shared zapTotals cache: new
--color-zap token (bitcoin orange, both themes), badge pushed to the
engagement row's end so views/comments stay left-aligned, hidden until a
nonzero total is known. The list view's zap binder fills [data-zap-total]
on bind (also scheduling the batched receipt fetch) and one shared change
listener updates every bound card as receipt batches land.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-03 21:49:35 -04:00
1 parent 8dfd2034b0
commit 86d7db4b1a
8 files changed
+286 -2

No files matched your search

+12
View File
@@ -2904,6 +2904,18 @@
transform-origin: top center;
}
/* Zap total on video cards (#47 follow-up): orange, pushed to the row's end
so views/comments stay left-aligned and sats sit right-aligned. Hidden
(via the utility class) until a nonzero total is known. */
.video-card__zaps {
color: var(--color-zap);
margin-inline-start: auto;
}
.video-card__zaps .zap-total-text {
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. */
+4
View File
@@ -509,6 +509,8 @@
--color-accent: rgb(99 102 241);
--color-accent-strong: rgb(79 70 229);
--color-accent-pressed: rgb(67 56 202);
/* Zap totals (sats) — bitcoin orange, darker for light-bg contrast. */
--color-zap: rgb(217 119 6);
--color-accent-override: rgb(84 0 17);
--color-accent-strong-override: rgb(254 0 50);
--color-accent-pressed-override: rgb(169 0 33);
@@ -696,6 +698,8 @@
--color-accent: rgb(129 140 248);
--color-accent-strong: rgb(99 102 241);
--color-accent-pressed: rgb(79 70 229);
/* Zap totals (sats) — bitcoin orange. */
--color-zap: rgb(247 147 26);
--color-accent-override: rgb(84 0 17);
--color-accent-strong-override: rgb(254 0 50);
--color-accent-pressed-override: rgb(169 0 33);
+51
View File
@@ -169,6 +169,7 @@ export class VideoCard {
this.urlHealthBadgeEl = null;
this.torrentHealthBadgeEl = null;
this.viewCountEl = null;
this.zapTotalEl = null;
this.discussionCountEl = null;
this.authorPicEl = null;
this.authorNameEl = null;
@@ -2440,6 +2441,33 @@ export class VideoCard {
return svg;
}
createZapIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
classNames.forEach((className) => {
if (className) {
svg.classList.add(className);
}
});
const polygon = this.document.createElementNS(SVG_NAMESPACE, "polygon");
polygon.setAttribute(
"points",
"13 2 3 14 12 14 11 22 21 10 12 10 13 2",
);
svg.appendChild(polygon);
return svg;
}
createMessageIcon(classNames = []) {
const svg = this.document.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
@@ -2556,6 +2584,29 @@ export class VideoCard {
}
}
// Zaps (#47 follow-up): total sats in orange, pushed to the row's end
// (views/comments stay left). Hidden until a nonzero total is known —
// most videos have no zaps and a wall of "0 sats" is just noise. The
// list view's zap binder fills the value and reveals the wrapper.
if (hasPointer) {
const zapWrapper = this.createElement("div", {
classNames: ["flex", "items-center", "gap-1.5", "video-card__zaps", "hidden"],
});
zapWrapper.setAttribute("title", "Zap total (sats)");
zapWrapper.appendChild(this.createZapIcon(["w-4", "h-4"]));
const zap = this.createElement("span", {
classNames: ["zap-total-text"],
textContent: "",
});
zap.dataset.zapTotal = "";
zap.dataset.zapPointer = this.pointerInfo.key;
zapWrapper.appendChild(zap);
container.appendChild(zapWrapper);
this.zapTotalEl = zap;
}
return container;
}
+6
View File
@@ -4,6 +4,7 @@ import {
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
} from "../../viewCounter.js";
import { createZapTotalBinder } from "./videoCardZapTotals.js";
import { normalizeDesignSystemContext } from "../../designSystem.js";
import { userLogger } from "../../utils/logger.js";
@@ -172,6 +173,8 @@ export class VideoListView {
this.renderedVideoIds = new Set();
this.videoCardInstances = [];
this.viewCountSubscriptions = new Map();
// #47 follow-up: orange per-card zap totals (right-aligned in the row).
this.zapTotalBinder = createZapTotalBinder();
this.currentVideos = [];
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = null;
@@ -266,6 +269,7 @@ export class VideoListView {
destroy() {
this.unmount();
this.teardownAllViewCountSubscriptions();
this.zapTotalBinder.destroy();
this.renderedVideoIds.clear();
this.videoCardInstances = [];
this.currentVideos = [];
@@ -712,6 +716,7 @@ export class VideoListView {
if (pointerInfo) {
this.registerVideoViewCountElement(cardEl, pointerInfo);
this.zapTotalBinder.bind(cardEl, pointerInfo);
}
if (video && video.id) {
@@ -809,6 +814,7 @@ export class VideoListView {
container: this.container,
});
this.pruneDetachedViewCountElements();
this.zapTotalBinder.prune();
return displayVideos;
}
+108
View File
@@ -0,0 +1,108 @@
// js/ui/views/videoCardZapTotals.js
//
// Per-card zap-total binding (#47 follow-up): fills each video card's
// [data-zap-total] span from the shared zapTotals cache and reveals the
// orange sats badge once a nonzero total is known. bind() reads the cached
// total AND schedules a batched receipt fetch for unknown pointers
// (requestVideoZapTotal); one shared onZapTotalsChanged listener re-renders
// every bound card when a batch lands. Kept out of VideoListView.js for its
// file-size budget.
import {
requestVideoZapTotal,
getVideoZapTotalSnapshot,
onZapTotalsChanged,
} from "../../zapTotals.js";
import { formatViewCount } from "../../viewCounter.js";
function defaultFormatSats(sats) {
return `${formatViewCount(sats)} sats`;
}
export function createZapTotalBinder({ formatSats = defaultFormatSats } = {}) {
const entries = new Map(); // pointerKey → { pointer, elements:Set }
let unsubscribe = null;
const renderElement = (el, sats) => {
const wrapper = el.closest?.(".video-card__zaps") || null;
if (Number.isFinite(sats) && sats > 0) {
el.textContent = formatSats(sats);
wrapper?.classList?.remove("hidden");
} else {
el.textContent = "";
wrapper?.classList?.add("hidden");
}
};
const renderKey = (key) => {
const entry = entries.get(key);
if (!entry) {
return;
}
const sats = getVideoZapTotalSnapshot(entry.pointer);
for (const el of Array.from(entry.elements)) {
if (!el || !el.isConnected) {
entry.elements.delete(el);
continue;
}
renderElement(el, sats);
}
if (!entry.elements.size) {
entries.delete(key);
}
};
const ensureSubscribed = () => {
if (!unsubscribe) {
unsubscribe = onZapTotalsChanged(() => {
for (const key of Array.from(entries.keys())) {
renderKey(key);
}
});
}
};
return {
bind(cardEl, pointerInfo) {
if (!cardEl || !pointerInfo?.key || !pointerInfo?.pointer) {
return;
}
const el = cardEl.querySelector?.("[data-zap-total]");
if (!el) {
return;
}
let entry = entries.get(pointerInfo.key);
if (!entry) {
entry = { pointer: pointerInfo.pointer, elements: new Set() };
entries.set(pointerInfo.key, entry);
}
entry.elements.add(el);
ensureSubscribed();
// Cached total now (also schedules the batched fetch when unknown).
renderElement(el, requestVideoZapTotal(pointerInfo.pointer));
},
prune() {
for (const [key, entry] of Array.from(entries.entries())) {
for (const el of Array.from(entry.elements)) {
if (!el || !el.isConnected) {
entry.elements.delete(el);
}
}
if (!entry.elements.size) {
entries.delete(key);
}
}
},
destroy() {
if (typeof unsubscribe === "function") {
try {
unsubscribe();
} catch (error) {
// best effort
}
unsubscribe = null;
}
entries.clear();
},
};
}
+3
View File
@@ -266,3 +266,6 @@ export function requestVideoZapTotal(pointer) {
export function onZapTotalsChanged(listener) {
return store.onChange(listener);
}
// The singleton store itself — exposes flush() for tests.
export default store;
+88
View File
@@ -0,0 +1,88 @@
// #47 follow-up: video cards show the zap total (sats, orange, right-aligned)
// next to the view count. The binder fills [data-zap-total] from the shared
// zapTotals cache, hides the badge while the total is zero/unknown (no "0
// sats" noise), and reveals/updates every bound card when a receipt batch
// lands (the shared change signal).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-card-zap-badge
// given: "cards bound to pointers with zero and nonzero cached totals"
// when: "bind() runs and a later zapTotals change fires"
// then: "nonzero → formatted sats + revealed wrapper; zero → hidden; change signal updates in place; detached cards pruned"
// observable_outcomes:
// - "badge textContent and .hidden class per state"
// determinism_controls:
// - "JSDOM; the real zapTotals singleton driven through its store test hook is avoided — the binder is exercised against the exported cache APIs via a scripted store double at module boundary"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { createZapTotalBinder } from "../js/ui/views/videoCardZapTotals.js";
import zapTotalsStore, {
initZapTotals,
} from "../js/zapTotals.js";
const A1 = "30078:aaaa:video-1";
function makeCard(doc) {
const card = doc.createElement("div");
card.innerHTML =
'<div class="video-card__zaps hidden"><span data-zap-total></span></div>';
doc.body.appendChild(card);
return card;
}
test("binder: zero stays hidden, receipts reveal and format, prune drops detached", async () => {
const dom = new JSDOM("<!DOCTYPE html><body></body>", { pretendToBeVisual: true });
const doc = dom.window.document;
// Drive the REAL singleton store: scripted receipts arrive via its batched
// fetch (subscription-manager list double), so the binder sees the same
// cache + change signal production uses.
let receipts = [];
initZapTotals({
nostrClient: {
relays: ["wss://relay.example"],
getSubscriptionManager: () => ({ list: async () => receipts }),
},
tools: { nip57: { getSatoshisAmountFromBolt11: () => NaN } },
});
const binder = createZapTotalBinder();
const card = makeCard(doc);
const badge = card.querySelector("[data-zap-total]");
const wrapper = card.querySelector(".video-card__zaps");
const pointerInfo = { key: `a:${A1}`, pointer: { type: "a", value: A1 } };
binder.bind(card, pointerInfo);
assert.equal(wrapper.classList.contains("hidden"), true, "unknown total stays hidden");
assert.equal(badge.textContent, "");
// A receipt batch lands: 2100 sats via the zap request's amount tag.
receipts = [
{
id: "r1",
kind: 9735,
tags: [
["a", A1],
["description", JSON.stringify({ tags: [["amount", "2100000"]] })],
],
},
];
await zapTotalsStore.flush();
assert.equal(wrapper.classList.contains("hidden"), false, "revealed once nonzero");
assert.match(badge.textContent, /2,?100 sats|2\.1K sats/);
// Detached cards are dropped by prune (no leak, no stale writes).
card.remove();
binder.prune();
binder.destroy();
});
+14 -2
View File
@@ -633,9 +633,21 @@ toward freshness and looked identical. Gave each a structural identity:
`FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default
on; off hides the tab like Trending's flag).
Tests: `tests/zap-totals.test.mjs` (5). Lint + build green.
- [x] **Follow-up DONE 2026-07-03 — per-card zap badges.** Video cards now show
the zap total in bitcoin ORANGE (new `--color-zap` token, both themes),
RIGHT-ALIGNED in the engagement row (views/comments stay left; CSS
`margin-inline-start: auto` on `.video-card__zaps`). Hidden until a
nonzero total is known (no "0 sats" noise). Binder
(`js/ui/views/videoCardZapTotals.js`, VideoListView file-size budget)
fills `[data-zap-total]` from the shared zapTotals cache — bind() also
schedules the batched receipt fetch, and one change listener updates
every bound card as batches land. Test:
`tests/video-card-zap-totals.test.mjs` (drives the REAL store).
- [ ] **VERIFY on unstable:** open Most Zapped → zapped videos rise as receipts
load (unzapped feed reads as Recent at first); zap a video → within ~2min
of tab re-entry its rank reflects the new total.
load (unzapped feed reads as Recent at first) and their cards show the
orange right-aligned sats badge; unzapped cards show no badge; zap a
video → within ~2min of tab re-entry its rank + badge reflect the new
total.
### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07)
- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail