From f0a3ede3e5656bfd189caadaeec05564f93bf524 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:23:05 -0400 Subject: [PATCH] Preserve autoplay preference on fallback; reload open comment thread after login ### Motivation - Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient. - Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured. ### Description - Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes. - Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util. - Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient. - Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`. - Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread. ### Testing - Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed. - Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed. (cherry picked from commit ce14004725bad67b6b534e6a2d94852c1300e231) --- js/feedEngine/index.js | 1 + js/feedEngine/mostZappedFeed.js | 25 +++++-- js/feedEngine/sources.js | 83 +++++++++++++++++++++++ js/ui/bitloginModalIntegration.js | 33 ++++++--- tests/bitlogin-modal-integration.test.mjs | 24 ++++++- tests/most-zapped-source.test.mjs | 73 ++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 25 ++++--- 7 files changed, 241 insertions(+), 23 deletions(-) create mode 100644 tests/most-zapped-source.test.mjs diff --git a/js/feedEngine/index.js b/js/feedEngine/index.js index ec4b91fc..4ee83b51 100644 --- a/js/feedEngine/index.js +++ b/js/feedEngine/index.js @@ -21,6 +21,7 @@ export { } from "./sorters.js"; export { createActiveNostrSource, + createWhitelistedAuthorsNostrSource, createSubscriptionAuthorsSource, createWatchHistoryPointerSource, } from "./sources.js"; diff --git a/js/feedEngine/mostZappedFeed.js b/js/feedEngine/mostZappedFeed.js index 7fcde6ec..ae9169a8 100644 --- a/js/feedEngine/mostZappedFeed.js +++ b/js/feedEngine/mostZappedFeed.js @@ -1,8 +1,9 @@ // js/feedEngine/mostZappedFeed.js // -// The "Most Zapped" tab (#47): the recently-added (active) source ranked by -// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives -// outside feedCoordinator.js (size cap) and is wired in via thin delegations. +// The "Most Zapped" tab (#47): the catalog of approved creators ranked by +// ZAP TOTAL (sats from kind-9735 receipts). It is intentionally scoped to the +// 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 // 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 @@ -11,7 +12,7 @@ import { FEED_TYPES } from "../constants.js"; import { requestVideoZapTotal } from "../zapTotals.js"; import { - createActiveNostrSource, + createWhitelistedAuthorsNostrSource, createBlacklistFilterStage, createDedupeByRootStage, 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) { const blacklist = app.blacklistedEventIds instanceof Set @@ -59,6 +73,7 @@ export function buildMostZappedFeedRuntime(app) { ? { ...moderationThresholds } : undefined, getZapTotal: makeGetZapTotal(app), + whitelistedAuthors: getWhitelistedAuthors(app), }; } @@ -94,7 +109,7 @@ export function registerMostZappedFeed(app) { try { return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, { - source: createActiveNostrSource({ service: app.nostrService }), + source: createWhitelistedAuthorsNostrSource({ service: app.nostrService }), stages: [ createBlacklistFilterStage({ shouldIncludeVideo: (video, options) => diff --git a/js/feedEngine/sources.js b/js/feedEngine/sources.js index bf7bf2a3..2b086741 100644 --- a/js/feedEngine/sources.js +++ b/js/feedEngine/sources.js @@ -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 } = {}) { const resolvedService = resolveService(service, nostrService); diff --git a/js/ui/bitloginModalIntegration.js b/js/ui/bitloginModalIntegration.js index eec967eb..a909ca1d 100644 --- a/js/ui/bitloginModalIntegration.js +++ b/js/ui/bitloginModalIntegration.js @@ -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 // element (e.g. a future bootstrap change that calls it more than once): the // same 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) -- // leave the permanent, statically-declared markup in place but never load // the vendored bundle or show the (otherwise-empty, unupgraded) element. - if (mount instanceof HTMLElement) { + if (isHtmlElement(mount)) { mount.hidden = true; } return; } - if (mount instanceof HTMLElement) { + if (isHtmlElement(mount)) { mount.hidden = false; } @@ -162,22 +170,29 @@ function attachWidget(app, widget) { * @param {{ authService: { requestLogin: Function } }} app */ export function wireBitloginLogin(app) { - if (!app?.authService) { + if (!app?.authService || typeof document === "undefined") { return; } - const existing = document.getElementById("bitloginWidget"); - if (existing instanceof HTMLElement) { + const documentRef = document; + const existing = documentRef.getElementById("bitloginWidget"); + if (isHtmlElement(existing, documentRef)) { attachWidget(app, existing); return; } - const observer = new MutationObserver(() => { - const widget = document.getElementById("bitloginWidget"); - if (widget instanceof HTMLElement) { + const Observer = + documentRef.defaultView?.MutationObserver || globalThis.MutationObserver; + if (typeof Observer !== "function" || !documentRef.body) { + return; + } + + const observer = new Observer(() => { + const widget = documentRef.getElementById("bitloginWidget"); + if (isHtmlElement(widget, documentRef)) { observer.disconnect(); attachWidget(app, widget); } }); - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(documentRef.body, { childList: true, subtree: true }); } diff --git a/tests/bitlogin-modal-integration.test.mjs b/tests/bitlogin-modal-integration.test.mjs index 3e930937..32f5b2a2 100644 --- a/tests/bitlogin-modal-integration.test.mjs +++ b/tests/bitlogin-modal-integration.test.mjs @@ -1,7 +1,10 @@ import test from "node:test"; 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); @@ -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({ 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; + } +}); diff --git a/tests/most-zapped-source.test.mjs b/tests/most-zapped-source.test.mjs new file mode 100644 index 00000000..d071083e --- /dev/null +++ b/tests/most-zapped-source.test.mjs @@ -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, []); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 6bc13a77..d0154955 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -615,7 +615,7 @@ toward freshness and looked identical. Gave each a structural identity: handled). ### 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 receipts (9735) are published by the RECIPIENT's LNURL server, and 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 existing bolt11 decode in `zapReceiptValidator.js`), counted alongside 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 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 - bump + durable local ledger (`bitvid:sentZaps:v1`) already shipped; the - tally is the missing global/cross-user source. + plan for the file-by-file changes. Current state: kind-9735 counting, + optimistic bump + durable local ledger (`bitvid:sentZaps:v1`), and the + 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: - `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's 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, `FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default 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. - [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 + `margin-inline-start: auto` on `.video-card__zaps`). The badge is visible + from bind (showing `0 sats` until receipts arrive). 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 +- [ ] **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 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