mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #2787 from PR0M3TH3AN/codex/enable-sound-by-default-in-player-2ks9wb
Whitelist-scoped Most Zapped feed, autoplay-fallback mute handling, BitLogin DOM hardening, and auth-thread reload
This commit is contained in:
7 files changed
+241
-23
No files matched your search
@@ -21,6 +21,7 @@ export {
|
|||||||
} from "./sorters.js";
|
} from "./sorters.js";
|
||||||
export {
|
export {
|
||||||
createActiveNostrSource,
|
createActiveNostrSource,
|
||||||
|
createWhitelistedAuthorsNostrSource,
|
||||||
createSubscriptionAuthorsSource,
|
createSubscriptionAuthorsSource,
|
||||||
createWatchHistoryPointerSource,
|
createWatchHistoryPointerSource,
|
||||||
} from "./sources.js";
|
} from "./sources.js";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// js/feedEngine/mostZappedFeed.js
|
// js/feedEngine/mostZappedFeed.js
|
||||||
//
|
//
|
||||||
// The "Most Zapped" tab (#47): the recently-added (active) source ranked by
|
// The "Most Zapped" tab (#47): the catalog of approved creators ranked by
|
||||||
// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives
|
// ZAP TOTAL (sats from kind-9735 receipts). It is intentionally scoped to the
|
||||||
// outside feedCoordinator.js (size cap) and is wired in via thin delegations.
|
// creator whitelist for discovery, but still runs the ordinary moderation
|
||||||
|
// stages — whitelisting does not waive Web-of-Trust handling.
|
||||||
// The metric getter uses requestVideoZapTotal, which returns the cached total
|
// The metric getter uses requestVideoZapTotal, which returns the cached total
|
||||||
// AND schedules a batched receipt fetch for unknown pointers; the Most Zapped
|
// AND schedules a batched receipt fetch for unknown pointers; the Most Zapped
|
||||||
// view re-runs the feed on zapTotals' change signal so the order settles as
|
// view re-runs the feed on zapTotals' change signal so the order settles as
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
import { FEED_TYPES } from "../constants.js";
|
import { FEED_TYPES } from "../constants.js";
|
||||||
import { requestVideoZapTotal } from "../zapTotals.js";
|
import { requestVideoZapTotal } from "../zapTotals.js";
|
||||||
import {
|
import {
|
||||||
createActiveNostrSource,
|
createWhitelistedAuthorsNostrSource,
|
||||||
createBlacklistFilterStage,
|
createBlacklistFilterStage,
|
||||||
createDedupeByRootStage,
|
createDedupeByRootStage,
|
||||||
createModerationStage,
|
createModerationStage,
|
||||||
@@ -43,6 +44,19 @@ function makeGetZapTotal(app) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWhitelistedAuthors(app) {
|
||||||
|
const accessControl = app?.nostrService?.accessControl;
|
||||||
|
if (typeof accessControl?.getWhitelistPubkeys !== "function") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const authors = accessControl.getWhitelistPubkeys();
|
||||||
|
return Array.isArray(authors) ? authors : [];
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildMostZappedFeedRuntime(app) {
|
export function buildMostZappedFeedRuntime(app) {
|
||||||
const blacklist =
|
const blacklist =
|
||||||
app.blacklistedEventIds instanceof Set
|
app.blacklistedEventIds instanceof Set
|
||||||
@@ -59,6 +73,7 @@ export function buildMostZappedFeedRuntime(app) {
|
|||||||
? { ...moderationThresholds }
|
? { ...moderationThresholds }
|
||||||
: undefined,
|
: undefined,
|
||||||
getZapTotal: makeGetZapTotal(app),
|
getZapTotal: makeGetZapTotal(app),
|
||||||
|
whitelistedAuthors: getWhitelistedAuthors(app),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +109,7 @@ export function registerMostZappedFeed(app) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, {
|
return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, {
|
||||||
source: createActiveNostrSource({ service: app.nostrService }),
|
source: createWhitelistedAuthorsNostrSource({ service: app.nostrService }),
|
||||||
stages: [
|
stages: [
|
||||||
createBlacklistFilterStage({
|
createBlacklistFilterStage({
|
||||||
shouldIncludeVideo: (video, options) =>
|
shouldIncludeVideo: (video, options) =>
|
||||||
|
|||||||
@@ -62,6 +62,89 @@ export function createActiveNostrSource({ service } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bounded discovery source for curated surfaces such as Most Zapped. It never
|
||||||
|
// falls back to an unscoped relay request: the caller must provide approved
|
||||||
|
// creator pubkeys in runtime.whitelistedAuthors. The successful remote result
|
||||||
|
// is retained briefly so an asynchronous ranking update does not turn into a
|
||||||
|
// second full relay scan for the same author set.
|
||||||
|
export function createWhitelistedAuthorsNostrSource({
|
||||||
|
service,
|
||||||
|
refreshTtlMs = 2 * 60 * 1000,
|
||||||
|
now = () => Date.now(),
|
||||||
|
} = {}) {
|
||||||
|
const resolvedService = resolveService(service, nostrService);
|
||||||
|
let cachedAuthorsKey = "";
|
||||||
|
let cachedVideos = [];
|
||||||
|
let fetchedAt = 0;
|
||||||
|
let inFlight = null;
|
||||||
|
|
||||||
|
const normalizeAuthors = (input) =>
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
toArray(input)
|
||||||
|
.map(normalizeAuthor)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
return async function whitelistedAuthorsNostrSource(context = {}) {
|
||||||
|
const authors = normalizeAuthors(context?.runtime?.whitelistedAuthors);
|
||||||
|
const authorsKey = authors.join(",");
|
||||||
|
if (!authors.length) {
|
||||||
|
cachedAuthorsKey = "";
|
||||||
|
cachedVideos = [];
|
||||||
|
fetchedAt = 0;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheIsCurrent =
|
||||||
|
authorsKey === cachedAuthorsKey &&
|
||||||
|
now() - fetchedAt < Math.max(0, Number(refreshTtlMs) || 0);
|
||||||
|
|
||||||
|
if (!cacheIsCurrent && !inFlight) {
|
||||||
|
const fetchByAuthors = resolvedService?.fetchVideosByAuthors;
|
||||||
|
inFlight = Promise.resolve(
|
||||||
|
typeof fetchByAuthors === "function"
|
||||||
|
? fetchByAuthors.call(resolvedService, authors)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
.then((videos) => {
|
||||||
|
const allowed = new Set(authors);
|
||||||
|
cachedAuthorsKey = authorsKey;
|
||||||
|
cachedVideos = (Array.isArray(videos) ? videos : []).filter(
|
||||||
|
(video) => allowed.has(normalizeAuthor(video?.pubkey)),
|
||||||
|
);
|
||||||
|
fetchedAt = now();
|
||||||
|
return cachedVideos;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
context?.log?.(
|
||||||
|
"[whitelisted-authors-source] Failed to fetch approved creators",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return authorsKey === cachedAuthorsKey ? cachedVideos : [];
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = cacheIsCurrent ? cachedVideos : await inFlight;
|
||||||
|
// The whitelist may change while a prior author-scoped request is still in
|
||||||
|
// flight. Filter again at return time so that stale results can never
|
||||||
|
// briefly render after an administrator removes a creator.
|
||||||
|
const allowed = new Set(authors);
|
||||||
|
return (Array.isArray(videos) ? videos : [])
|
||||||
|
.filter((video) => allowed.has(normalizeAuthor(video?.pubkey)))
|
||||||
|
.map((video) => ({
|
||||||
|
video,
|
||||||
|
metadata: {
|
||||||
|
source: "nostr:whitelisted-authors",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createSubscriptionAuthorsSource({ service } = {}) {
|
export function createSubscriptionAuthorsSource({ service } = {}) {
|
||||||
const resolvedService = resolveService(service, nostrService);
|
const resolvedService = resolveService(service, nostrService);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ function closeLoginModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHtmlElement(value, documentRef = document) {
|
||||||
|
const HTMLElementConstructor =
|
||||||
|
documentRef?.defaultView?.HTMLElement || globalThis.HTMLElement;
|
||||||
|
return typeof HTMLElementConstructor === "function"
|
||||||
|
? value instanceof HTMLElementConstructor
|
||||||
|
: Boolean(value && value.nodeType === 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Cheap insurance against wireBitloginLogin() ever running twice for the same
|
// Cheap insurance against wireBitloginLogin() ever running twice for the same
|
||||||
// element (e.g. a future bootstrap change that calls it more than once): the
|
// element (e.g. a future bootstrap change that calls it more than once): the
|
||||||
// same <bitlogin-auth> instance should only ever get one "bitlogin-login"
|
// same <bitlogin-auth> instance should only ever get one "bitlogin-login"
|
||||||
@@ -82,12 +90,12 @@ function attachWidget(app, widget) {
|
|||||||
// Unstable-branch experiment (AGENTS.md §1, config/instance-config.js) --
|
// Unstable-branch experiment (AGENTS.md §1, config/instance-config.js) --
|
||||||
// leave the permanent, statically-declared markup in place but never load
|
// leave the permanent, statically-declared markup in place but never load
|
||||||
// the vendored bundle or show the (otherwise-empty, unupgraded) element.
|
// the vendored bundle or show the (otherwise-empty, unupgraded) element.
|
||||||
if (mount instanceof HTMLElement) {
|
if (isHtmlElement(mount)) {
|
||||||
mount.hidden = true;
|
mount.hidden = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mount instanceof HTMLElement) {
|
if (isHtmlElement(mount)) {
|
||||||
mount.hidden = false;
|
mount.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,22 +170,29 @@ function attachWidget(app, widget) {
|
|||||||
* @param {{ authService: { requestLogin: Function } }} app
|
* @param {{ authService: { requestLogin: Function } }} app
|
||||||
*/
|
*/
|
||||||
export function wireBitloginLogin(app) {
|
export function wireBitloginLogin(app) {
|
||||||
if (!app?.authService) {
|
if (!app?.authService || typeof document === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = document.getElementById("bitloginWidget");
|
const documentRef = document;
|
||||||
if (existing instanceof HTMLElement) {
|
const existing = documentRef.getElementById("bitloginWidget");
|
||||||
|
if (isHtmlElement(existing, documentRef)) {
|
||||||
attachWidget(app, existing);
|
attachWidget(app, existing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
const Observer =
|
||||||
const widget = document.getElementById("bitloginWidget");
|
documentRef.defaultView?.MutationObserver || globalThis.MutationObserver;
|
||||||
if (widget instanceof HTMLElement) {
|
if (typeof Observer !== "function" || !documentRef.body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new Observer(() => {
|
||||||
|
const widget = documentRef.getElementById("bitloginWidget");
|
||||||
|
if (isHtmlElement(widget, documentRef)) {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
attachWidget(app, widget);
|
attachWidget(app, widget);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
observer.observe(documentRef.body, { childList: true, subtree: true });
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { resolveVerifiedBitloginPubkey } from "../js/ui/bitloginModalIntegration.js";
|
import {
|
||||||
|
resolveVerifiedBitloginPubkey,
|
||||||
|
wireBitloginLogin,
|
||||||
|
} from "../js/ui/bitloginModalIntegration.js";
|
||||||
|
|
||||||
const PUBKEY = "a".repeat(64);
|
const PUBKEY = "a".repeat(64);
|
||||||
|
|
||||||
@@ -19,3 +22,22 @@ test("BitLogin rejects malformed claims and widgets without a worker-backed publ
|
|||||||
assert.equal(await resolveVerifiedBitloginPubkey({}, PUBKEY), "");
|
assert.equal(await resolveVerifiedBitloginPubkey({}, PUBKEY), "");
|
||||||
assert.equal(await resolveVerifiedBitloginPubkey({ getPublicKey: async () => PUBKEY }, "npub1nope"), "");
|
assert.equal(await resolveVerifiedBitloginPubkey({ getPublicKey: async () => PUBKEY }, "npub1nope"), "");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("BitLogin bootstrap safely defers when MutationObserver is unavailable", () => {
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const originalObserver = globalThis.MutationObserver;
|
||||||
|
globalThis.document = {
|
||||||
|
body: {},
|
||||||
|
defaultView: {},
|
||||||
|
getElementById: () => null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
delete globalThis.MutationObserver;
|
||||||
|
assert.doesNotThrow(() => wireBitloginLogin({ authService: {} }));
|
||||||
|
} finally {
|
||||||
|
if (originalDocument === undefined) delete globalThis.document;
|
||||||
|
else globalThis.document = originalDocument;
|
||||||
|
if (originalObserver === undefined) delete globalThis.MutationObserver;
|
||||||
|
else globalThis.MutationObserver = originalObserver;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { createWhitelistedAuthorsNostrSource } from "../js/feedEngine/sources.js";
|
||||||
|
|
||||||
|
test("Most Zapped discovery fetches only whitelisted creators and caches the catalog", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const approved = "a".repeat(64);
|
||||||
|
const unapproved = "b".repeat(64);
|
||||||
|
let now = 1_000;
|
||||||
|
const source = createWhitelistedAuthorsNostrSource({
|
||||||
|
now: () => now,
|
||||||
|
service: {
|
||||||
|
async fetchVideosByAuthors(authors) {
|
||||||
|
calls.push(authors);
|
||||||
|
return [
|
||||||
|
{ id: "approved", pubkey: approved, created_at: 20 },
|
||||||
|
{ id: "unapproved", pubkey: unapproved, created_at: 30 },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = { whitelistedAuthors: [approved.toUpperCase()] };
|
||||||
|
const first = await source({ runtime });
|
||||||
|
const rerank = await source({ runtime });
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [[approved]], "relay lookup is author-scoped once");
|
||||||
|
assert.deepEqual(first.map((entry) => entry.video.id), ["approved"]);
|
||||||
|
assert.deepEqual(rerank.map((entry) => entry.video.id), ["approved"]);
|
||||||
|
|
||||||
|
now += 2 * 60 * 1000;
|
||||||
|
await source({ runtime });
|
||||||
|
assert.equal(calls.length, 2, "catalog refreshes after its bounded TTL");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Most Zapped discovery is empty without approved creators and never queries globally", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const source = createWhitelistedAuthorsNostrSource({
|
||||||
|
service: {
|
||||||
|
async fetchVideosByAuthors() {
|
||||||
|
calls += 1;
|
||||||
|
return [{ id: "must-not-leak", pubkey: "c".repeat(64) }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await source({ runtime: { whitelistedAuthors: [] } });
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Most Zapped never leaks a removed creator while an earlier lookup is in flight", async () => {
|
||||||
|
const removed = "d".repeat(64);
|
||||||
|
const remaining = "e".repeat(64);
|
||||||
|
let resolveFetch;
|
||||||
|
const source = createWhitelistedAuthorsNostrSource({
|
||||||
|
service: {
|
||||||
|
fetchVideosByAuthors() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = source({ runtime: { whitelistedAuthors: [removed] } });
|
||||||
|
const afterRemoval = source({ runtime: { whitelistedAuthors: [remaining] } });
|
||||||
|
resolveFetch([{ id: "removed-video", pubkey: removed }]);
|
||||||
|
|
||||||
|
assert.deepEqual((await first).map((entry) => entry.video.id), ["removed-video"]);
|
||||||
|
assert.deepEqual(await afterRemoval, []);
|
||||||
|
});
|
||||||
@@ -615,7 +615,7 @@ toward freshness and looked identical. Gave each a structural identity:
|
|||||||
handled).
|
handled).
|
||||||
|
|
||||||
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
|
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
|
||||||
- [ ] **NEXT (planned, not started): bitvid-native zap tally.** Full spec +
|
- [x] **DONE 2026-07-26: bitvid-native zap tally.** Full spec +
|
||||||
ordered task checklist in `docs/zap-tally-plan.md`. Problem: NIP-57 zap
|
ordered task checklist in `docs/zap-tally-plan.md`. Problem: NIP-57 zap
|
||||||
receipts (9735) are published by the RECIPIENT's LNURL server, and
|
receipts (9735) are published by the RECIPIENT's LNURL server, and
|
||||||
custodial wallets (Strike) don't — so paid zaps leave no on-relay record
|
custodial wallets (Strike) don't — so paid zaps leave no on-relay record
|
||||||
@@ -624,12 +624,13 @@ toward freshness and looked identical. Gave each a structural identity:
|
|||||||
the preimage→payment_hash + description_hash→zapRequest binding, reusing
|
the preimage→payment_hash + description_hash→zapRequest binding, reusing
|
||||||
the existing bolt11 decode in `zapReceiptValidator.js`), counted alongside
|
the existing bolt11 decode in `zapReceiptValidator.js`), counted alongside
|
||||||
real 9735s (deduped by payment_hash, 9735-preferred). Behind
|
real 9735s (deduped by payment_hash, 9735-preferred). Behind
|
||||||
`FEATURE_ZAP_TALLY` (default off). Plan also covers a
|
`FEATURE_ZAP_TALLY` (default on). Plan also covers a
|
||||||
SECOND orange zaps-over-time line on the per-video Popularity chart with a
|
SECOND orange zaps-over-time line on the per-video Popularity chart with a
|
||||||
legend (red=Views/orange=Zaps) and visible date labels (docs §5.9). See the
|
legend (red=Views/orange=Zaps) and visible date labels (docs §5.9). See the
|
||||||
plan for the file-by-file changes. Current state (commit `cbc62306`): 9735 counting + optimistic
|
plan for the file-by-file changes. Current state: kind-9735 counting,
|
||||||
bump + durable local ledger (`bitvid:sentZaps:v1`) already shipped; the
|
optimistic bump + durable local ledger (`bitvid:sentZaps:v1`), and the
|
||||||
tally is the missing global/cross-user source.
|
verified tally source are shipped; receipts and tallies are deduped by
|
||||||
|
payment hash.
|
||||||
- [x] **DONE 2026-07-03.** Full Trending-pattern clone ranked by SATS:
|
- [x] **DONE 2026-07-03.** Full Trending-pattern clone ranked by SATS:
|
||||||
- `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's
|
- `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's
|
||||||
getZapTotal reads the cached total AND schedules a BATCHED one-shot
|
getZapTotal reads the cached total AND schedules a BATCHED one-shot
|
||||||
@@ -647,18 +648,26 @@ toward freshness and looked identical. Gave each a structural identity:
|
|||||||
link, viewManager registry, feedCoordinator case/loader,
|
link, viewManager registry, feedCoordinator case/loader,
|
||||||
`FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default
|
`FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default
|
||||||
on; off hides the tab like Trending's flag).
|
on; off hides the tab like Trending's flag).
|
||||||
|
- **Discovery hardened 2026-07-26:** fetches the bounded catalog for
|
||||||
|
creator-whitelist pubkeys only (never an unscoped relay query), then
|
||||||
|
ranks those videos by zaps. The normal blacklist and Web-of-Trust
|
||||||
|
stages still run; whitelist membership is not a moderation override.
|
||||||
|
The catalog is briefly cached so zap-total re-ranks do not repeatedly
|
||||||
|
scan relays, and an in-flight result is re-filtered after whitelist
|
||||||
|
changes so removed creators cannot briefly appear.
|
||||||
Tests: `tests/zap-totals.test.mjs` (5). Lint + build green.
|
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
|
- [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),
|
the zap total in bitcoin ORANGE (new `--color-zap` token, both themes),
|
||||||
RIGHT-ALIGNED in the engagement row (views/comments stay left; CSS
|
RIGHT-ALIGNED in the engagement row (views/comments stay left; CSS
|
||||||
`margin-inline-start: auto` on `.video-card__zaps`). Hidden until a
|
`margin-inline-start: auto` on `.video-card__zaps`). The badge is visible
|
||||||
nonzero total is known (no "0 sats" noise). Binder
|
from bind (showing `0 sats` until receipts arrive). Binder
|
||||||
(`js/ui/views/videoCardZapTotals.js`, VideoListView file-size budget)
|
(`js/ui/views/videoCardZapTotals.js`, VideoListView file-size budget)
|
||||||
fills `[data-zap-total]` from the shared zapTotals cache — bind() also
|
fills `[data-zap-total]` from the shared zapTotals cache — bind() also
|
||||||
schedules the batched receipt fetch, and one change listener updates
|
schedules the batched receipt fetch, and one change listener updates
|
||||||
every bound card as batches land. Test:
|
every bound card as batches land. Test:
|
||||||
`tests/video-card-zap-totals.test.mjs` (drives the REAL store).
|
`tests/video-card-zap-totals.test.mjs` (drives the REAL store).
|
||||||
- [ ] **VERIFY on unstable:** open Most Zapped → zapped videos rise as receipts
|
- [ ] **VERIFY on unstable:** open Most Zapped → only whitelisted creators are
|
||||||
|
discovered; zapped videos rise as receipts
|
||||||
load (unzapped feed reads as Recent at first) and their cards show the
|
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
|
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
|
video → within ~2min of tab re-entry its rank + badge reflect the new
|
||||||
|
|||||||
Reference in new issue
Block a user