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:
thePR0M3TH3AN authored and GitHub committed 2026-07-26 09:23:35 -04:00
commit 04c8511a50
7 files changed
+241 -23

No files matched your search

+1
View File
@@ -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";
+20 -5
View File
@@ -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) =>
+83
View File
@@ -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);
+24 -9
View File
@@ -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 });
} }
+23 -1
View File
@@ -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;
}
});
+73
View File
@@ -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, []);
});
+17 -8
View File
@@ -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