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