From 8d45f25962cd6fcb5300abc5efbc4440d6b9e109 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:43:22 -0400 Subject: [PATCH] zaps: make the card + modal zap counter always visible (0 sats when none) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosis (live, verified): the zap-totals pipeline is correct end to end — a seeded receipt queries and sums to the right sats — but of 310 kind-9735 receipts reachable on the relays, ZERO reference any bitvid video, so every badge was legitimately hidden. That reads as "broken" and profile zaps to a Lightning address never attach to a video, so hidden-when-zero left the feature invisible in practice. Show the counter always, like the view counter it sits beside: "0 sats" until a receipt lands, then the summed total. Card binder and modal render no longer gate on sats>0; the badge is revealed on bind/open and re-hidden on modal close (no video open). Live-checked: 158/158 cards render the badge. Tests spec-corrected with integrity notes. Co-Authored-By: Claude Fable 5 --- js/app/modalCoordinator.js | 13 ++++++------ js/ui/views/videoCardZapTotals.js | 14 ++++++------- tests/modal-coordinator-zap-badge.test.mjs | 19 +++++++++++------ tests/video-card-zap-totals.test.mjs | 24 +++++++++++----------- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/js/app/modalCoordinator.js b/js/app/modalCoordinator.js index f9c8a65d..ac4e9460 100644 --- a/js/app/modalCoordinator.js +++ b/js/app/modalCoordinator.js @@ -81,17 +81,16 @@ export function createModalCoordinator(deps) { if (!el || !pointer) { return; } + // Always visible while a video is open (mirrors the view counter); a zero + // is a real value, not noise to hide. const render = () => { const sats = getVideoZapTotalSnapshot(pointer); + const value = Number.isFinite(sats) && sats > 0 ? sats : 0; 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"); + if (text) { + text.textContent = `${formatViewCount(value)} sats`; } + el.classList.remove("hidden"); }; requestVideoZapTotal(pointer); render(); diff --git a/js/ui/views/videoCardZapTotals.js b/js/ui/views/videoCardZapTotals.js index 9d336c9a..60a3010e 100644 --- a/js/ui/views/videoCardZapTotals.js +++ b/js/ui/views/videoCardZapTotals.js @@ -23,15 +23,15 @@ export function createZapTotalBinder({ formatSats = defaultFormatSats } = {}) { const entries = new Map(); // pointerKey → { pointer, elements:Set } let unsubscribe = null; + // Always visible, mirroring the view counter (a zero is a real, informative + // value — "this hasn't been zapped yet" — not noise to hide). The wrapper + // starts hidden in markup and is revealed on first bind so cards without a + // pointer stay clean. 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 value = Number.isFinite(sats) && sats > 0 ? sats : 0; + el.textContent = formatSats(value); + wrapper?.classList?.remove("hidden"); }; const renderKey = (key) => { diff --git a/tests/modal-coordinator-zap-badge.test.mjs b/tests/modal-coordinator-zap-badge.test.mjs index a8b17ab5..84b113eb 100644 --- a/tests/modal-coordinator-zap-badge.test.mjs +++ b/tests/modal-coordinator-zap-badge.test.mjs @@ -13,10 +13,10 @@ // - id: SCN-modal-open-no-missing-delegate // given: "a coordinator bound to an app stub exposing only app.js's real modal delegates" // when: "subscribeModalViewCount runs (the modal-open path) and teardown runs (close path)" -// then: "no TypeError; the zap badge renders from the shared store and clears on teardown" +// then: "no TypeError; the zap badge is visible from open (0 sats) and updates to the summed total; hidden on teardown" // observable_outcomes: // - "subscribeModalViewCount does not throw (the user-facing 'video will not open' bug)" -// - "badge hidden at zero, revealed with sats after a receipt batch, cleared on teardown" +// - "badge visible at open showing 0 sats, updates to the summed total, hidden on teardown (no video open)" // determinism_controls: // - "JSDOM; scripted subscription-manager double; store flushed manually" // anti_cheat_rationale: @@ -86,7 +86,13 @@ test("modal open path survives binding and drives the zap badge", async () => { app.subscribeModalViewCount(pointer, `a:${A1}`); const badge = dom.window.document.getElementById("videoZapTotal"); - assert.equal(badge.classList.contains("hidden"), true, "zero total stays hidden"); + // Always visible while a video is open (mirrors the view counter); 0 sats + // until a receipt lands. + assert.equal(badge.classList.contains("hidden"), false, "visible while open"); + assert.match( + badge.querySelector("[data-zap-total-text]").textContent, + /^0 sats$/, + ); receipts = [ { @@ -96,13 +102,14 @@ test("modal open path survives binding and drives the zap badge", async () => { }, ]; await zapTotalsStore.flush(); - assert.equal(badge.classList.contains("hidden"), false, "receipts reveal the badge"); + assert.equal(badge.classList.contains("hidden"), false, "stays visible"); assert.match( badge.querySelector("[data-zap-total-text]").textContent, /21 sats/, ); - // Modal close path (also used to throw via this.teardownModalZapTotal). + // Modal close path re-hides the badge (no video open) — also used to throw + // via the missing this.teardownModalZapTotal delegate. app.teardownModalViewCountSubscription(); - assert.equal(badge.classList.contains("hidden"), true, "teardown clears the badge"); + assert.equal(badge.classList.contains("hidden"), true, "teardown hides the badge"); }); diff --git a/tests/video-card-zap-totals.test.mjs b/tests/video-card-zap-totals.test.mjs index 009f233f..69ac5799 100644 --- a/tests/video-card-zap-totals.test.mjs +++ b/tests/video-card-zap-totals.test.mjs @@ -1,24 +1,24 @@ // #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). +// next to the view count. The badge is ALWAYS visible once bound (mirroring the +// view counter — a zero is a real, informative value), showing "0 sats" until a +// receipt batch lands, then the summed total. // // test_integrity_note: -// change_type: ["new_tests"] +// change_type: ["spec_correction"] // scenarios: // - id: SCN-card-zap-badge -// given: "cards bound to pointers with zero and nonzero cached totals" +// given: "cards bound to pointers with zero then 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" +// then: "badge visible from bind showing 0 sats; updates to the summed total in place; detached cards pruned" // observable_outcomes: -// - "badge textContent and .hidden class per state" +// - "badge textContent per state; wrapper visible after bind" // 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" +// - "JSDOM; the real zapTotals singleton driven through its store test hook; scripted subscription-manager list double" // anti_cheat_rationale: // prevents: ["hard-coded return value", "over-mocking internal logic"] // relaxation: // did_relax_any_assertion: false +// if_true_explain_spec_basis: "hidden-when-zero replaced with always-visible (matches the view counter the feature sits beside; the maintainer wants a persistent counter). Equally strict: still asserts exact textContent per state." import "./test-helpers/setup-localstorage.mjs"; import test from "node:test"; @@ -63,8 +63,8 @@ test("binder: zero stays hidden, receipts reveal and format, prune drops detache // 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, ""); + assert.equal(wrapper.classList.contains("hidden"), false, "visible from bind"); + assert.match(badge.textContent, /^0 sats$/, "shows 0 sats until receipts arrive"); // A receipt batch lands: 2100 sats via the zap request's amount tag. receipts = [ @@ -79,7 +79,7 @@ test("binder: zero stays hidden, receipts reveal and format, prune drops detache ]; await zapTotalsStore.flush(); - assert.equal(wrapper.classList.contains("hidden"), false, "revealed once nonzero"); + assert.equal(wrapper.classList.contains("hidden"), false, "stays visible"); assert.match(badge.textContent, /2,?100 sats|2\.1K sats/); // Detached cards are dropped by prune (no leak, no stale writes).