Files
bitvid/js/app/authSessionCoordinator.js
T
thePR0M3TH3ANandClaude Opus 4.8 512b6c3f6a feat(feed): warm watch history in background after login
The For You feed's watch-history suppression and tag-affinity previously
loaded only when the For You / History view was first opened
(feedCoordinator loadLatest({allowStale:true})), so on a cold login the
feed first rendered without suppression and then re-ran ~1-3s later on the
watch-history "fingerprint" event. Watch history was the one feed-driving
list not eagerly warmed in the background on login.

It was deliberately deferred because a cold load used to be ~150+
serialized nip-07 decrypts that starved the single-threaded signer during
the fragile post-login handshake. That cost has since been bounded
(format-routing + newest-first chunk cap + deferred backfill), so the
authSessionCoordinator.js comment's condition for re-enabling an eager
preload is now met.

Kick a fire-and-forget watch-history preload AFTER the blocks/subscriptions/
hashtags lists finish decrypting (never concurrently — same reasoning as the
DM deferral). loadLatest({allowStale:true}) returns immediately and runs the
chunk-capped refresh in the background, so the For You feed's suppression is
warm by the time the user reaches it instead of waiting on the tab.

Extracted as schedulePostLoginWatchHistoryPreload() so the contract is
unit-tested in isolation (correct pubkey + allowStale, pubkey guard,
fire-and-forget / non-blocking, swallows a failed refresh). Build + full
unit suite green; mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 08:04:54 -04:00

1840 lines
59 KiB
JavaScript

// js/app/authSessionCoordinator.js
/**
* Signer, profile, and session transition orchestration.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js";
import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js";
import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js";
import { FEED_TYPES } from "../constants.js";
/**
* Kick a background watch-history refresh after login, once the feed-driving
* lists have already finished decrypting.
*
* `loadLatest({ allowStale: true })` returns immediately and runs the
* chunk-capped refresh in the background, so this is strictly fire-and-forget:
* it must never block, await, or throw into the login path. The For You feed
* re-runs on the watch-history "fingerprint" event once the refresh resolves.
*
* @param {object} args
* @param {{ loadLatest?: Function }} [args.watchHistoryService]
* @param {string} [args.activePubkey]
* @param {{ warn?: Function }} [args.devLogger]
* @returns {boolean} Whether a preload was scheduled.
*/
export function schedulePostLoginWatchHistoryPreload({
watchHistoryService,
activePubkey,
devLogger,
} = {}) {
if (!activePubkey || typeof watchHistoryService?.loadLatest !== "function") {
return false;
}
Promise.resolve()
.then(() =>
watchHistoryService.loadLatest(activePubkey, { allowStale: true }),
)
.catch((error) => {
devLogger?.warn?.(
"[Application] Background watch-history preload after login failed:",
error,
);
});
return true;
}
/**
* @param {object} deps - Injected dependencies.
* @returns {object} Methods to be bound to the Application instance.
*/
export function createAuthSessionCoordinator(deps) {
const {
devLogger,
userLogger,
nostrClient,
accessControl,
userBlocks,
subscriptions,
hashtagPreferences,
watchHistoryService,
storageService,
relayManager,
torrentClient,
getHashViewName,
setHashView,
DEFAULT_NIP07_PERMISSION_METHODS,
RELAY_UI_BATCH_DELAY_MS,
sanitizeRelayList,
buildDmRelayListEvent,
publishEventToRelays,
assertAnyRelayAccepted,
queueSignEvent,
bootstrapTrustedSeeds,
getModerationSettings,
getActiveProfilePubkey,
} = deps;
const now =
typeof performance !== "undefined" && typeof performance.now === "function"
? () => performance.now()
: () => Date.now();
const SIGNER_GATE_STATUS = Object.freeze({
READY: "signer-ready",
EXTENSION_UNAVAILABLE: "extension-unavailable",
PERMISSION_DENIED: "permission-denied",
});
const classifySignerGateError = (error) => {
const code =
typeof error?.code === "string" && error.code.trim()
? error.code.trim().toLowerCase()
: "";
const message =
typeof error?.message === "string" && error.message.trim()
? error.message.trim().toLowerCase()
: "";
if (
code.includes("permission") ||
message.includes("permission") ||
message.includes("denied")
) {
return SIGNER_GATE_STATUS.PERMISSION_DENIED;
}
return SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE;
};
const evaluateSignerReadinessGate = async (pubkey) => {
if (!pubkey) {
return {
ready: false,
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
error: null,
};
}
try {
const signer = await nostrClient.ensureActiveSignerForPubkey(pubkey);
if (!signer) {
return {
ready: false,
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
error: null,
};
}
if (
signer?.type === "extension" ||
signer?.type === "nip07"
) {
const permissionResult = await nostrClient.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult?.ok) {
const error =
permissionResult?.error instanceof Error
? permissionResult.error
: new Error("permission-denied");
return {
ready: false,
status: classifySignerGateError(error),
error,
};
}
}
return {
ready: true,
status: SIGNER_GATE_STATUS.READY,
error: null,
};
} catch (error) {
return {
ready: false,
status: classifySignerGateError(error),
error,
};
}
};
return {
async handleAuthLogin(detail = {}) {
const authLoginStart = now();
userLogger.info("[auth-login-start]", {
pubkey: detail?.pubkey || this.pubkey || null,
});
const postLoginPromise =
detail && typeof detail.postLoginPromise?.then === "function"
? detail.postLoginPromise
: Promise.resolve(detail?.postLogin ?? null);
const postLoginResult = detail?.postLogin ?? null;
// relaysReadyPromise resolves as soon as the user's relay list is loaded,
// before profile fetching completes. Lists can start loading immediately
// after relays are available — they don't depend on profile data.
const relaysReadyPromise =
detail && typeof detail.relaysReadyPromise?.then === "function"
? detail.relaysReadyPromise
: postLoginPromise;
if (detail && typeof detail === "object") {
try {
detail.__handled = true;
} catch (error) {
// Ignore attempts to mutate read-only descriptors.
}
}
if (detail?.identityChanged) {
this.resetViewLoggingState();
}
this.resetPermissionPromptState();
// Stop existing feed subscription to prioritize user data sync
if (
this.videoSubscription &&
typeof this.videoSubscription.unsub === "function"
) {
this.videoSubscription.unsub();
this.videoSubscription = null;
}
this.applyAuthenticatedUiState();
this.commentController?.refreshAuthState?.();
this.updateShareNostrAuthState({ reason: "auth-login" });
if (typeof this.refreshUnreadDmIndicator === "function") {
void this.refreshUnreadDmIndicator({ reason: "auth-login" });
}
const currentView = getHashViewName();
const normalizedView =
typeof currentView === "string" ? currentView.toLowerCase() : "";
const urlParams = new URLSearchParams(window.location.search);
const hasVideoParam = urlParams.has("v");
const rawProviderId =
typeof detail?.providerId === "string" ? detail.providerId.trim() : "";
const rawAuthType =
typeof detail?.authType === "string" ? detail.authType.trim() : "";
const normalizedProvider =
(rawProviderId || rawAuthType).toLowerCase() || "";
this.maybeShowExperimentalLoginWarning(normalizedProvider);
const loginContext = {
pubkey: detail?.pubkey || this.pubkey,
previousPubkey: detail?.previousPubkey,
identityChanged: Boolean(detail?.identityChanged),
};
const activePubkey = detail?.pubkey || this.pubkey;
const initialLoadingState = {
profile: activePubkey ? "loading" : "idle",
lists: activePubkey ? "loading" : "idle",
dms: activePubkey ? "loading" : "idle",
};
this.updateAuthLoadingState(initialLoadingState);
this.dispatchAuthChange({
status: "login",
loggedIn: true,
pubkey: loginContext.pubkey || null,
previousPubkey: loginContext.previousPubkey || null,
authLoadingState: this.authLoadingState,
});
const cachedProfile =
detail?.postLogin && typeof detail.postLogin === "object"
? detail.postLogin.profile || null
: null;
if (activePubkey && cachedProfile) {
try {
this.updateActiveProfileUI(activePubkey, cachedProfile);
} catch (error) {
devLogger.warn(
"[Application] Failed to apply cached profile during login:",
error,
);
}
}
if (this.profileController) {
try {
const maybePromise = this.profileController.handleAuthLogin(detail);
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.catch((error) => {
devLogger.error(
"Failed to process login within the profile controller:",
error,
);
});
}
} catch (error) {
devLogger.error(
"Failed to process login within the profile controller:",
error,
);
}
} else {
this.renderSavedProfiles();
}
const accessControlReadyPromise =
accessControl && typeof accessControl.ensureReady === "function"
? Promise.resolve()
.then(() => accessControl.ensureReady())
.catch((error) => {
userLogger.error(
"[Application] Failed to refresh admin lists after login:",
error,
);
throw error;
})
: null;
const profileStatePromise = Promise.resolve(postLoginPromise)
.then(async (postLogin) => {
// 1. Relays and Profile are now loaded (sequentially or efficiently by authService)
const nextProfile = postLogin?.profile || cachedProfile;
if (activePubkey && nextProfile) {
this.updateActiveProfileUI(activePubkey, nextProfile);
}
this.forceRefreshAllProfiles();
this.updateAuthLoadingState({
profile: nextProfile ? "ready" : "error",
});
return postLogin;
})
.catch((error) => {
devLogger.error("Post-login hydration failed:", error);
this.updateAuthLoadingState({ profile: "error" });
return null;
});
// Shared signer readiness gate used by both auto-login and manual NIP-07
// login sync orchestration before decrypt-dependent list fetches begin.
const signerReadinessPromise = activePubkey
? Promise.resolve()
.then(async () => {
const signerGateStart = now();
// PERF: Evaluate signer readiness once. The previous double-gate
// evaluation added 5-10s to the login path by repeating the same
// extension wait + permission request. A single evaluation is
// sufficient because ensureActiveSignerForPubkey already waits
// for extension injection internally.
const outcome = await evaluateSignerReadinessGate(activePubkey);
const durationMs = Math.max(
0,
Math.round(now() - signerGateStart),
);
const finalOutcome = {
...outcome,
recoveryAttempted: false,
durationMs,
};
userLogger.info("[signer-ready]", {
ready: finalOutcome.ready,
status: finalOutcome.status,
recoveryAttempted: finalOutcome.recoveryAttempted,
durationMs: finalOutcome.durationMs,
});
if (!finalOutcome.ready && finalOutcome.error) {
devLogger.warn(
"[Application] Signer readiness gate not ready:",
finalOutcome.error,
);
}
return finalOutcome;
})
.catch((error) => {
devLogger.warn("[Application] Signer readiness gate failed:", error);
return {
ready: false,
status: classifySignerGateError(error),
error,
recoveryAttempted: false,
durationMs: 0,
};
})
: Promise.resolve({
ready: false,
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
error: null,
recoveryAttempted: false,
durationMs: 0,
});
// Start list loading as soon as relays are ready — lists don't depend on
// profile data, only on relay URLs. This runs in parallel with profile
// fetching instead of sequentially after it.
//
// PERF: Load blocks, subscriptions, and hashtag preferences ALL in
// parallel. They have no data dependencies on each other — only the feed
// render needs all three to be settled. Loading them concurrently shaves
// 20-30 seconds off the critical login path.
//
// Lists wait for the permission pre-grant to settle before attempting
// decryption. During auto-login (page refresh), the NIP-07 extension
// may not be ready yet — without waiting, all three services would fail
// with "permission-required" and schedule 3-second retry delays, adding
// 6-10 seconds to the critical login path. By waiting for the pre-grant
// (which itself waits for the extension and requests permissions), the
// signer is guaranteed to be available when decryption starts. This does
// NOT slow down fresh logins because the pre-grant resolves immediately
// when permissions were already granted during loginWithExtension().
//
// If pre-auth fails (e.g. extension was slow to inject), allow list
// services to request permissions themselves so they don't stall waiting
// for a retry timer.
const listStatePromise = Promise.all([
relaysReadyPromise,
signerReadinessPromise,
]).then(async ([, signerGateOutcome]) => {
const listSyncStart = now();
const allowPermissionPrompt = !signerGateOutcome?.ready;
userLogger.info("[lists-sync-start]", {
pubkey: activePubkey || null,
allowPermissionPrompt,
signerStatus: signerGateOutcome?.status || null,
});
const hasValidBlocksCache = () => {
if (!activePubkey || !userBlocks) {
return false;
}
const activeCachePubkey = this.normalizeHexPubkey(userBlocks.activePubkey);
return userBlocks.loaded === true && activeCachePubkey === activePubkey;
};
const hasValidSubscriptionsCache = () => {
if (!activePubkey || !subscriptions) {
return false;
}
const activeCachePubkey = this.normalizeHexPubkey(
subscriptions.currentUserPubkey,
);
return subscriptions.loaded === true && activeCachePubkey === activePubkey;
};
const hasValidHashtagCache = () => {
if (!activePubkey || !this.hashtagPreferences) {
return false;
}
const activeCachePubkey = this.normalizeHexPubkey(
this.hashtagPreferences.activePubkey,
);
return this.hashtagPreferences.loaded === true && activeCachePubkey === activePubkey;
};
const runListTask = async (name, task, { hasValidCache }) => {
try {
const result = await task();
return {
name,
ok: Boolean(result?.ok),
error: result?.error || null,
hasCache: hasValidCache(),
};
} catch (error) {
return {
name,
ok: false,
error,
hasCache: hasValidCache(),
};
}
};
const parallelListTasks = [];
if (
activePubkey &&
typeof this.authService.loadBlocksForPubkey === "function"
) {
parallelListTasks.push(
runListTask(
"blocks",
async () => {
const loaded = await this.authService.loadBlocksForPubkey(activePubkey, {
allowPermissionPrompt,
signerReadinessGate: signerGateOutcome,
});
const ok = loaded !== false;
if (!ok) {
const error = new Error("Block list sync returned a failed status.");
error.code = "blocks-sync-failed";
return { ok: false, error };
}
return { ok: true, error: null };
},
{ hasValidCache: hasValidBlocksCache },
),
);
}
if (
activePubkey &&
subscriptions &&
typeof subscriptions.ensureLoaded === "function"
) {
parallelListTasks.push(
runListTask(
"subscriptions",
async () => {
await subscriptions.ensureLoaded(activePubkey, {
allowPermissionPrompt,
signerReadinessGate: signerGateOutcome,
});
const error = subscriptions?.lastLoadError || null;
this.capturePermissionPromptFromError(error);
return { ok: !error, error };
},
{ hasValidCache: hasValidSubscriptionsCache },
),
);
}
if (
activePubkey &&
this.hashtagPreferences &&
typeof this.hashtagPreferences.load === "function"
) {
parallelListTasks.push(
runListTask(
"hashtags",
async () => {
await this.hashtagPreferences.load(activePubkey, {
allowPermissionPrompt,
signerReadinessGate: signerGateOutcome,
});
const error = this.hashtagPreferences?.lastLoadError || null;
this.capturePermissionPromptFromError(error);
this.updateCachedHashtagPreferences();
return { ok: !error, error };
},
{ hasValidCache: hasValidHashtagCache },
),
);
}
const taskOutcomes = await Promise.all(parallelListTasks);
const requiredTaskOutcomes = taskOutcomes.filter((outcome) =>
["blocks", "subscriptions", "hashtags"].includes(outcome.name),
);
const failedRequiredOutcomes = requiredTaskOutcomes.filter(
(outcome) => !outcome.ok,
);
const ready = requiredTaskOutcomes.every(
(outcome) => outcome.ok || outcome.hasCache,
);
const degraded = failedRequiredOutcomes.length > 0;
const fatal = failedRequiredOutcomes.some((outcome) => !outcome.hasCache);
const listSyncDetail = {
ready,
degraded,
error: fatal,
retryScheduled: false,
tasks: requiredTaskOutcomes.map((outcome) => ({
name: outcome.name,
ok: outcome.ok,
fromCache: outcome.hasCache && !outcome.ok,
error: outcome.error || null,
})),
};
if (degraded && activePubkey) {
listSyncDetail.retryScheduled = true;
Promise.resolve()
.then(() =>
Promise.all([
this.authService?.loadBlocksForPubkey?.(activePubkey, {
allowPermissionPrompt: true,
signerReadinessGate: signerGateOutcome,
}),
subscriptions?.ensureLoaded?.(activePubkey, {
allowPermissionPrompt: true,
signerReadinessGate: signerGateOutcome,
}),
this.hashtagPreferences?.load?.(activePubkey, {
allowPermissionPrompt: true,
signerReadinessGate: signerGateOutcome,
}),
]),
)
.then(() => {
this.updateCachedHashtagPreferences();
const retryTasks = [
{
name: "blocks",
ok: hasValidBlocksCache(),
fromCache: false,
error: null,
},
{
name: "subscriptions",
ok:
hasValidSubscriptionsCache() &&
!subscriptions?.lastLoadError,
fromCache: false,
error: subscriptions?.lastLoadError || null,
},
{
name: "hashtags",
ok:
hasValidHashtagCache() &&
!this.hashtagPreferences?.lastLoadError,
fromCache: false,
error: this.hashtagPreferences?.lastLoadError || null,
},
];
const retryReady = retryTasks.every((task) => task.ok);
const retryDegraded = retryTasks.some((task) => !task.ok);
this.updateAuthLoadingState({
lists: retryReady ? "ready" : "degraded",
listsDetail: {
ready: retryReady,
degraded: retryDegraded,
error: retryDegraded,
retryScheduled: false,
retryCompleted: true,
tasks: retryTasks,
},
});
this.dispatchAuthChange({
status: "login-lists-sync",
loggedIn: true,
pubkey: activePubkey,
authLoadingState: this.authLoadingState,
});
})
.catch((error) => {
devLogger.warn(
"[Application] Consolidated list sync retry failed after login:",
error,
);
});
}
const listsState = ready ? (degraded ? "degraded" : "ready") : "error";
userLogger.info("[lists-sync-complete]", {
pubkey: activePubkey || null,
ready,
degraded,
fatal,
durationMs: Math.max(0, Math.round(now() - listSyncStart)),
});
this.updateAuthLoadingState({
lists: listsState,
listsDetail: listSyncDetail,
});
this.dispatchAuthChange({
status: "login-lists-sync",
loggedIn: true,
pubkey: activePubkey,
authLoadingState: this.authLoadingState,
});
// Warm the watch history in the background now that the feed-driving
// lists (blocks/subscriptions/hashtags) have finished decrypting. This
// runs AFTER the required lists — never concurrently — so it does not
// re-introduce the signer starvation that previously made this an
// intentionally-deferred load. The cold load is now bounded to a few
// decrypts + deferred backfill (not the old ~150 serialized decrypts),
// so the For You feed's suppression and tag-affinity are ready by the
// time the user reaches it instead of waiting until the For You /
// History view is opened. Fire-and-forget: the feed re-runs on the
// watch-history "fingerprint" event.
schedulePostLoginWatchHistoryPreload({
watchHistoryService,
activePubkey,
devLogger,
});
return listSyncDetail;
});
// DMs are intentionally NOT eagerly loaded at login. A cold DM load
// (limit: 50) is a burst of nip-07 decrypts that competes with the
// feed-driving lists (blocks/subscriptions/hashtags) for the SAME
// single-threaded extension channel during the fragile post-login
// handshake window — under load that channel drops ("message channel
// closed") and every subsequent decrypt times out until a page refresh.
// DMs are lower priority (the user isn't viewing Messages at login) and
// already load lazily when the Messages tab is opened
// (ProfileDirectMessageActions.populateProfileMessages). The lightweight
// unread indicator (refreshUnreadDmIndicator, above) still runs so the
// badge is accurate without decrypting the full conversation history.
const dmStatePromise = profileStatePromise.then(() => {
this.updateAuthLoadingState({ dms: "deferred" });
return Promise.resolve();
});
const nwcPromise = profileStatePromise.then(() => {
if (
activePubkey &&
this.nwcSettingsService &&
typeof this.nwcSettingsService.hydrateNwcSettingsForPubkey === "function"
) {
return this.nwcSettingsService
.hydrateNwcSettingsForPubkey(activePubkey)
.catch((error) => {
devLogger.warn(
"[Application] Failed to hydrate NWC settings during login:",
error,
);
});
}
return Promise.resolve();
});
// Ensure DMs are tracked for cleanup.
void dmStatePromise;
void nwcPromise;
if (activePubkey) {
const seedBlacklist = () => {
const aggregatedBlacklist = accessControl.getBlacklist();
return userBlocks.seedWithNpubs(
activePubkey,
Array.isArray(aggregatedBlacklist) ? aggregatedBlacklist : [],
);
};
// Background the seeding process because it involves publishing events,
// which can block the login flow significantly.
Promise.resolve(accessControlReadyPromise)
.catch(() => null)
.then(() => seedBlacklist())
.catch((error) => {
if (
error?.code === "extension-permission-denied" ||
error?.code === "nip04-missing" ||
error?.name === "RelayPublishError"
) {
userLogger.error(
"[Application] Failed to seed shared block list after login:",
error,
);
} else {
devLogger.error(
"[Application] Unexpected error while seeding shared block list:",
error,
);
}
});
}
// Show loading state immediately while lists are syncing.
try {
this.reinitializeVideoListView({ reason: "login", postLoginResult });
} catch (error) {
devLogger.warn("Failed to reinitialize video list view after login:", error);
}
// Wait for the critical feed-filtering lists (blocks, subscriptions,
// hashtag preferences) to settle before rendering the video grid. This
// prevents the feed from briefly showing unfiltered content. We use a
// time-boxed wait so the UI isn't blocked indefinitely if decryption stalls.
// PERF: Reduced from 12s to 8s — list decryption timeouts have been
// lowered so settled results arrive faster. 8s prevents the feed from
// being blocked while still giving most lists time to decrypt.
const FEED_SYNC_TIMEOUT_MS = 8000;
const feedSyncPromise = Promise.race([
Promise.allSettled([
listStatePromise,
profileStatePromise,
]),
new Promise((resolve) => setTimeout(resolve, FEED_SYNC_TIMEOUT_MS)),
]).catch(() => null);
// Chain the grid refresh after the feed data is available.
this.lastIdentityRefreshPromise = feedSyncPromise
.then(() =>
this.refreshAllVideoGrids({
reason: "auth-login",
forceMainReload: true,
}),
);
this.lastIdentityRefreshPromise
.catch((error) => {
devLogger.error("Failed to refresh video grids after login:", error);
})
.finally(() => {
this.lastIdentityRefreshPromise = null;
});
this.forceRefreshAllProfiles();
if (this.uploadModal?.refreshCloudflareBucketPreview) {
Promise.resolve()
.then(() => this.uploadModal.refreshCloudflareBucketPreview())
.catch((error) => {
devLogger.warn(
"[Application] Failed to refresh cloudflare bucket preview after login:",
error,
);
});
}
userLogger.info("[auth-login-complete]", {
pubkey: activePubkey || null,
durationMs: Math.max(0, Math.round(now() - authLoginStart)),
});
},
handleBlocksLoaded(detail = {}) {
if (detail?.blocksLoaded !== true) {
return;
}
if (this.profileController) {
try {
this.profileController.populateBlockedList();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh blocked list after blocks loaded:",
error,
);
}
}
// Only reload the feed when the block set ACTUALLY changed. "blocks-loaded"
// fires on every (re)fetch — including no-op refetches — and a forced
// reload re-fetches the lists, which re-emit "blocks-loaded": an
// unconditional reload here is a self-sustaining loop (KNOWN_BUGS #2).
let nextSignature = "";
try {
const blocked =
typeof userBlocks?.getBlockedPubkeys === "function"
? userBlocks.getBlockedPubkeys()
: [];
nextSignature = Array.isArray(blocked)
? blocked.slice().sort().join(",")
: String(blocked || "");
} catch (error) {
devLogger.warn("[Application] Failed to read block set signature:", error);
}
if (nextSignature === this._blocksRefreshSignature) {
return;
}
this._blocksRefreshSignature = nextSignature;
try {
void this.onVideosShouldRefresh({ reason: "blocks-loaded" });
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh videos after blocks loaded:",
error,
);
}
},
handleRelaysLoaded(detail = {}) {
if (detail?.relaysLoaded !== true) {
return;
}
this.scheduleRelayUiRefresh();
},
scheduleRelayUiRefresh() {
if (this.relayUiRefreshTimeout) {
return;
}
const scheduleTimeout =
typeof window !== "undefined" && typeof window.setTimeout === "function"
? window.setTimeout.bind(window)
: setTimeout;
this.relayUiRefreshTimeout = scheduleTimeout(() => {
this.relayUiRefreshTimeout = null;
this.flushRelayUiRefresh();
}, RELAY_UI_BATCH_DELAY_MS);
},
flushRelayUiRefresh() {
if (!this.profileController) {
return;
}
try {
this.profileController.populateProfileRelays();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh profile relays after relays loaded:",
error,
);
}
try {
void this.profileController.refreshDmRelayPreferences({ force: true });
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh DM relay preferences after relays loaded:",
error,
);
}
},
async requestLogout() {
const detail = await this.authService.logout();
if (detail && typeof detail === "object") {
try {
detail.__handled = true;
} catch (error) {
devLogger.warn("Failed to mark logout detail as handled:", error);
}
}
try {
await this.handleAuthLogout(detail);
} catch (error) {
// Logout must remain idempotent and successful once auth state is cleared.
// Post-logout UI cleanup failures should be logged but must not bubble
// up and cause "Failed to logout" UX or require repeated attempts.
userLogger.warn(
"[Application] Post-logout cleanup failed after auth state was cleared:",
error,
);
}
return detail ?? null;
},
async handleAuthLogout(detail = {}) {
// Deduplicate concurrent calls — the auth:logout event listener may
// fire-and-forget this method while requestLogout() also awaits it.
// Return the in-flight promise so both callers resolve together.
if (this._pendingLogoutPromise) {
return this._pendingLogoutPromise;
}
// Coordinator methods are bound to the Application instance, so
// private coordinator helpers must be reached through the coordinator object.
this._pendingLogoutPromise = this._auth._executeAuthLogout(detail);
try {
return await this._pendingLogoutPromise;
} finally {
this._pendingLogoutPromise = null;
}
},
async _executeAuthLogout(detail = {}) {
if (detail && typeof detail === "object") {
try {
detail.__handled = true;
} catch (error) {
devLogger.warn("Failed to mark logout detail as handled:", error);
}
}
this.resetViewLoggingState();
this.pendingModalZapOpen = false;
this.resetHashtagPreferencesState();
this.resetPermissionPromptState();
clearDecryptionSchemeCache();
clearWatchHistoryConversationKeyCache();
clearWatchHistoryDecryptedChunkCache();
this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" });
try {
await this.nwcSettingsService.onLogout({
pubkey: detail?.pubkey || this.pubkey,
previousPubkey: detail?.previousPubkey,
});
} catch (error) {
devLogger.warn("Failed to reset NWC settings during logout:", error);
}
if (this.profileController) {
try {
await this.profileController.handleAuthLogout(detail);
} catch (error) {
devLogger.error(
"Failed to process logout within the profile controller:",
error,
);
}
} else {
this.renderSavedProfiles();
}
this.applyLoggedOutUiState();
this.updateShareNostrAuthState({ reason: "auth-logout" });
if (typeof this.refreshUnreadDmIndicator === "function") {
void this.refreshUnreadDmIndicator({ reason: "auth-logout" });
} else if (this.appChromeController?.setUnreadDmIndicator) {
this.appChromeController.setUnreadDmIndicator(false);
}
const logoutView = getHashViewName();
if (
typeof logoutView === "string" &&
logoutView.trim().toLowerCase() === FEED_TYPES.FOR_YOU
) {
setHashView(FEED_TYPES.RECENT);
}
const activeModalVideo =
typeof this.videoModal?.getCurrentVideo === "function"
? this.videoModal.getCurrentVideo()
: this.commentController?.currentVideo || null;
if (this.commentController && activeModalVideo) {
// Regression guard: ensure logout refreshes the modal thread so comments stay visible without reopening.
this.commentController.load(activeModalVideo);
} else {
this.commentController?.refreshAuthState?.();
}
try {
await this.handleModerationSettingsChange({
settings: getModerationSettings(),
skipRefresh: true,
});
} catch (error) {
devLogger.warn(
"Failed to reset moderation settings after logout:",
error,
);
}
if (this.videoModal?.closeZapDialog) {
try {
this.videoModal.closeZapDialog({ silent: true, restoreFocus: false });
} catch (error) {
devLogger.warn("Failed to close zap dialog during logout:", error);
}
}
if (this.zapController) {
try {
this.zapController.resetState();
this.zapController.setVisibility(Boolean(this.currentVideo?.lightningAddress));
} catch (error) {
devLogger.warn("Failed to reset zap controller during logout:", error);
}
}
if (typeof this.nostrService?.stopDirectMessageSubscription === "function") {
try {
this.nostrService.stopDirectMessageSubscription();
} catch (error) {
devLogger.warn("Failed to stop DM subscription during logout:", error);
}
}
if (typeof this.nostrService?.clearDirectMessages === "function") {
try {
this.nostrService.clearDirectMessages({ emit: true });
} catch (error) {
devLogger.warn("Failed to clear cached DMs during logout:", error);
}
}
if (typeof this.nostrService?.clearVideoSubscription === "function") {
try {
this.nostrService.clearVideoSubscription();
} catch (error) {
devLogger.warn("Failed to clear video subscription during logout:", error);
}
}
if (typeof this.nostrService?.resetVideosCache === "function") {
try {
this.nostrService.resetVideosCache();
} catch (error) {
devLogger.warn("Failed to reset cached videos during logout:", error);
}
}
try {
await this.renderVideoList({
videos: [],
metadata: { reason: "auth:logout" },
});
} catch (error) {
devLogger.warn("Failed to render empty list during logout:", error);
}
this.dispatchAuthChange({
status: "logout",
loggedIn: false,
pubkey: detail?.pubkey || null,
previousPubkey: detail?.previousPubkey || null,
});
try {
await this.loadVideos(true);
} catch (error) {
devLogger.error("Failed to refresh videos after logout:", error);
}
this.forceRefreshAllProfiles();
if (this.uploadModal?.refreshCloudflareBucketPreview) {
try {
await this.uploadModal.refreshCloudflareBucketPreview();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh Cloudflare preview during logout:",
error,
);
}
}
},
handleProfileUpdated(detail = {}) {
if (this.profileController) {
this.profileController.handleProfileUpdated(detail);
} else if (Array.isArray(detail?.savedProfiles)) {
this.renderSavedProfiles();
}
const normalizedPubkey = detail?.pubkey
? this.normalizeHexPubkey(detail.pubkey)
: null;
const profile = detail?.profile;
if (normalizedPubkey && profile) {
this.updateProfileInDOM(normalizedPubkey, profile);
if (
!this.profileController &&
this.normalizeHexPubkey(this.pubkey) === normalizedPubkey
) {
this.updateActiveProfileUI(normalizedPubkey, profile);
}
}
},
/**
* Cleanup resources on unload or modal close.
*
* When `preserveModals` is true the modal infrastructure is kept alive so the
* next playback session can reuse the existing controllers without
* reinitializing DOM bindings.
*/
async cleanup({
preserveSubscriptions = false,
preserveObservers = false,
preserveModals = false,
} = {}) {
this.log(
`[cleanup] Requested (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers}, preserveModals=${preserveModals})`
);
// Serialise teardown so overlapping calls (e.g. close button spam) don't
// race each other and clobber a fresh playback setup.
if (this.cleanupPromise) {
this.log("[cleanup] Waiting for in-flight cleanup to finish before starting a new run.");
try {
await this.cleanupPromise;
} catch (err) {
devLogger.warn("Previous cleanup rejected:", err);
}
}
const runCleanup = async () => {
this.log(
`[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers}, preserveModals=${preserveModals})`
);
try {
this.cancelPendingViewLogging();
await this.flushWatchHistory("session-end", "cleanup").catch(
(error) => {
const message =
error && typeof error.message === "string"
? error.message
: String(error ?? "unknown error");
this.log(`[cleanup] Watch history flush failed: ${message}`);
}
);
this.clearActiveIntervals();
if (this.playbackService) {
this.playbackService.cleanupWatchdog();
}
this.teardownModalViewCountSubscription();
if (this.reactionController) {
this.reactionController.unsubscribe();
}
if (!preserveObservers && this.mediaLoader) {
this.mediaLoader.disconnect();
}
if (!preserveObservers) {
this.teardownAllViewCountSubscriptions();
} else {
this.pruneDetachedViewCountElements();
}
if (!preserveSubscriptions) {
this.nostrService.clearVideoSubscription();
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
}
// If there's a small inline player
if (this.videoElement) {
this.videoElement = this.teardownVideoElement(this.videoElement);
}
if (
this.videoModal &&
typeof this.videoModal.clearPosterCleanup === "function"
) {
try {
this.videoModal.clearPosterCleanup();
} catch (err) {
devLogger.warn("[cleanup] video modal poster cleanup threw:", err);
}
}
const modalVideoEl = this.modalVideo;
if (modalVideoEl) {
const refreshedModal = this.teardownVideoElement(modalVideoEl, {
replaceNode: true,
});
if (refreshedModal) {
this.modalVideo = refreshedModal;
if (
this.videoModal &&
typeof this.videoModal.setVideoElement === "function"
) {
try {
this.videoModal.setVideoElement(refreshedModal);
} catch (err) {
devLogger.warn(
"[cleanup] Failed to sync video modal element after replacement:",
err
);
}
}
}
}
this.commentController?.dispose({ resetUi: false });
if (!preserveModals) {
if (this.modalManager) {
try {
this.modalManager.teardown();
} catch (error) {
devLogger.warn("[cleanup] Modal teardown failed:", error);
}
this.modalManager = null;
}
if (this.bootstrapper) {
try {
this.bootstrapper.teardown();
} catch (error) {
devLogger.warn("[cleanup] Bootstrap teardown failed:", error);
}
}
}
// Tell webtorrent to cleanup
await torrentClient.cleanup();
this.log("[cleanup] WebTorrent cleanup resolved.");
try {
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
}
} catch (err) {
// Ignore errors when cancelling the service worker stream; it may not be active.
}
} catch (err) {
devLogger.error("Cleanup error:", err);
} finally {
this.log("[cleanup] Finished.");
}
};
const cleanupPromise = runCleanup();
this.cleanupPromise = cleanupPromise;
try {
await cleanupPromise;
} finally {
if (this.cleanupPromise === cleanupPromise) {
this.cleanupPromise = null;
}
}
},
async waitForCleanup() {
if (!this.cleanupPromise) {
return;
}
try {
this.log("[waitForCleanup] Awaiting previous cleanup before continuing.");
await this.cleanupPromise;
this.log("[waitForCleanup] Previous cleanup completed.");
} catch (err) {
devLogger.warn("waitForCleanup observed a rejected cleanup:", err);
}
},
clearActiveIntervals() {
if (Array.isArray(this.activeIntervals)) {
this.activeIntervals.forEach((id) => clearInterval(id));
}
this.activeIntervals = [];
this.torrentStatusIntervalId = null;
},
cacheTorrentStatusNodes() {
const doc =
(this.videoModal && this.videoModal.document) ||
(typeof document !== "undefined" ? document : null);
if (!doc || typeof doc.getElementById !== "function") {
this.torrentStatusNodes = null;
return;
}
this.torrentStatusNodes = {
status: doc.getElementById("status"),
progress: doc.getElementById("progress"),
peers: doc.getElementById("peers"),
speed: doc.getElementById("speed"),
downloaded: doc.getElementById("downloaded"),
};
},
clearTorrentStatusNodes() {
this.torrentStatusNodes = null;
},
removeActiveInterval(intervalId) {
if (!intervalId || !Array.isArray(this.activeIntervals)) {
return;
}
this.activeIntervals = this.activeIntervals.filter((id) => id !== intervalId);
},
addTorrentStatusVisibilityHandlers({ onPause, onResume, onClose } = {}) {
this.removeTorrentStatusVisibilityHandlers();
const handleVisibilityChange = () => {
if (!document.body.contains(this.modalVideo)) {
if (typeof onClose === "function") {
onClose();
}
this.removeTorrentStatusVisibilityHandlers();
return;
}
if (document.visibilityState === "hidden") {
if (typeof onPause === "function") {
onPause();
}
return;
}
if (typeof onResume === "function") {
onResume();
}
};
const handlePageHide = () => {
if (typeof onPause === "function") {
onPause();
}
};
this.torrentStatusVisibilityHandler = handleVisibilityChange;
this.torrentStatusPageHideHandler = handlePageHide;
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("pagehide", handlePageHide);
},
removeTorrentStatusVisibilityHandlers() {
if (this.torrentStatusVisibilityHandler) {
document.removeEventListener("visibilitychange", this.torrentStatusVisibilityHandler);
this.torrentStatusVisibilityHandler = null;
}
if (this.torrentStatusPageHideHandler) {
window.removeEventListener("pagehide", this.torrentStatusPageHideHandler);
this.torrentStatusPageHideHandler = null;
}
},
async handleProfileSwitchRequest({ pubkey, providerId } = {}) {
if (!pubkey) {
throw new Error("Missing pubkey for profile switch request.");
}
const result = await this.authService.switchProfile(pubkey, { providerId });
if (result?.switched) {
const detail = result.detail || null;
if (
detail?.postLoginPromise &&
typeof detail.postLoginPromise.then === "function"
) {
try {
await detail.postLoginPromise;
} catch (error) {
devLogger.warn(
"Failed to complete post-login hydration before continuing after profile switch:",
error,
);
}
}
try {
await this.handleModerationSettingsChange({
settings: getModerationSettings(),
skipRefresh: true,
});
} catch (error) {
devLogger.warn(
"Failed to sync moderation settings after profile switch:",
error,
);
}
const refreshCompleted = await this.waitForIdentityRefresh({
reason: "profile-switch",
});
if (!refreshCompleted) {
devLogger.warn(
"[Application] Fallback identity refresh was required after switching profiles.",
);
}
if (this.watchHistoryTelemetry?.resetPlaybackLoggingState) {
try {
this.watchHistoryTelemetry.resetPlaybackLoggingState();
} catch (error) {
devLogger.warn(
"Failed to reset watch history telemetry after profile switch:",
error,
);
}
}
if (this.watchHistoryTelemetry?.refreshPreferenceSettings) {
try {
this.watchHistoryTelemetry.refreshPreferenceSettings();
} catch (error) {
devLogger.warn(
"Failed to refresh watch history preferences after profile switch:",
error,
);
}
}
}
return result;
},
async waitForIdentityRefresh({
reason = "identity-refresh",
attempts = 6,
} = {}) {
const maxAttempts = Number.isFinite(attempts)
? Math.max(1, Math.floor(attempts))
: 6;
const waitForTick = () =>
new Promise((resolve) => {
if (typeof queueMicrotask === "function") {
queueMicrotask(resolve);
} else if (typeof setTimeout === "function") {
setTimeout(resolve, 0);
} else {
resolve();
}
});
let promise = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidate = this.lastIdentityRefreshPromise;
if (candidate && typeof candidate.then === "function") {
promise = candidate;
break;
}
// Yield to allow the auth login flow to schedule the refresh promise.
// eslint-disable-next-line no-await-in-loop
await waitForTick();
}
if (promise && typeof promise.then === "function") {
try {
await promise;
return true;
} catch (error) {
devLogger.error(
"[Application] Identity refresh promise rejected:",
error,
);
}
const activePubkey = this.pubkey;
if (activePubkey) {
try {
await this.nostrService.loadDirectMessages({
actorPubkey: activePubkey,
limit: 50,
initialLoad: true,
});
} catch (error) {
devLogger.warn(
"[Application] Failed to sync direct messages during login:",
error,
);
}
}
}
try {
await this.refreshAllVideoGrids({
reason,
forceMainReload: true,
});
} catch (error) {
devLogger.error(
"[Application] Failed to refresh video grids after waiting for identity refresh:",
error,
);
}
return false;
},
async handleProfileLogoutRequest({ pubkey, entry } = {}) {
const candidatePubkey =
typeof pubkey === "string" && pubkey.trim()
? pubkey.trim()
: typeof entry?.pubkey === "string" && entry.pubkey.trim()
? entry.pubkey.trim()
: "";
if (!candidatePubkey) {
return { loggedOut: false, reason: "invalid-pubkey" };
}
const normalizedTarget =
this.normalizeHexPubkey(candidatePubkey) || candidatePubkey;
if (!normalizedTarget) {
return { loggedOut: false, reason: "invalid-pubkey" };
}
const activeNormalized = this.normalizeHexPubkey(getActiveProfilePubkey());
if (activeNormalized && activeNormalized === normalizedTarget) {
const detail = await this.requestLogout();
return {
loggedOut: true,
reason: "active-profile",
active: true,
detail,
};
}
let removalResult;
try {
removalResult = this.authService.removeSavedProfile(candidatePubkey);
} catch (error) {
devLogger.error(
"[Application] Failed to remove saved profile during logout request:",
error,
);
return { loggedOut: false, reason: "remove-failed", error };
}
if (!removalResult?.removed) {
if (removalResult?.error) {
devLogger.warn(
"[Application] removeSavedProfile returned an error during logout request:",
removalResult.error,
);
}
return { loggedOut: false, reason: "not-found" };
}
if (
this.nwcSettingsService &&
typeof this.nwcSettingsService.clearStoredNwcSettings === "function"
) {
try {
await this.nwcSettingsService.clearStoredNwcSettings(normalizedTarget, {
silent: true,
});
} catch (error) {
devLogger.warn(
"[Application] Failed to clear wallet settings for logged-out profile:",
error,
);
}
}
this.renderSavedProfiles();
return { loggedOut: true, removed: true };
},
async handleProfileRelayOperation({
action,
url,
activePubkey,
skipPublishIfUnchanged = true,
} = {}) {
const context = {
action,
url,
ok: false,
changed: false,
reason: null,
error: null,
publishResult: null,
operationResult: null,
};
if (!activePubkey) {
context.reason = "no-active-pubkey";
return context;
}
const previous = relayManager.snapshot();
let operationResult;
try {
switch (action) {
case "add":
operationResult = relayManager.addRelay(url);
break;
case "remove":
operationResult = relayManager.removeRelay(url);
break;
case "restore":
operationResult = relayManager.restoreDefaults();
break;
case "mode-toggle":
operationResult = relayManager.cycleRelayMode(url);
break;
default: {
const error = Object.assign(new Error("Unknown relay operation."), {
code: "invalid-operation",
});
throw error;
}
}
} catch (error) {
context.reason = error?.code || "operation-error";
context.error = error;
return context;
}
context.operationResult = operationResult;
context.changed = Boolean(operationResult?.changed);
if (!context.changed && skipPublishIfUnchanged) {
context.reason = operationResult?.reason || "unchanged";
return context;
}
try {
const publishResult = await relayManager.publishRelayList(activePubkey);
if (!publishResult?.ok) {
throw Object.assign(new Error("No relays accepted the update."), {
code: "publish-failed",
});
}
context.ok = true;
context.publishResult = publishResult;
const refreshReason = `relay-${action || "update"}`;
try {
await this.onVideosShouldRefresh({ reason: refreshReason });
} catch (refreshError) {
devLogger.warn(
"[Profile] Failed to refresh videos after relay update:",
refreshError,
);
}
return context;
} catch (error) {
context.reason = error?.code || "publish-failed";
context.error = error;
try {
if (Array.isArray(previous)) {
relayManager.setEntries(previous, { allowEmpty: false });
}
} catch (restoreError) {
devLogger.warn(
"[Profile] Failed to restore relay preferences after publish error:",
restoreError,
);
}
return context;
}
},
handleProfileRelayModeToggle(payload = {}) {
return payload?.context || null;
},
handleProfileRelayRestore(payload = {}) {
return payload?.context || null;
},
async handleProfileBlocklistMutation({
action,
actorHex,
targetHex,
} = {}) {
const context = { ok: false, reason: null, error: null };
if (!actorHex || !targetHex) {
context.reason = "invalid-target";
return context;
}
try {
await userBlocks.ensureLoaded(actorHex);
const isBlocked = userBlocks.isBlocked(targetHex);
if (action === "add") {
if (isBlocked) {
context.reason = "already-blocked";
return context;
}
await userBlocks.addBlock(targetHex, actorHex);
context.ok = true;
context.reason = "blocked";
} else if (action === "remove") {
if (!isBlocked) {
context.reason = "not-blocked";
return context;
}
await userBlocks.removeBlock(targetHex, actorHex);
context.ok = true;
context.reason = "unblocked";
} else {
context.reason = "invalid-action";
return context;
}
if (context.ok) {
try {
await this.onVideosShouldRefresh({ reason: `blocklist-${action}` });
} catch (refreshError) {
devLogger.error(
"Failed to refresh videos after blocklist mutation:",
refreshError,
);
}
}
return context;
} catch (error) {
context.error = error;
context.reason = error?.code || "service-error";
return context;
}
},
async handleProfileAdminMutation(payload = {}) {
const action = payload?.action;
const context = { ok: false, error: null, result: null };
try {
switch (action) {
case "ensure-ready":
await accessControl.waitForReady();
context.ok = true;
break;
case "add-moderator":
context.result = await accessControl.addModerator(
payload.actorNpub,
payload.targetNpub,
);
context.ok = !!context.result?.ok;
break;
case "remove-moderator":
context.result = await accessControl.removeModerator(
payload.actorNpub,
payload.targetNpub,
);
context.ok = !!context.result?.ok;
break;
case "list-mutation":
if (payload.listType === "whitelist") {
context.result =
payload.mode === "add"
? await accessControl.addToWhitelist(
payload.actorNpub,
payload.targetNpub,
)
: await accessControl.removeFromWhitelist(
payload.actorNpub,
payload.targetNpub,
);
} else {
context.result =
payload.mode === "add"
? await accessControl.addToBlacklist(
payload.actorNpub,
payload.targetNpub,
)
: await accessControl.removeFromBlacklist(
payload.actorNpub,
payload.targetNpub,
);
}
context.ok = !!context.result?.ok;
break;
default:
context.error = Object.assign(
new Error("Unknown admin mutation."),
{ code: "invalid-action" },
);
}
} catch (error) {
context.error = error;
return context;
}
return context;
},
async handleProfileWalletPersist(options = {}) {
return this.nwcSettingsService.handleProfileWalletPersist(options);
},
async handleProfileWalletTest({ nwcUri, defaultZap } = {}) {
return this.nwcSettingsService.ensureWallet({ nwcUri, defaultZap });
},
async handleProfileWalletDisconnect() {
return this.nwcSettingsService.updateActiveNwcSettings(
this.nwcSettingsService.createDefaultNwcSettings(),
);
},
handleProfileAdminNotifyError({ error } = {}) {
if (!error) {
return;
}
devLogger.warn("[admin] Notification dispatch issue:", error);
},
handleProfileHistoryEvent() {
return null;
},
};
}