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 ce14004725)
This commit is contained in:
thePR0M3TH3AN committed 2026-07-26 12:33:57 -04:00
1 parent d8e5113efd
commit f0a3ede3e5
7 files changed
+241 -23

No files matched your search

+23 -1
View File
@@ -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;
}
});
+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, []);
});