From 7739a934f9f84598996e8a0b145fa8f908b0dc59 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:18:09 -0400 Subject: [PATCH 1/3] docs: update zap progress tracking --- todo/TODO_2026-06-20_pre-launch.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 6bc13a77..25e4a6ae 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 @@ -651,8 +652,8 @@ toward freshness and looked identical. Gave each a structural identity: - [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 From 2cda5025d41da7153f09f4b51d432d98f035be1c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:24:49 -0400 Subject: [PATCH 2/3] fix(zaps): scope discovery to approved creators --- 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 | 10 ++- 7 files changed, 233 insertions(+), 16 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 25e4a6ae..d0154955 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -648,6 +648,13 @@ 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), @@ -659,7 +666,8 @@ toward freshness and looked identical. Gave each a structural identity: 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 From 2f3028f2e4f9977365a2929dba8adb728bb6d3d0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:58:58 -0400 Subject: [PATCH 3/3] fix: keep autoplay fallback from saving mute (cherry picked from commit f373bb9ce77d7e9b5896eb55838364e5469f3878) --- js/app/authSessionCoordinator.js | 13 +++++++++++- js/app/playbackCoordinator.js | 2 +- js/services/playbackService.js | 16 ++++++++++++++- js/utils/autoplayPreference.js | 27 +++++++++++++++++++++++++ js/webtorrent.js | 3 ++- tests/app/login-defers-dms.test.mjs | 27 +++++++++++++++++++++++++ tests/services/playbackService.test.mjs | 18 +++++++++++------ 7 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 js/utils/autoplayPreference.js diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 94986672..1c50994d 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -232,7 +232,18 @@ export function createAuthSessionCoordinator(deps) { } this.applyAuthenticatedUiState(); - this.commentController?.refreshAuthState?.(); + const activeModalVideo = + typeof this.videoModal?.getCurrentVideo === "function" + ? this.videoModal.getCurrentVideo() + : this.commentController?.currentVideo || null; + if (this.commentController && activeModalVideo) { + // Login can reconfigure relay-backed services. Reload the active thread + // instead of only enabling its composer so readable comments survive + // the transition from guest to authenticated state. + this.commentController.load(activeModalVideo); + } else { + this.commentController?.refreshAuthState?.(); + } this.updateShareNostrAuthState({ reason: "auth-login" }); if (typeof this.refreshUnreadDmIndicator === "function") { void this.refreshUnreadDmIndicator({ reason: "auth-login" }); diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index 6a4e2afe..f1ccf4ed 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -237,7 +237,7 @@ export function createPlaybackCoordinator(deps) { this.log("Autoplay failed:", err); if (!this.modalVideo.muted) { this.log("Falling back to muted autoplay."); - this.modalVideo.muted = true; + this.playbackService.muteForAutoplayFallback(this.modalVideo); this.modalVideo.play().catch((err2) => { this.log("Muted autoplay also failed:", err2); }); diff --git a/js/services/playbackService.js b/js/services/playbackService.js index e6b74bc6..f9adf2de 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -1,6 +1,10 @@ import { userLogger } from "../utils/logger.js"; import { PLAYBACK_START_TIMEOUT } from "../constants.js"; import { getCurrentVideo, setCurrentVideo } from "../state/appState.js"; +import { + consumeAutoplayFallbackMute, + muteForAutoplayFallback, +} from "../utils/autoplayPreference.js"; import { SimpleEventEmitter, extractWebSeedsFromMagnet, @@ -330,11 +334,17 @@ export class PlaybackService { return; } const storedUnmuted = localStorage.getItem("unmutedAutoplay"); - const userWantsUnmuted = storedUnmuted === "true"; + // Sound is the default for an explicit watch action. Browsers may still + // reject unmuted autoplay, in which case the coordinator retries muted. + // Preserve a viewer's explicit mute choice across subsequent videos. + const userWantsUnmuted = storedUnmuted !== "false"; videoElement.muted = !userWantsUnmuted; if (!videoElement.dataset.autoplayBound) { videoElement.addEventListener("volumechange", () => { + if (consumeAutoplayFallbackMute(videoElement)) { + return; + } localStorage.setItem( "unmutedAutoplay", (!videoElement.muted).toString() @@ -344,6 +354,10 @@ export class PlaybackService { } } + muteForAutoplayFallback(videoElement) { + return muteForAutoplayFallback(videoElement); + } + /** * Registers a set of event listeners ("watchdogs") on the video element to detect * when playback has stalled or failed. diff --git a/js/utils/autoplayPreference.js b/js/utils/autoplayPreference.js new file mode 100644 index 00000000..f5cce19f --- /dev/null +++ b/js/utils/autoplayPreference.js @@ -0,0 +1,27 @@ +const FALLBACK_MUTE_DATASET_KEY = "bitvidAutoplayFallbackMute"; + +/** + * Mute playback after an unmuted autoplay rejection without recording the + * browser-policy fallback as a viewer preference. + */ +export function muteForAutoplayFallback(videoElement) { + if (!videoElement || videoElement.muted) { + return false; + } + + if (videoElement.dataset) { + videoElement.dataset[FALLBACK_MUTE_DATASET_KEY] = "true"; + } + videoElement.muted = true; + return true; +} + +/** Consume the marker attached to the next fallback-generated volume change. */ +export function consumeAutoplayFallbackMute(videoElement) { + if (videoElement?.dataset?.[FALLBACK_MUTE_DATASET_KEY] !== "true") { + return false; + } + + delete videoElement.dataset[FALLBACK_MUTE_DATASET_KEY]; + return true; +} diff --git a/js/webtorrent.js b/js/webtorrent.js index 5264177c..6c5ee499 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -30,6 +30,7 @@ import { waitForActiveController as waitForActiveControllerHelper, } from "./webtorrentHelpers.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { muteForAutoplayFallback } from "./utils/autoplayPreference.js"; import { emit } from "./embedDiagnostics.js"; const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); @@ -561,7 +562,7 @@ export class TorrentClient { return; } this.log(`Retrying with muted autoplay (${context} path).`); - videoElement.muted = true; + muteForAutoplayFallback(videoElement); videoElement.play().catch((err2) => { this.log(`Muted autoplay also failed (${context} path):`, err2); }); diff --git a/tests/app/login-defers-dms.test.mjs b/tests/app/login-defers-dms.test.mjs index 6f37003d..68620d15 100644 --- a/tests/app/login-defers-dms.test.mjs +++ b/tests/app/login-defers-dms.test.mjs @@ -151,3 +151,30 @@ test("login does not eagerly load DM history; it is deferred", async () => { "the lightweight unread DM indicator should still run so the badge stays accurate", ); }); + +test("login reloads the open video comment thread", async () => { + globalThis.window.location = { search: "", href: "http://localhost/" }; + + const coord = makeCoordinator({ + loadDirectMessages: async () => ({ ok: true }), + refreshUnreadDmIndicator: async () => {}, + }); + const activeVideo = { id: "b".repeat(64), title: "Open video" }; + const loadedVideos = []; + coord.videoModal = { getCurrentVideo: () => activeVideo }; + coord.commentController = { + load: (video) => loadedVideos.push(video), + refreshAuthState: () => { + throw new Error("an open thread should be reloaded, not only refreshed"); + }, + }; + + await coord.handleAuthLogin({ + pubkey: ACTIVE_PUBKEY, + identityChanged: true, + postLogin: { profile: { name: "tester" } }, + postLoginPromise: Promise.resolve({ profile: { name: "tester" } }), + }); + + assert.deepEqual(loadedVideos, [activeVideo]); +}); diff --git a/tests/services/playbackService.test.mjs b/tests/services/playbackService.test.mjs index 115ce348..a6dcf57d 100644 --- a/tests/services/playbackService.test.mjs +++ b/tests/services/playbackService.test.mjs @@ -44,21 +44,27 @@ describe("PlaybackService", () => { const service = new PlaybackService(); const video = document.createElement("video"); - // Default: muted (unless localStorage says unmuted) + // Default: sound on unless the viewer explicitly chose mute. localStorage.removeItem("unmutedAutoplay"); service.prepareVideoElement(video); - assert.equal(video.muted, true); + assert.equal(video.muted, false); assert.equal(video.dataset.autoplayBound, "true"); - // Simulate volume change - video.muted = false; + // Simulate an explicit mute choice. + video.muted = true; video.dispatchEvent(new window.Event("volumechange")); - assert.equal(localStorage.getItem("unmutedAutoplay"), "true"); + assert.equal(localStorage.getItem("unmutedAutoplay"), "false"); // Test respecting stored value const video2 = document.createElement("video"); service.prepareVideoElement(video2); - assert.equal(video2.muted, false); + assert.equal(video2.muted, true); + + // A browser-policy fallback is temporary and must not become a saved mute. + localStorage.setItem("unmutedAutoplay", "true"); + video2.muted = false; + service.muteForAutoplayFallback(video2); + assert.equal(localStorage.getItem("unmutedAutoplay"), "true"); }); test("registerUrlPlaybackWatchdogs triggers onFallback on error", () => {