mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
### 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)
199 lines
7.6 KiB
JavaScript
199 lines
7.6 KiB
JavaScript
// js/ui/bitloginModalIntegration.js
|
|
//
|
|
// Wires the permanent <bitlogin-auth id="bitloginWidget"> element -- declared
|
|
// once, statically, in components/login-modal.html, never written by an
|
|
// innerHTML re-render -- into the exact same session-establishment path every
|
|
// other sign-in method uses (AuthService.requestLogin -> handleAuthLogin).
|
|
// BitLogin isn't a single button-click-then-await-a-promise flow like the
|
|
// other providers, it's a whole multi-screen sign-in/create/recover UI, so it
|
|
// gets its own widget in the modal instead of a grid button (see
|
|
// providersForModal in js/services/authProviders/index.js) and its own small
|
|
// integration module here rather than special-casing LoginModalController.
|
|
import { devLogger, userLogger } from "../utils/logger.js";
|
|
import { closeStaticModal } from "./components/staticModalAccessibility.js";
|
|
import { setModalState as setGlobalModalState } from "../state/appState.js";
|
|
import { createBitloginAdapter } from "../nostr/adapters/bitloginAdapter.js";
|
|
import { setPendingBitloginResult } from "../services/authProviders/bitlogin.js";
|
|
import bitloginProvider from "../services/authProviders/bitlogin.js";
|
|
import { FEATURE_BITLOGIN } from "../constants.js";
|
|
|
|
// Vendored, pinned BitLogin widget bundle (scripts/build-bitlogin-widget.mjs).
|
|
// Lazy-imported so its ~350KB never weighs on the main bundle for visitors who
|
|
// never open the login modal, mirroring the existing Bitcoin Connect vendor
|
|
// step (js/ui/profileModal/ProfileWalletController.js). A plain dynamic
|
|
// import is enough: the bundle's only job is the customElements.define("bitlogin-auth")
|
|
// side effect, which retroactively upgrades the already-present <bitlogin-auth>
|
|
// tag in the modal's static HTML once it resolves.
|
|
const BITLOGIN_WIDGET_BUNDLE_URL = "../../vendor/bitlogin/bitlogin.js";
|
|
const PUBKEY_PATTERN = /^[0-9a-f]{64}$/i;
|
|
|
|
let widgetLoadPromise = null;
|
|
|
|
function loadBitloginWidgetOnce() {
|
|
if (!widgetLoadPromise) {
|
|
widgetLoadPromise = import(BITLOGIN_WIDGET_BUNDLE_URL).catch((error) => {
|
|
widgetLoadPromise = null;
|
|
throw error;
|
|
});
|
|
}
|
|
return widgetLoadPromise;
|
|
}
|
|
|
|
function closeLoginModal() {
|
|
const modal = document.getElementById("loginModal");
|
|
if (modal && closeStaticModal(modal)) {
|
|
setGlobalModalState("login", false);
|
|
}
|
|
}
|
|
|
|
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"
|
|
// listener, since two would each race the shared, single-read `pendingResult`
|
|
// and each independently call requestLogin() for one real sign-in.
|
|
const wiredWidgets = new WeakSet();
|
|
|
|
/**
|
|
* A CustomEvent's detail is not an authentication boundary: any same-origin
|
|
* script can dispatch one. Re-read the key from the widget's crypto worker and
|
|
* require it to match before BitVid adopts the identity or its signer.
|
|
*/
|
|
export async function resolveVerifiedBitloginPubkey(widget, claimedPubkey) {
|
|
const claimed = typeof claimedPubkey === "string" ? claimedPubkey.trim() : "";
|
|
if (!PUBKEY_PATTERN.test(claimed) || typeof widget?.getPublicKey !== "function") {
|
|
return "";
|
|
}
|
|
try {
|
|
const actual = await widget.getPublicKey();
|
|
const normalizedActual = typeof actual === "string" ? actual.trim() : "";
|
|
if (!PUBKEY_PATTERN.test(normalizedActual) || normalizedActual.toLowerCase() !== claimed.toLowerCase()) {
|
|
return "";
|
|
}
|
|
return normalizedActual.toLowerCase();
|
|
} catch (error) {
|
|
devLogger.warn("[BitLogin] Unable to verify the widget signer:", error);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function attachWidget(app, widget) {
|
|
const mount = document.getElementById("bitloginMount");
|
|
if (!FEATURE_BITLOGIN) {
|
|
// 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 (isHtmlElement(mount)) {
|
|
mount.hidden = true;
|
|
}
|
|
return;
|
|
}
|
|
if (isHtmlElement(mount)) {
|
|
mount.hidden = false;
|
|
}
|
|
|
|
if (wiredWidgets.has(widget)) {
|
|
return;
|
|
}
|
|
wiredWidgets.add(widget);
|
|
|
|
loadBitloginWidgetOnce().catch((error) => {
|
|
devLogger.warn("[BitLogin] Failed to load the widget bundle:", error);
|
|
});
|
|
|
|
widget.addEventListener("bitlogin-login", async (event) => {
|
|
const claimedPubkey =
|
|
event?.detail && typeof event.detail.publicKey === "string"
|
|
? event.detail.publicKey.trim()
|
|
: "";
|
|
const pubkey = await resolveVerifiedBitloginPubkey(widget, claimedPubkey);
|
|
if (!pubkey) {
|
|
userLogger.warn("[BitLogin] Ignored an unverifiable login event.");
|
|
return;
|
|
}
|
|
|
|
setPendingBitloginResult({
|
|
pubkey,
|
|
signer: createBitloginAdapter(widget, pubkey),
|
|
});
|
|
|
|
try {
|
|
await app.authService.requestLogin({ providerId: "bitlogin" });
|
|
closeLoginModal();
|
|
} catch (error) {
|
|
// bitvid rejecting the sign-in (e.g. the invite-only access-control
|
|
// check) leaves the widget itself still showing its own "Signed in"
|
|
// screen -- BitLogin succeeded on its own terms, bitvid just didn't
|
|
// accept the identity. Reset the widget and surface the real reason
|
|
// through the same error-banner path every other sign-in method uses
|
|
// (devLogger.warn is a silent no-op outside dev mode -- see
|
|
// IS_DEV_MODE in config/instance-config.js -- so relying on it here
|
|
// meant a rejected user saw no feedback at all).
|
|
userLogger.warn("[BitLogin] Sign-in failed to apply:", error);
|
|
const maybePromise = app?.handleLoginModalError?.({
|
|
error,
|
|
provider: bitloginProvider,
|
|
});
|
|
if (maybePromise && typeof maybePromise.then === "function") {
|
|
maybePromise.catch((handlerError) => {
|
|
devLogger.warn(
|
|
"[BitLogin] handleLoginModalError threw:",
|
|
handlerError,
|
|
);
|
|
});
|
|
}
|
|
try {
|
|
await widget.logout();
|
|
} catch (logoutError) {
|
|
devLogger.warn(
|
|
"[BitLogin] Failed to reset widget after a rejected sign-in:",
|
|
logoutError,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Call once during app bootstrap, after `app.authService` exists. The login
|
|
* modal's HTML (including the permanent #bitloginWidget element) loads
|
|
* asynchronously (js/index.js bootstrapInterface()) and isn't guaranteed to be
|
|
* in the DOM yet at this point, so this waits for it rather than requiring a
|
|
* specific call order between the two.
|
|
* @param {{ authService: { requestLogin: Function } }} app
|
|
*/
|
|
export function wireBitloginLogin(app) {
|
|
if (!app?.authService || typeof document === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const documentRef = document;
|
|
const existing = documentRef.getElementById("bitloginWidget");
|
|
if (isHtmlElement(existing, documentRef)) {
|
|
attachWidget(app, existing);
|
|
return;
|
|
}
|
|
|
|
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(documentRef.body, { childList: true, subtree: true });
|
|
}
|