Files
bitvid/js/app/authSessionCoordinator.js
T
2026-07-26 12:33:57 -04:00

2025 lines
67 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, FEATURE_TRENDING_FEED } from "../constants.js";
import { showPasswordPrompt } from "../ui/promptDialog.js";
import { evaluateStoredNsecSwitch } from "./storedNsecSwitch.js";
import {
createListCacheProbe,
waitForListCaches,
} from "./feedListCacheGate.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();
// Account switch: clear the PREVIOUS account's app-wide DM store + stop
// its subscription (logout does the same); resetting only the modal view
// left old messages on screen. keepSnapshot preserves each account's
// persisted DM cache so switching back stays fast.
try {
this.nostrService?.stopDirectMessageSubscription?.();
this.nostrService?.clearDirectMessages?.({ emit: true, keepSnapshot: true });
} catch (error) {
devLogger.warn(
"[Application] Failed to reset DMs on account switch:",
error,
);
}
}
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();
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" });
}
if (typeof this.refreshSubmissionsIndicator === "function") {
void this.refreshSubmissionsIndicator({ reason: "auth-login" });
}
if (typeof this.refreshAdminStar === "function") {
this.refreshAdminStar();
}
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,
});
// The NEW identity's lists settled: backfill the open profile modal so a
// switch refreshes the visible pane (no-op when the modal is closed).
if (
this.profileController &&
typeof this.profileController.refreshOpenPanesForActiveIdentity ===
"function"
) {
try {
this.profileController.refreshOpenPanesForActiveIdentity();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh profile panes after lists settled:",
error,
);
}
}
// 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;
// Cache-aware early release (rationale in feedListCacheGate.js): open the
// gate as soon as all three list caches are valid for this pubkey.
const listCacheReadyPromise = waitForListCaches(
createListCacheProbe({
userBlocks,
subscriptions,
hashtagPreferences: this.hashtagPreferences,
normalizeHexPubkey: (value) => this.normalizeHexPubkey(value),
activePubkey,
}),
{ timeoutMs: FEED_SYNC_TIMEOUT_MS },
);
const feedSyncPromise = Promise.race([
Promise.allSettled([listStatePromise, profileStatePromise]),
listCacheReadyPromise,
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();
// Forget the cached "keep unlocked" key so logout re-locks (TODO #51).
// MUST read previousPubkey: authService.logout already nulled the active
// pubkey, so detail.pubkey/this.pubkey are empty here.
try {
const loggedOutPubkey =
detail?.previousPubkey || detail?.pubkey || this.pubkey;
if (loggedOutPubkey) {
nostrClient.forgetUnlockedSigner?.(loggedOutPubkey);
}
} catch (error) {
devLogger.warn("Failed to forget cached unlock during logout:", error);
}
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);
}
if (typeof this.refreshSubmissionsIndicator === "function") {
void this.refreshSubmissionsIndicator({ reason: "auth-logout" });
}
if (typeof this.refreshAdminStar === "function") {
this.refreshAdminStar();
}
const logoutView = getHashViewName();
if (
typeof logoutView === "string" &&
logoutView.trim().toLowerCase() === FEED_TYPES.FOR_YOU
) {
// For You is logged-in only; send logged-out users to the same landing
// as the default homepage — Trending (or Recent if Trending is off).
setHashView(
FEATURE_TRENDING_FEED ? FEED_TYPES.TRENDING : 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;
}
},
// For an nsec target, ensure its stored key can be unlocked and collect the
// passphrase. Returns { status: "ok", passphrase } | { status: "cancelled" } |
// { status: "error", reason }.
async prepareStoredNsecSwitch(targetPubkey) {
const meta =
typeof nostrClient?.getStoredSessionActorMetadata === "function"
? nostrClient.getStoredSessionActorMetadata(targetPubkey)
: null;
const decision = evaluateStoredNsecSwitch(meta, targetPubkey);
if (decision.action === "error") {
this.showError?.(
decision.reason === "pubkey-mismatch"
? "A different account's key is saved on this device. Log in with this account's nsec to switch to it."
: "This account's key isn't saved on this device. Log in with its nsec to switch to it.",
);
return { status: "error", reason: decision.reason };
}
const passphrase = await showPasswordPrompt(
"Enter your PIN / passphrase to unlock this account's saved key.",
{ title: "Switch account", confirmLabel: "Switch" },
);
if (passphrase == null || passphrase === "") {
return { status: "cancelled", reason: "cancelled" };
}
return { status: "ok", passphrase };
},
async handleProfileSwitchRequest({ pubkey, providerId } = {}) {
if (!pubkey) {
throw new Error("Missing pubkey for profile switch request.");
}
// Different signing methods need help to activate on switch:
// - nsec: its key is passphrase-encrypted → prompt for the PIN + unlock it.
// - NIP-46: reuse the stored remote-signer session (don't restart a handshake).
// - NIP-07: the extension connects directly, no prep needed.
const normalizedProviderId = String(providerId || "").trim().toLowerCase();
const isNsecTarget = normalizedProviderId === "nsec";
const isNip46Target = normalizedProviderId === "nip46";
let switchOptions = { providerId };
if (isNsecTarget) {
const prep = await this.prepareStoredNsecSwitch(pubkey);
if (prep.status !== "ok") {
return { switched: false, reason: prep.reason || prep.status };
}
switchOptions = {
providerId,
unlockStored: true,
passphrase: prep.passphrase,
};
} else if (isNip46Target) {
switchOptions = { providerId, reuseStored: true };
}
let result;
try {
result = await this.authService.switchProfile(pubkey, switchOptions);
} catch (error) {
if (isNsecTarget && error?.code === "decrypt-failed") {
this.showError?.(
"Incorrect PIN / passphrase. Try switching to this account again.",
);
return { switched: false, reason: "bad-passphrase" };
}
if (isNip46Target) {
this.showError?.(
"Couldn't reconnect to this account's remote signer. Reconnect it (Login → remote signer) to switch to it.",
);
return { switched: false, reason: "nip46-reconnect-failed" };
}
throw error;
}
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,
);
}
}
// A removed profile must not leave keys on the device: forget its cached
// "keep unlocked" key + stored credentials (nsec key, NIP-46 session).
try {
nostrClient.forgetUnlockedSigner?.(normalizedTarget);
nostrClient.forgetStoredAccount?.(normalizedTarget);
} catch (error) {
devLogger.warn(
"[Application] Failed to forget credentials for removed 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;
}
// Block lists are NIP-04 encrypted: re-unlock a reloaded nsec signer via
// one passphrase prompt (TODO #57). Only user-driven outcomes short-circuit;
// everything else falls through to the mutation's own signer errors.
if (typeof this.ensureEncryptionCapableSigner === "function") {
const ensured = await this.ensureEncryptionCapableSigner({
pubkey: actorHex,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and update your mute/block list.",
});
if (
ensured &&
(ensured.reason === "cancelled" || ensured.reason === "bad-passphrase")
) {
context.reason = ensured.reason;
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;
},
};
}