mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
The login-to-feed pipeline had several issues causing lists (blocks, subscriptions, hashtag preferences) to load too slowly or not reflect in the feed: 1. All list loading was chained after profileStatePromise, adding 2.5-6s of unnecessary delay. Lists only need relay URLs, not profile data. Now they chain off relaysReadyPromise instead, loading in parallel with profile fetching. 2. The video grid refreshed immediately without waiting for any lists, so the feed briefly displayed unfiltered content. Now the grid refresh waits (up to 8s) for critical feed-filtering lists to settle. 3. Hashtag preferences weren't cached into the snapshot before the feed rendered, so interests/disinterests were empty on first paint. 4. Subscription ensureLoaded had a 6s timeout that was too aggressive for relay fetch (12s) + decryption (3-12s), causing silent failures. Increased to 30s. 5. Subscription relay fetches for user and session actor ran sequentially. Now they run in parallel via Promise.all. https://claude.ai/code/session_01UKmTWb83AnQPRVQo3bKZqm
1271 lines
37 KiB
JavaScript
1271 lines
37 KiB
JavaScript
// js/services/authService.js
|
|
|
|
import {
|
|
setPubkey,
|
|
setCurrentUserNpub,
|
|
getPubkey,
|
|
getCurrentUserNpub,
|
|
} from "../state/appState.js";
|
|
import { userLogger } from "../utils/logger.js";
|
|
import { requestDefaultExtensionPermissions } from "../nostr/defaultClient.js";
|
|
import {
|
|
getSavedProfiles,
|
|
getActiveProfilePubkey,
|
|
setActiveProfilePubkey,
|
|
mutateSavedProfiles,
|
|
persistSavedProfiles,
|
|
loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage,
|
|
syncSavedProfileFromCache as syncSavedProfileFromCacheState,
|
|
loadProfileCacheFromStorage as hydrateProfileCacheFromStorage,
|
|
persistProfileCacheToStorage as persistProfileCacheState,
|
|
getProfileCacheEntry as getCachedProfileEntry,
|
|
setProfileCacheEntry as setCachedProfileEntry,
|
|
resetModerationSettings,
|
|
} from "../state/cache.js";
|
|
import { profileCache } from "../state/profileCache.js";
|
|
import getDefaultAuthProvider, {
|
|
providers as defaultAuthProviders,
|
|
} from "./authProviders/index.js";
|
|
import { fetchProfileMetadata } from "./profileMetadataService.js";
|
|
|
|
class SimpleEventEmitter {
|
|
constructor(logger = null) {
|
|
this.logger = typeof logger === "function" ? logger : null;
|
|
this.listeners = new Map();
|
|
}
|
|
|
|
on(eventName, handler) {
|
|
if (typeof handler !== "function") {
|
|
return () => {};
|
|
}
|
|
if (!this.listeners.has(eventName)) {
|
|
this.listeners.set(eventName, new Set());
|
|
}
|
|
const handlers = this.listeners.get(eventName);
|
|
handlers.add(handler);
|
|
return () => {
|
|
handlers.delete(handler);
|
|
if (!handlers.size) {
|
|
this.listeners.delete(eventName);
|
|
}
|
|
};
|
|
}
|
|
|
|
emit(eventName, detail) {
|
|
const handlers = this.listeners.get(eventName);
|
|
if (!handlers || !handlers.size) {
|
|
return;
|
|
}
|
|
|
|
for (const handler of Array.from(handlers)) {
|
|
try {
|
|
handler(detail);
|
|
} catch (error) {
|
|
if (this.logger) {
|
|
this.logger(`AuthService listener for "${eventName}" threw`, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
|
const FALLBACK_PROFILE = {
|
|
name: "Unknown",
|
|
picture: "assets/svg/default-profile.svg",
|
|
about: "",
|
|
website: "",
|
|
banner: "",
|
|
lud16: "",
|
|
lud06: "",
|
|
};
|
|
|
|
const FAST_PROFILE_RELAY_LIMIT = 3;
|
|
const FAST_PROFILE_TIMEOUT_MS = 2500;
|
|
const BACKGROUND_PROFILE_TIMEOUT_MS = 6000;
|
|
|
|
export default class AuthService {
|
|
constructor({
|
|
nostrClient,
|
|
userBlocks,
|
|
relayManager,
|
|
logger,
|
|
accessControl,
|
|
authProviders,
|
|
getAuthProvider,
|
|
} = {}) {
|
|
this.nostrClient = nostrClient || null;
|
|
this.userBlocks = userBlocks || null;
|
|
this.relayManager = relayManager || null;
|
|
this.accessControl = accessControl || null;
|
|
this.authProviders =
|
|
authProviders && typeof authProviders === "object"
|
|
? authProviders
|
|
: defaultAuthProviders;
|
|
|
|
if (typeof logger === "function") {
|
|
this.logger = logger;
|
|
} else if (logger && typeof logger.log === "function") {
|
|
this.logger = (...args) => logger.log(...args);
|
|
} else {
|
|
this.logger = () => {};
|
|
}
|
|
|
|
this.emitter = new SimpleEventEmitter((message, error) => {
|
|
try {
|
|
this.logger(message, error);
|
|
} catch (logError) {
|
|
userLogger.warn("[AuthService] logger threw", logError);
|
|
}
|
|
});
|
|
|
|
if (typeof getAuthProvider === "function") {
|
|
this.resolveAuthProvider = (providerId) => {
|
|
const normalizedId = this.normalizeProviderId(providerId);
|
|
const provider = getAuthProvider(normalizedId);
|
|
if (!provider || typeof provider.login !== "function") {
|
|
const error = new Error(`Unknown auth provider: ${normalizedId}`);
|
|
error.code = "unknown-auth-provider";
|
|
throw error;
|
|
}
|
|
return provider;
|
|
};
|
|
} else {
|
|
this.resolveAuthProvider = (providerId) => {
|
|
const normalizedId = this.normalizeProviderId(providerId);
|
|
const registry = this.authProviders || defaultAuthProviders;
|
|
const provider =
|
|
registry && typeof registry === "object"
|
|
? registry[normalizedId]
|
|
: null;
|
|
|
|
if (provider && typeof provider.login === "function") {
|
|
return provider;
|
|
}
|
|
|
|
return getDefaultAuthProvider(normalizedId);
|
|
};
|
|
}
|
|
}
|
|
|
|
normalizeProviderId(providerId) {
|
|
return typeof providerId === "string" && providerId.trim()
|
|
? providerId.trim()
|
|
: "nip07";
|
|
}
|
|
|
|
normalizeAuthType(authTypeCandidate, providerId, providerResult) {
|
|
const candidates = [];
|
|
|
|
if (typeof authTypeCandidate === "string") {
|
|
candidates.push(authTypeCandidate);
|
|
}
|
|
|
|
if (providerResult && typeof providerResult === "object") {
|
|
const resultAuthType = providerResult.authType;
|
|
if (typeof resultAuthType === "string") {
|
|
candidates.push(resultAuthType);
|
|
}
|
|
|
|
const resultProviderId = providerResult.providerId;
|
|
if (typeof resultProviderId === "string") {
|
|
candidates.push(resultProviderId);
|
|
}
|
|
}
|
|
|
|
if (typeof providerId === "string") {
|
|
candidates.push(providerId);
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
const trimmed = candidate.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
return "nip07";
|
|
}
|
|
|
|
log(...args) {
|
|
try {
|
|
this.logger(...args);
|
|
} catch (error) {
|
|
userLogger.warn("[AuthService] logger threw", error);
|
|
}
|
|
}
|
|
|
|
on(eventName, handler) {
|
|
return this.emitter.on(eventName, handler);
|
|
}
|
|
|
|
emit(eventName, detail) {
|
|
this.emitter.emit(eventName, detail);
|
|
}
|
|
|
|
hydrateFromStorage() {
|
|
hydrateProfileCacheFromStorage();
|
|
const { profiles, hasExplicitActiveProfile } = hydrateSavedProfilesFromStorage();
|
|
if (
|
|
!getActiveProfilePubkey() &&
|
|
!hasExplicitActiveProfile &&
|
|
Array.isArray(profiles) &&
|
|
profiles.length
|
|
) {
|
|
const first = profiles[0]?.pubkey || null;
|
|
if (first) {
|
|
setActiveProfilePubkey(first, { persist: true });
|
|
}
|
|
}
|
|
this.emitProfileList("hydrate");
|
|
}
|
|
|
|
persistProfileCache() {
|
|
persistProfileCacheState();
|
|
}
|
|
|
|
getProfileCacheEntry(pubkey) {
|
|
return getCachedProfileEntry(pubkey);
|
|
}
|
|
|
|
setProfileCacheEntry(pubkey, profile, { persist = true, reason = "manual" } = {}) {
|
|
const entry = setCachedProfileEntry(pubkey, profile, { persist });
|
|
if (entry) {
|
|
const normalized = this.normalizeHexPubkey(pubkey);
|
|
if (normalized) {
|
|
const updated = syncSavedProfileFromCacheState(normalized, {
|
|
persist,
|
|
});
|
|
if (updated) {
|
|
this.emitProfileList("cache-sync");
|
|
}
|
|
this.emit("profile:updated", {
|
|
pubkey: normalized,
|
|
profile: entry.profile || FALLBACK_PROFILE,
|
|
reason,
|
|
savedProfiles: this.cloneSavedProfiles(),
|
|
activeProfilePubkey: getActiveProfilePubkey(),
|
|
});
|
|
}
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
cloneSavedProfiles() {
|
|
const saved = getSavedProfiles();
|
|
return Array.isArray(saved) ? saved.map((entry) => ({ ...entry })) : [];
|
|
}
|
|
|
|
emitProfileList(reason = "update") {
|
|
this.emit("profile:updated", {
|
|
reason,
|
|
savedProfiles: this.cloneSavedProfiles(),
|
|
activeProfilePubkey: getActiveProfilePubkey(),
|
|
});
|
|
}
|
|
|
|
safeEncodeNpub(pubkey) {
|
|
if (typeof pubkey !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = pubkey.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (trimmed.startsWith("npub1")) {
|
|
return trimmed;
|
|
}
|
|
|
|
try {
|
|
return window?.NostrTools?.nip19?.npubEncode(trimmed) || null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
safeDecodeNpub(npub) {
|
|
if (typeof npub !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = npub.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
|
if (decoded?.type === "npub" && typeof decoded.data === "string") {
|
|
return decoded.data;
|
|
}
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
normalizeHexPubkey(pubkey) {
|
|
if (typeof pubkey !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = pubkey.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (HEX64_REGEX.test(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
if (trimmed.startsWith("npub1")) {
|
|
const decoded = this.safeDecodeNpub(trimmed);
|
|
if (decoded && HEX64_REGEX.test(decoded)) {
|
|
return decoded.toLowerCase();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async requestLogin(rawOptions = {}) {
|
|
const normalizedOptions =
|
|
rawOptions && typeof rawOptions === "object" ? { ...rawOptions } : {};
|
|
const {
|
|
providerId: requestedProviderId = "nip07",
|
|
persistActive,
|
|
autoApply,
|
|
...providerOptions
|
|
} = normalizedOptions;
|
|
|
|
const providerId = this.normalizeProviderId(requestedProviderId);
|
|
const provider = this.resolveAuthProvider(providerId);
|
|
const providerResult = await provider.login({
|
|
nostrClient: this.nostrClient,
|
|
options: providerOptions,
|
|
});
|
|
|
|
const rawPubkey =
|
|
providerResult && typeof providerResult === "object"
|
|
? providerResult.pubkey
|
|
: providerResult;
|
|
const pubkey =
|
|
typeof rawPubkey === "string"
|
|
? rawPubkey
|
|
: providerResult && typeof providerResult === "object"
|
|
? providerResult.publicKey || ""
|
|
: "";
|
|
|
|
const authTypeCandidate =
|
|
providerResult && typeof providerResult === "object"
|
|
? providerResult.authType
|
|
: null;
|
|
const authType = this.normalizeAuthType(
|
|
authTypeCandidate,
|
|
providerId,
|
|
providerResult,
|
|
);
|
|
const signer =
|
|
providerResult && typeof providerResult === "object"
|
|
? providerResult.signer || null
|
|
: null;
|
|
const detailAuthType =
|
|
typeof authType === "string" && authType.trim() ? authType.trim() : "nip07";
|
|
|
|
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
|
|
if (!trimmed) {
|
|
return { pubkey: null, providerId, authType: detailAuthType, signer };
|
|
}
|
|
|
|
if (autoApply === false) {
|
|
return { pubkey: trimmed, providerId, authType: detailAuthType, signer };
|
|
}
|
|
|
|
const detail = await this.login(trimmed, {
|
|
persistActive: persistActive !== false,
|
|
providerId,
|
|
authType: detailAuthType,
|
|
signer,
|
|
providerResult,
|
|
});
|
|
|
|
return {
|
|
pubkey: trimmed,
|
|
detail,
|
|
providerId,
|
|
authType: detailAuthType,
|
|
signer,
|
|
};
|
|
}
|
|
|
|
async handleUploadSubmit(payload, { publish } = {}) {
|
|
if (!this.getActivePubkey()) {
|
|
await this.requestLogin({ allowAccountSelection: true });
|
|
if (!this.getActivePubkey()) {
|
|
throw new Error("Login required to publish videos.");
|
|
}
|
|
}
|
|
|
|
if (typeof publish !== "function") {
|
|
throw new Error("A publish callback must be provided to handleUploadSubmit.");
|
|
}
|
|
|
|
return publish(payload);
|
|
}
|
|
|
|
getActivePubkey() {
|
|
return getPubkey();
|
|
}
|
|
|
|
async login(pubkey, options = {}) {
|
|
const normalizedOptions =
|
|
options && typeof options === "object" ? { ...options } : {};
|
|
const persistActive =
|
|
normalizedOptions.persistActive === false ? false : true;
|
|
const providerId = this.normalizeProviderId(normalizedOptions.providerId);
|
|
const providerResult = Object.prototype.hasOwnProperty.call(
|
|
normalizedOptions,
|
|
"providerResult",
|
|
)
|
|
? normalizedOptions.providerResult ?? null
|
|
: null;
|
|
const authTypeCandidate =
|
|
typeof normalizedOptions.authType === "string"
|
|
? normalizedOptions.authType
|
|
: null;
|
|
const authType = this.normalizeAuthType(
|
|
authTypeCandidate,
|
|
providerId,
|
|
providerResult,
|
|
);
|
|
const signer = Object.prototype.hasOwnProperty.call(normalizedOptions, "signer")
|
|
? normalizedOptions.signer ?? null
|
|
: null;
|
|
|
|
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
|
const normalized = this.normalizeHexPubkey(pubkey);
|
|
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
|
|
const nextPubkey = normalized || trimmed || null;
|
|
if (!nextPubkey) {
|
|
throw new Error("A valid pubkey is required for login.");
|
|
}
|
|
|
|
const identityChanged = previousPubkey !== nextPubkey;
|
|
|
|
const control = this.accessControl;
|
|
const candidateNpub = this.safeEncodeNpub(nextPubkey) || null;
|
|
let lockdownActive = false;
|
|
if (control && typeof control.isLockdownActive === "function") {
|
|
try {
|
|
lockdownActive = control.isLockdownActive();
|
|
} catch (error) {
|
|
this.log("[AuthService] accessControl.isLockdownActive threw", error);
|
|
lockdownActive = Boolean(lockdownActive);
|
|
}
|
|
}
|
|
|
|
if (lockdownActive) {
|
|
let isAdminCandidate = false;
|
|
const adminCheckValue =
|
|
candidateNpub || (typeof trimmed === "string" && trimmed ? trimmed : null);
|
|
if (adminCheckValue && typeof control?.isAdminEditor === "function") {
|
|
try {
|
|
isAdminCandidate = !!control.isAdminEditor(adminCheckValue);
|
|
} catch (error) {
|
|
this.log("[AuthService] accessControl.isAdminEditor threw", error);
|
|
isAdminCandidate = false;
|
|
}
|
|
}
|
|
|
|
if (!isAdminCandidate) {
|
|
const lockdownError = new Error(
|
|
"This site is temporarily locked down. Only administrators may sign in right now.",
|
|
);
|
|
lockdownError.code = "site-lockdown";
|
|
lockdownError.npub = candidateNpub || adminCheckValue;
|
|
throw lockdownError;
|
|
}
|
|
}
|
|
|
|
if (control && typeof control.canAccess === "function") {
|
|
let canAccess = true;
|
|
try {
|
|
canAccess = control.canAccess(candidateNpub || nextPubkey);
|
|
} catch (error) {
|
|
this.log("[AuthService] accessControl.canAccess threw", error);
|
|
canAccess = Boolean(canAccess);
|
|
}
|
|
|
|
if (!canAccess) {
|
|
let isBlocked = false;
|
|
if (typeof control.isBlacklisted === "function") {
|
|
try {
|
|
isBlocked = control.isBlacklisted(candidateNpub || nextPubkey);
|
|
} catch (error) {
|
|
this.log("[AuthService] accessControl.isBlacklisted threw", error);
|
|
isBlocked = false;
|
|
}
|
|
}
|
|
|
|
const accessError = new Error(
|
|
isBlocked
|
|
? "Your account has been blocked on this platform."
|
|
: "Access restricted to admins and moderators users only.",
|
|
);
|
|
throw accessError;
|
|
}
|
|
}
|
|
|
|
if (normalized) {
|
|
setPubkey(normalized);
|
|
if (this.nostrClient && typeof this.nostrClient === "object") {
|
|
this.nostrClient.pubkey = normalized;
|
|
}
|
|
profileCache.setActiveProfile(normalized);
|
|
} else {
|
|
setPubkey(nextPubkey);
|
|
if (this.nostrClient && typeof this.nostrClient === "object") {
|
|
this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : "";
|
|
}
|
|
profileCache.setActiveProfile(trimmed ? trimmed.toLowerCase() : null);
|
|
}
|
|
|
|
const npub = candidateNpub;
|
|
setCurrentUserNpub(npub);
|
|
|
|
let savedProfilesMutated = false;
|
|
const entryAuthType = this.normalizeAuthType(
|
|
authType,
|
|
providerId,
|
|
providerResult,
|
|
);
|
|
|
|
const entryProviderId = this.normalizeProviderId(providerId) || entryAuthType;
|
|
|
|
mutateSavedProfiles((profiles) => {
|
|
const draft = Array.isArray(profiles) ? profiles.slice() : [];
|
|
const existingIndex = draft.findIndex((entry) => {
|
|
const normalizedEntry = this.normalizeHexPubkey(entry?.pubkey);
|
|
return normalizedEntry && normalizedEntry === normalized;
|
|
});
|
|
|
|
const cacheEntry = this.getProfileCacheEntry(normalized || nextPubkey);
|
|
const cachedProfile = cacheEntry?.profile || {};
|
|
|
|
const nextEntry = {
|
|
pubkey: normalized || nextPubkey,
|
|
npub: npub || (cachedProfile.npub ?? null),
|
|
name: cachedProfile.name || draft[existingIndex]?.name || "",
|
|
picture: cachedProfile.picture || draft[existingIndex]?.picture || "",
|
|
authType: entryAuthType,
|
|
providerId: entryProviderId,
|
|
};
|
|
|
|
if (existingIndex >= 0) {
|
|
const currentEntry = draft[existingIndex] || {};
|
|
const changed =
|
|
currentEntry.npub !== nextEntry.npub ||
|
|
currentEntry.name !== nextEntry.name ||
|
|
currentEntry.picture !== nextEntry.picture ||
|
|
currentEntry.authType !== nextEntry.authType ||
|
|
currentEntry.providerId !== nextEntry.providerId;
|
|
if (changed) {
|
|
draft[existingIndex] = nextEntry;
|
|
savedProfilesMutated = true;
|
|
}
|
|
} else {
|
|
draft.push(nextEntry);
|
|
savedProfilesMutated = true;
|
|
}
|
|
|
|
return draft;
|
|
}, { persist: false, persistActive: false });
|
|
|
|
if (persistActive) {
|
|
setActiveProfilePubkey(normalized || nextPubkey, { persist: true });
|
|
}
|
|
|
|
if (!persistActive && savedProfilesMutated) {
|
|
persistSavedProfiles({ persistActive: false });
|
|
} else if (persistActive || savedProfilesMutated) {
|
|
persistSavedProfiles({ persistActive: true });
|
|
}
|
|
|
|
if (savedProfilesMutated) {
|
|
this.emitProfileList("login");
|
|
}
|
|
|
|
const activeLoginPubkey = getPubkey();
|
|
const normalizedLoginPubkey =
|
|
this.normalizeHexPubkey(activeLoginPubkey) || activeLoginPubkey || null;
|
|
|
|
const detail = {
|
|
pubkey: activeLoginPubkey,
|
|
npub: getCurrentUserNpub(),
|
|
previousPubkey: previousPubkey || null,
|
|
identityChanged,
|
|
savedProfiles: this.cloneSavedProfiles(),
|
|
activeProfilePubkey: getActiveProfilePubkey(),
|
|
providerId,
|
|
authType: entryAuthType,
|
|
signer,
|
|
providerResult,
|
|
postLogin: {
|
|
pubkey: normalizedLoginPubkey,
|
|
blocksLoaded: false,
|
|
relaysLoaded: false,
|
|
profile: null,
|
|
},
|
|
postLoginPromise: null,
|
|
postLoginError: null,
|
|
};
|
|
|
|
const { detail: postLoginDetail, completionPromise, relaysReadyPromise } = this.applyPostLoginState({
|
|
deferBlocks: true,
|
|
});
|
|
detail.postLogin = postLoginDetail;
|
|
detail.relaysReadyPromise = relaysReadyPromise;
|
|
|
|
const postLoginPromise = Promise.resolve(completionPromise)
|
|
.then((postLogin) => {
|
|
detail.postLogin = postLogin;
|
|
detail.postLoginError = null;
|
|
|
|
try {
|
|
this.emitProfileList("post-login");
|
|
} catch (error) {
|
|
this.log("[AuthService] Failed to emit profile list after login", error);
|
|
}
|
|
|
|
this.emit("auth:post-login", {
|
|
...detail,
|
|
postLogin,
|
|
postLoginError: null,
|
|
savedProfiles: this.cloneSavedProfiles(),
|
|
activeProfilePubkey: getActiveProfilePubkey(),
|
|
});
|
|
|
|
return postLogin;
|
|
})
|
|
.catch((error) => {
|
|
this.log("[AuthService] applyPostLoginState failed", error);
|
|
detail.postLoginError = error;
|
|
|
|
this.emit("auth:post-login", {
|
|
...detail,
|
|
postLogin: detail.postLogin,
|
|
postLoginError: error,
|
|
savedProfiles: this.cloneSavedProfiles(),
|
|
activeProfilePubkey: getActiveProfilePubkey(),
|
|
});
|
|
|
|
return null;
|
|
});
|
|
|
|
detail.postLoginPromise = postLoginPromise;
|
|
|
|
try {
|
|
Object.defineProperty(detail, "__handled", {
|
|
configurable: true,
|
|
enumerable: false,
|
|
writable: true,
|
|
value: false,
|
|
});
|
|
} catch (error) {
|
|
// Ignore descriptor errors (e.g., frozen objects) and fall back to direct assignment.
|
|
detail.__handled = false;
|
|
}
|
|
|
|
this.emit("auth:login", detail);
|
|
return detail;
|
|
}
|
|
|
|
applyPostLoginState({ deferBlocks = false } = {}) {
|
|
const activePubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
|
const cachedEntry = activePubkey ? this.getProfileCacheEntry(activePubkey) : null;
|
|
const cachedProfile = cachedEntry?.profile || FALLBACK_PROFILE;
|
|
const detail = {
|
|
pubkey: activePubkey || null,
|
|
blocksLoaded: deferBlocks ? null : false,
|
|
relaysLoaded: false,
|
|
profile: cachedProfile,
|
|
};
|
|
|
|
if (!activePubkey) {
|
|
const resolved = Promise.resolve(detail);
|
|
return { detail, completionPromise: resolved, relaysReadyPromise: resolved };
|
|
}
|
|
|
|
const runOperation = async (operation) => {
|
|
try {
|
|
const value = await operation.promise;
|
|
const resolved = operation.onFulfilled(value);
|
|
detail[operation.name] = resolved;
|
|
if (typeof operation.emit === "function") {
|
|
operation.emit(resolved, null);
|
|
}
|
|
return resolved;
|
|
} catch (error) {
|
|
const resolved = operation.onRejected(error);
|
|
detail[operation.name] = resolved;
|
|
if (typeof operation.emit === "function") {
|
|
operation.emit(resolved, error);
|
|
}
|
|
return resolved;
|
|
}
|
|
};
|
|
|
|
const schedule = (callback) => Promise.resolve().then(() => callback());
|
|
|
|
// Expose relay readiness separately so list loading can start immediately
|
|
// after relays are available, without waiting for profile to complete.
|
|
let resolveRelaysReady;
|
|
const relaysReadyPromise = new Promise((resolve) => {
|
|
resolveRelaysReady = resolve;
|
|
});
|
|
|
|
const completionPromise = (async () => {
|
|
if (this.relayManager && typeof this.relayManager.loadRelayList === "function") {
|
|
const relayOp = {
|
|
name: "relaysLoaded",
|
|
promise: schedule(() => this.relayManager.loadRelayList(activePubkey)),
|
|
onFulfilled: () => true,
|
|
onRejected: (error) => {
|
|
this.log("[AuthService] Failed to load relay list", error);
|
|
return false;
|
|
},
|
|
emit: (value, error) => {
|
|
this.emit("relaysLoaded", {
|
|
pubkey: activePubkey,
|
|
relaysLoaded: value,
|
|
error: error ?? null,
|
|
});
|
|
},
|
|
};
|
|
await runOperation(relayOp);
|
|
}
|
|
resolveRelaysReady(detail);
|
|
|
|
const concurrentOps = [];
|
|
|
|
if (!deferBlocks) {
|
|
const blocksOperation = this.createBlocksLoadOperation(activePubkey, schedule);
|
|
if (blocksOperation) {
|
|
concurrentOps.push(blocksOperation);
|
|
}
|
|
}
|
|
|
|
concurrentOps.push({
|
|
name: "profile",
|
|
promise: schedule(() => this.loadOwnProfile(activePubkey)),
|
|
onFulfilled: (value) => {
|
|
const profile = value || FALLBACK_PROFILE;
|
|
this.setProfileCacheEntry(activePubkey, profile, {
|
|
persist: true,
|
|
reason: "post-login",
|
|
});
|
|
return profile;
|
|
},
|
|
onRejected: (error) => {
|
|
this.log("[AuthService] Failed to load own profile", error);
|
|
const fallbackProfile = cachedProfile || FALLBACK_PROFILE;
|
|
this.setProfileCacheEntry(activePubkey, fallbackProfile, {
|
|
persist: true,
|
|
reason: "post-login-fallback",
|
|
});
|
|
return fallbackProfile;
|
|
},
|
|
});
|
|
|
|
await Promise.allSettled(concurrentOps.map(runOperation));
|
|
|
|
return detail;
|
|
})();
|
|
|
|
return { detail, completionPromise, relaysReadyPromise };
|
|
}
|
|
|
|
createBlocksLoadOperation(
|
|
activePubkey,
|
|
schedule = (callback) => Promise.resolve().then(() => callback()),
|
|
options = {},
|
|
) {
|
|
if (!this.userBlocks || typeof this.userBlocks.loadBlocks !== "function") {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
name: "blocksLoaded",
|
|
promise: schedule(() => this.userBlocks.loadBlocks(activePubkey, options)),
|
|
onFulfilled: () => true,
|
|
onRejected: (error) => {
|
|
this.log("[AuthService] Failed to load block list", error);
|
|
return false;
|
|
},
|
|
emit: (value, error) => {
|
|
this.emit("blocksLoaded", {
|
|
pubkey: activePubkey,
|
|
blocksLoaded: value,
|
|
error: error ?? null,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async loadBlocksForPubkey(pubkey, options = {}) {
|
|
const normalized = this.normalizeHexPubkey(pubkey) ||
|
|
(typeof pubkey === "string" ? pubkey.trim() : "");
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
const operation = this.createBlocksLoadOperation(normalized, undefined, options);
|
|
if (!operation) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const value = await operation.promise;
|
|
const resolved = operation.onFulfilled(value);
|
|
operation.emit?.(resolved, null);
|
|
return resolved;
|
|
} catch (error) {
|
|
const resolved = operation.onRejected(error);
|
|
operation.emit?.(resolved, error);
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
async logout() {
|
|
if (this.nostrClient && typeof this.nostrClient.logout === "function") {
|
|
try {
|
|
this.nostrClient.logout();
|
|
} catch (error) {
|
|
this.log("[AuthService] nostrClient.logout threw", error);
|
|
}
|
|
}
|
|
|
|
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
|
|
|
|
setPubkey(null);
|
|
setCurrentUserNpub(null);
|
|
setActiveProfilePubkey(null, { persist: true });
|
|
|
|
try {
|
|
if (profileCache && typeof profileCache.reset === "function") {
|
|
profileCache.reset();
|
|
}
|
|
} catch (error) {
|
|
this.log("[AuthService] profileCache.reset threw", error);
|
|
}
|
|
|
|
try {
|
|
resetModerationSettings({ persist: true });
|
|
} catch (error) {
|
|
this.log(
|
|
"[AuthService] Failed to reset moderation settings on logout",
|
|
error,
|
|
);
|
|
}
|
|
|
|
if (this.userBlocks && typeof this.userBlocks.reset === "function") {
|
|
try {
|
|
this.userBlocks.reset();
|
|
} catch (error) {
|
|
this.log("[AuthService] userBlocks.reset threw", error);
|
|
}
|
|
}
|
|
|
|
if (this.relayManager && typeof this.relayManager.reset === "function") {
|
|
try {
|
|
this.relayManager.reset();
|
|
} catch (error) {
|
|
this.log("[AuthService] relayManager.reset threw", error);
|
|
}
|
|
}
|
|
|
|
persistSavedProfiles({ persistActive: true });
|
|
|
|
const detail = {
|
|
previousPubkey: previousPubkey || null,
|
|
savedProfiles: this.cloneSavedProfiles(),
|
|
activeProfilePubkey: getActiveProfilePubkey(),
|
|
};
|
|
|
|
this.emit("auth:logout", detail);
|
|
return detail;
|
|
}
|
|
|
|
removeSavedProfile(pubkey) {
|
|
const normalized = this.normalizeHexPubkey(pubkey) ||
|
|
(typeof pubkey === "string" ? pubkey.trim() : "");
|
|
if (!normalized) {
|
|
return { removed: false };
|
|
}
|
|
|
|
const { changed } = mutateSavedProfiles((profiles) =>
|
|
profiles.filter((entry) => entry?.pubkey !== normalized),
|
|
{
|
|
persist: true,
|
|
persistActive: true,
|
|
});
|
|
|
|
if (changed && getActiveProfilePubkey() === normalized) {
|
|
setActiveProfilePubkey(null, { persist: true });
|
|
}
|
|
|
|
if (changed) {
|
|
this.emitProfileList("remove-saved-profile");
|
|
}
|
|
|
|
return { removed: changed };
|
|
}
|
|
|
|
async switchProfile(pubkey, options = {}) {
|
|
const normalizedTarget = this.normalizeHexPubkey(pubkey);
|
|
if (!normalizedTarget) {
|
|
throw new Error("Unable to switch profiles: invalid account.");
|
|
}
|
|
|
|
const normalizedOptions =
|
|
options && typeof options === "object" ? { ...options } : {};
|
|
const providerId = this.normalizeProviderId(normalizedOptions.providerId);
|
|
|
|
const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey());
|
|
if (normalizedActive && normalizedActive === normalizedTarget) {
|
|
return { switched: false, reason: "already-active" };
|
|
}
|
|
|
|
const requestOptions = {
|
|
allowAccountSelection: true,
|
|
expectPubkey: normalizedTarget,
|
|
persistActive: true,
|
|
...(providerId ? { providerId } : {}),
|
|
};
|
|
const autoApply = requestOptions.autoApply !== false;
|
|
|
|
let requestResult;
|
|
try {
|
|
requestResult = await this.requestLogin(requestOptions);
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
|
|
const requestedPubkey =
|
|
this.normalizeHexPubkey(requestResult?.pubkey) ||
|
|
(typeof requestResult?.pubkey === "string"
|
|
? requestResult.pubkey.trim()
|
|
: "");
|
|
|
|
let detail = requestResult?.detail || null;
|
|
|
|
if (!detail && !autoApply) {
|
|
const fallbackTarget =
|
|
this.normalizeHexPubkey(requestedPubkey) || requestedPubkey || normalizedTarget;
|
|
detail = await this.login(fallbackTarget, {
|
|
persistActive: true,
|
|
providerId,
|
|
});
|
|
}
|
|
|
|
mutateSavedProfiles((profiles) => {
|
|
const draft = profiles.slice();
|
|
const index = draft.findIndex(
|
|
(entry) => this.normalizeHexPubkey(entry?.pubkey) === normalizedTarget
|
|
);
|
|
if (index > 0) {
|
|
const [moved] = draft.splice(index, 1);
|
|
draft.unshift(moved);
|
|
}
|
|
return draft;
|
|
}, { persist: true, persistActive: true });
|
|
|
|
const savedProfiles = this.cloneSavedProfiles();
|
|
const activeProfilePubkey = getActiveProfilePubkey();
|
|
|
|
if (detail && typeof detail === "object") {
|
|
detail.savedProfiles = savedProfiles;
|
|
detail.activeProfilePubkey = activeProfilePubkey;
|
|
}
|
|
|
|
this.emitProfileList("switch-profile");
|
|
|
|
return { switched: true, detail };
|
|
}
|
|
|
|
async loadOwnProfile(pubkey) {
|
|
const normalized = this.normalizeHexPubkey(pubkey) ||
|
|
(typeof pubkey === "string" ? pubkey.trim() : "");
|
|
if (!normalized) {
|
|
return FALLBACK_PROFILE;
|
|
}
|
|
|
|
if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
|
return FALLBACK_PROFILE;
|
|
}
|
|
|
|
const relays = Array.isArray(this.nostrClient.relays)
|
|
? this.nostrClient.relays.filter((url) => typeof url === "string" && url)
|
|
: [];
|
|
|
|
if (!relays.length) {
|
|
this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, {
|
|
persist: true,
|
|
reason: "load-own-profile:fallback",
|
|
});
|
|
return FALLBACK_PROFILE;
|
|
}
|
|
|
|
const filter = [{ kinds: [0], authors: [normalized], limit: 1 }];
|
|
|
|
const selectNewestEvent = (eventList) => {
|
|
if (!Array.isArray(eventList) || !eventList.length) {
|
|
return null;
|
|
}
|
|
const sorted = eventList
|
|
.filter((event) => event && event.pubkey === normalized)
|
|
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
|
|
return sorted.length ? sorted[0] : null;
|
|
};
|
|
|
|
const buildProfileFromEvent = (event) => {
|
|
if (!event?.content) {
|
|
return FALLBACK_PROFILE;
|
|
}
|
|
try {
|
|
const data = JSON.parse(event.content);
|
|
return {
|
|
name: data.display_name || data.name || FALLBACK_PROFILE.name,
|
|
picture: data.picture || FALLBACK_PROFILE.picture,
|
|
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
|
|
website:
|
|
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
|
|
banner:
|
|
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
|
|
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
|
|
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
|
|
};
|
|
} catch (error) {
|
|
this.log("[AuthService] Failed to parse profile metadata", error);
|
|
return FALLBACK_PROFILE;
|
|
}
|
|
};
|
|
|
|
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
const timeoutError = new Error(
|
|
`Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`
|
|
);
|
|
timeoutError.code = "timeout";
|
|
timeoutError.relay = relayUrl;
|
|
timeoutError.timeoutMs = timeoutMs;
|
|
reject(timeoutError);
|
|
}, timeoutMs);
|
|
|
|
Promise.resolve()
|
|
.then(() => this.nostrClient.pool.list([relayUrl], filter))
|
|
.then((result) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
const events = Array.isArray(result)
|
|
? result.filter((event) => event && event.pubkey === normalized)
|
|
: [];
|
|
if (requireEvent && !events.length) {
|
|
const emptyError = new Error(
|
|
`No profile events returned from ${relayUrl}`
|
|
);
|
|
emptyError.code = "empty";
|
|
emptyError.relay = relayUrl;
|
|
reject(emptyError);
|
|
return;
|
|
}
|
|
resolve({ relayUrl, events });
|
|
})
|
|
.catch((error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
const wrapped =
|
|
error instanceof Error ? error : new Error(String(error));
|
|
wrapped.relay = relayUrl;
|
|
reject(wrapped);
|
|
});
|
|
});
|
|
};
|
|
|
|
const fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT);
|
|
const backgroundRelays = relays.slice(fastRelays.length);
|
|
|
|
const fastPromises = fastRelays.map((relayUrl) =>
|
|
fetchFromRelay(relayUrl, FAST_PROFILE_TIMEOUT_MS, true)
|
|
);
|
|
const backgroundPromises = backgroundRelays.map((relayUrl) =>
|
|
fetchFromRelay(relayUrl, BACKGROUND_PROFILE_TIMEOUT_MS, false)
|
|
);
|
|
|
|
const background = Promise.allSettled([
|
|
...fastPromises,
|
|
...backgroundPromises,
|
|
])
|
|
.then((outcomes) => {
|
|
const aggregated = [];
|
|
outcomes.forEach((outcome) => {
|
|
if (outcome.status === "fulfilled") {
|
|
const events = Array.isArray(outcome.value?.events)
|
|
? outcome.value.events
|
|
: [];
|
|
if (events.length) {
|
|
aggregated.push(...events);
|
|
}
|
|
} else if (outcome.status === "rejected") {
|
|
const reason = outcome.reason;
|
|
if (reason?.code === "timeout") {
|
|
this.log(
|
|
`[AuthService] Relay ${reason.relay} timed out loading profile (background)`
|
|
);
|
|
} else {
|
|
this.log(
|
|
`[AuthService] Relay ${reason?.relay || "unknown"} failed loading profile (background)`,
|
|
reason
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
const newest = selectNewestEvent(aggregated);
|
|
if (newest && this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
|
|
this.nostrClient.handleEvent(newest);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
this.log("[AuthService] Background profile refresh failed", error);
|
|
});
|
|
|
|
let fastResult = null;
|
|
if (fastPromises.length) {
|
|
try {
|
|
fastResult = await Promise.any(fastPromises);
|
|
} catch (error) {
|
|
if (error instanceof AggregateError) {
|
|
error.errors?.forEach((err) => {
|
|
if (err?.code === "timeout") {
|
|
this.log(
|
|
`[AuthService] Relay ${err.relay} timed out loading profile`
|
|
);
|
|
}
|
|
});
|
|
} else {
|
|
this.log("[AuthService] Failed to load profile from fast relays", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fastResult?.events?.length) {
|
|
const newest = selectNewestEvent(fastResult.events);
|
|
if (newest) {
|
|
if (this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
|
|
this.nostrClient.handleEvent(newest);
|
|
}
|
|
const profile = buildProfileFromEvent(newest);
|
|
// setProfileCacheEntry now delegates to profileCache.setProfile, but handleEvent also does it.
|
|
// Doing it here returns the profile object which might be needed immediately.
|
|
background.catch(() => {});
|
|
return profile;
|
|
}
|
|
}
|
|
|
|
this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, {
|
|
persist: true,
|
|
reason: "load-own-profile:fallback",
|
|
});
|
|
|
|
background.catch(() => {});
|
|
|
|
return FALLBACK_PROFILE;
|
|
}
|
|
|
|
async fetchProfile(pubkey, { forceRefresh = false } = {}) {
|
|
const normalized = this.normalizeHexPubkey(pubkey) ||
|
|
(typeof pubkey === "string" ? pubkey.trim() : "");
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const cacheEntry = this.getProfileCacheEntry(normalized);
|
|
if (cacheEntry && !forceRefresh) {
|
|
this.emit("profile:updated", {
|
|
pubkey: normalized,
|
|
profile: cacheEntry.profile || FALLBACK_PROFILE,
|
|
reason: "cache-hit",
|
|
savedProfiles: this.cloneSavedProfiles(),
|
|
activeProfilePubkey: getActiveProfilePubkey(),
|
|
});
|
|
return cacheEntry.profile;
|
|
}
|
|
|
|
if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
|
|
return cacheEntry?.profile || FALLBACK_PROFILE;
|
|
}
|
|
|
|
try {
|
|
const result = await fetchProfileMetadata(normalized, {
|
|
nostr: this.nostrClient,
|
|
});
|
|
|
|
if (result?.event && typeof this.nostrClient?.handleEvent === "function") {
|
|
this.nostrClient.handleEvent(result.event);
|
|
}
|
|
|
|
if (result?.profile) {
|
|
return result.profile;
|
|
}
|
|
} catch (error) {
|
|
this.log("[AuthService] Failed to fetch profile", error);
|
|
}
|
|
|
|
return cacheEntry?.profile || FALLBACK_PROFILE;
|
|
}
|
|
|
|
async fetchAndRenderProfile(pubkey, forceRefresh = false) {
|
|
return this.fetchProfile(pubkey, { forceRefresh });
|
|
}
|
|
|
|
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
|
const normalized = this.normalizeHexPubkey(pubkey) ||
|
|
(typeof pubkey === "string" ? pubkey.trim() : "");
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
const updated = syncSavedProfileFromCacheState(normalized, { persist });
|
|
if (updated) {
|
|
this.emitProfileList("cache-sync");
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
loadProfileCacheFromStorage() {
|
|
hydrateProfileCacheFromStorage();
|
|
}
|
|
|
|
loadSavedProfilesFromStorage() {
|
|
const result = hydrateSavedProfilesFromStorage();
|
|
this.emitProfileList("hydrate");
|
|
return result;
|
|
}
|
|
}
|