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.
This commit is contained in:
thePR0M3TH3AN committed 2026-07-26 09:23:05 -04:00
1 parent 98c623c85a
commit ce14004725
14 files changed
+336 -32

No files matched your search

+11
View File
@@ -232,7 +232,18 @@ export function createAuthSessionCoordinator(deps) {
}
this.applyAuthenticatedUiState();
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" });
+1 -1
View File
@@ -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);
});
+1
View File
@@ -21,6 +21,7 @@ export {
} from "./sorters.js";
export {
createActiveNostrSource,
createWhitelistedAuthorsNostrSource,
createSubscriptionAuthorsSource,
createWatchHistoryPointerSource,
} from "./sources.js";
+20 -5
View File
@@ -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) =>
+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 } = {}) {
const resolvedService = resolveService(service, nostrService);
+15 -1
View File
@@ -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.
+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
// 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"
@@ -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 });
}
+27
View File
@@ -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;
}
+2 -1
View File
@@ -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);
});
+27
View File
@@ -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]);
});
+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, []);
});
+12 -6
View File
@@ -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", () => {
+17 -8
View File
@@ -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