Merge pull request #268 from PR0M3TH3AN/codex/build-authservice-for-login-and-profiles

Refactor authentication handling into dedicated service
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-03 12:42:44 -04:00
commit 9fa359f9b5
2 files changed
+822 -386

No files matched your search

+170 -386
View File
@@ -22,6 +22,7 @@ import { relayManager } from "./relayManager.js";
import watchHistoryService from "./watchHistoryService.js";
import r2Service from "./services/r2Service.js";
import PlaybackService from "./services/playbackService.js";
import AuthService from "./services/authService.js";
import { initQuickR2Upload } from "./r2-quick.js";
import { createWatchHistoryRenderer } from "./historyView.js";
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
@@ -57,12 +58,6 @@ import {
setActiveProfilePubkey as setStoredActiveProfilePubkey,
setSavedProfiles,
persistSavedProfiles,
loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage,
syncSavedProfileFromCache as syncSavedProfileFromCacheState,
loadProfileCacheFromStorage as hydrateProfileCacheFromStorage,
persistProfileCacheToStorage as persistProfileCacheState,
getProfileCacheEntry as getCachedProfileEntry,
setProfileCacheEntry as setCachedProfileEntry,
getProfileCacheMap,
getCachedUrlHealth as readCachedUrlHealth,
storeUrlHealth as persistUrlHealth,
@@ -393,6 +388,18 @@ class bitvidApp {
});
this.activePlaybackSession = null;
this.authService = new AuthService({
nostrClient,
userBlocks,
relayManager,
logger: (message, ...args) => this.log(message, ...args),
});
this.authService.on("auth:login", (detail) => this.handleAuthLogin(detail));
this.authService.on("auth:logout", (detail) => this.handleAuthLogout(detail));
this.authService.on("profile:updated", (detail) =>
this.handleProfileUpdated(detail)
);
// Optional: a "profile" button or avatar (if used)
this.profileButton = document.getElementById("profileButton") || null;
this.profileAvatar = document.getElementById("profileAvatar") || null;
@@ -671,15 +678,15 @@ class bitvidApp {
}
loadSavedProfilesFromStorage() {
const { profiles } = hydrateSavedProfilesFromStorage();
if (!getActiveProfilePubkey() && Array.isArray(profiles) && profiles.length) {
setStoredActiveProfilePubkey(profiles[0].pubkey, { persist: true });
}
const result = this.authService.loadSavedProfilesFromStorage();
this.renderSavedProfiles();
return result;
}
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
const updated = syncSavedProfileFromCacheState(pubkey, { persist });
const updated = this.authService.syncSavedProfileFromCache(pubkey, {
persist,
});
if (updated) {
this.renderSavedProfiles();
}
@@ -687,31 +694,19 @@ class bitvidApp {
}
loadProfileCacheFromStorage() {
hydrateProfileCacheFromStorage();
this.authService.loadProfileCacheFromStorage();
}
persistProfileCacheToStorage() {
persistProfileCacheState();
this.authService.persistProfileCache();
}
getProfileCacheEntry(pubkey) {
return getCachedProfileEntry(pubkey);
return this.authService.getProfileCacheEntry(pubkey);
}
setProfileCacheEntry(pubkey, profile) {
const entry = setCachedProfileEntry(pubkey, profile);
if (!entry) {
return null;
}
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
const didUpdateSavedProfile = this.syncSavedProfileFromCache(normalizedPubkey, {
persist: true,
});
if (!didUpdateSavedProfile) {
this.renderSavedProfiles();
}
return entry;
return this.authService.setProfileCacheEntry(pubkey, profile);
}
persistActiveProfileSelection(pubkey, { persist = true } = {}) {
@@ -754,7 +749,7 @@ class bitvidApp {
// 3) For each author, fetchAndRenderProfile with forceRefresh = true
for (const authorPubkey of uniqueAuthors) {
this.fetchAndRenderProfile(authorPubkey, true);
this.authService.fetchAndRenderProfile(authorPubkey, true);
}
}
@@ -767,8 +762,8 @@ class bitvidApp {
});
}
this.loadProfileCacheFromStorage();
this.loadSavedProfilesFromStorage();
this.authService.hydrateFromStorage();
this.renderSavedProfiles();
// 1. Initialize the video modal (components/video-modal.html)
await this.initModal();
@@ -821,7 +816,7 @@ class bitvidApp {
if (savedPubKey) {
// Auto-login if a pubkey was saved
try {
await this.login(savedPubKey, { persistActive: false });
await this.authService.login(savedPubKey, { persistActive: false });
} catch (error) {
console.error("Auto-login failed:", error);
}
@@ -3364,8 +3359,13 @@ class bitvidApp {
}
if (this.profileLogoutBtn && !this.profileLogoutBtn.dataset.bound) {
this.profileLogoutBtn.dataset.bound = "true";
this.profileLogoutBtn.addEventListener("click", () => {
this.logout();
this.profileLogoutBtn.addEventListener("click", async () => {
try {
await this.authService.logout();
} catch (error) {
console.error("Logout failed:", error);
this.showError("Failed to logout. Please try again.");
}
this.hideProfileModal();
});
}
@@ -4251,8 +4251,9 @@ class bitvidApp {
setLoadingState(true);
try {
const pubkey = await nostrClient.login({
const { pubkey } = await this.authService.requestLogin({
allowAccountSelection: true,
autoApply: false,
});
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
@@ -4275,7 +4276,7 @@ class bitvidApp {
let profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
if (!profileMeta) {
await this.loadOwnProfile(normalizedPubkey);
await this.authService.loadOwnProfile(normalizedPubkey);
profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
}
@@ -5030,8 +5031,13 @@ class bitvidApp {
setupEventListeners() {
// 1) Logout button
if (this.logoutButton) {
this.logoutButton.addEventListener("click", () => {
this.logout();
this.logoutButton.addEventListener("click", async () => {
try {
await this.authService.logout();
} catch (error) {
console.error("Logout failed:", error);
this.showError("Failed to logout. Please try again.");
}
});
}
@@ -5101,15 +5107,15 @@ class bitvidApp {
"[app.js] loginNIP07 clicked! Attempting extension login..."
);
try {
const pubkey = await nostrClient.login(); // call the extension
const { pubkey } = await this.authService.requestLogin();
console.log("[NIP-07] login returned pubkey:", pubkey);
await this.login(pubkey, { persistActive: true });
// Hide the login modal
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
setGlobalModalState("login", false);
if (pubkey) {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
setGlobalModalState("login", false);
}
}
} catch (err) {
console.error("[NIP-07 login error]", err);
@@ -5244,87 +5250,11 @@ class bitvidApp {
* Attempt to load the user's own profile from Nostr (kind:0).
*/
async loadOwnProfile(pubkey) {
try {
const events = await nostrClient.pool.list(nostrClient.relays, [
{ kinds: [0], authors: [pubkey], limit: 1 },
]);
let displayName = "User";
let picture = "assets/svg/default-profile.svg";
if (events.length && events[0].content) {
const data = JSON.parse(events[0].content);
displayName = data.name || data.display_name || "User";
picture = data.picture || "assets/svg/default-profile.svg";
}
this.setProfileCacheEntry(pubkey, {
name: displayName,
picture,
});
// If you have a top-bar avatar (profileAvatar)
if (this.profileAvatar) {
this.profileAvatar.src = picture;
}
// If you want to show name somewhere
if (this.profileModalName) {
this.profileModalName.textContent = displayName;
}
if (this.profileModalAvatar) {
this.profileModalAvatar.src = picture;
}
if (this.profileModalNpub) {
const encoded = this.safeEncodeNpub(pubkey);
this.profileModalNpub.textContent = encoded
? truncateMiddle(encoded, 48)
: "Not signed in";
}
if (this.profileChannelLink) {
const targetNpub = this.safeEncodeNpub(pubkey);
if (targetNpub) {
this.profileChannelLink.href = `#view=channel-profile&npub=${targetNpub}`;
this.profileChannelLink.dataset.targetNpub = targetNpub;
this.profileChannelLink.classList.remove("hidden");
} else {
this.profileChannelLink.removeAttribute("href");
delete this.profileChannelLink.dataset.targetNpub;
this.profileChannelLink.classList.add("hidden");
}
}
} catch (error) {
console.error("loadOwnProfile error:", error);
}
return this.authService.loadOwnProfile(pubkey);
}
async fetchAndRenderProfile(pubkey, forceRefresh = false) {
const cacheEntry = this.getProfileCacheEntry(pubkey);
if (cacheEntry) {
this.updateProfileInDOM(pubkey, cacheEntry.profile);
if (!forceRefresh) {
return;
}
}
// 2) Otherwise, fetch from Nostr
try {
const userEvents = await nostrClient.pool.list(nostrClient.relays, [
{ kinds: [0], authors: [pubkey], limit: 1 },
]);
if (userEvents.length > 0 && userEvents[0].content) {
const data = JSON.parse(userEvents[0].content);
const profile = {
name: data.name || data.display_name || "Unknown",
picture: data.picture || "assets/svg/default-profile.svg",
};
// Cache it
this.setProfileCacheEntry(pubkey, profile);
// Update DOM
this.updateProfileInDOM(pubkey, profile);
}
} catch (err) {
console.error("Profile fetch error:", err);
}
return this.authService.fetchAndRenderProfile(pubkey, forceRefresh);
}
async batchFetchProfiles(authorSet) {
@@ -5628,39 +5558,41 @@ class bitvidApp {
const privEl = document.getElementById("uploadIsPrivate");
const commentsEl = document.getElementById("uploadEnableComments");
const title = titleEl?.value.trim() || "";
const url = urlEl?.value.trim() || "";
const magnet = magnetEl?.value.trim() || "";
const ws = wsEl?.value.trim() || "";
const xs = xsEl?.value.trim() || "";
const thumbnail = thumbEl?.value.trim() || "";
const description = descEl?.value.trim() || "";
const enableComments = commentsEl ? commentsEl.checked : true;
const payload = {
title,
url,
magnet,
thumbnail,
description,
ws,
xs,
enableComments,
title: titleEl?.value.trim() || "",
url: urlEl?.value.trim() || "",
magnet: magnetEl?.value.trim() || "",
ws: wsEl?.value.trim() || "",
xs: xsEl?.value.trim() || "",
thumbnail: thumbEl?.value.trim() || "",
description: descEl?.value.trim() || "",
enableComments: commentsEl ? commentsEl.checked : true,
};
await this.publishVideoNote(payload, {
onSuccess: () => {
if (titleEl) titleEl.value = "";
if (urlEl) urlEl.value = "";
if (magnetEl) magnetEl.value = "";
if (wsEl) wsEl.value = "";
if (xsEl) xsEl.value = "";
if (thumbEl) thumbEl.value = "";
if (descEl) descEl.value = "";
if (privEl) privEl.checked = false;
if (commentsEl) commentsEl.checked = true;
},
});
try {
await this.authService.handleUploadSubmit(payload, {
publish: (data) =>
this.publishVideoNote(data, {
onSuccess: () => {
if (titleEl) titleEl.value = "";
if (urlEl) urlEl.value = "";
if (magnetEl) magnetEl.value = "";
if (wsEl) wsEl.value = "";
if (xsEl) xsEl.value = "";
if (thumbEl) thumbEl.value = "";
if (descEl) descEl.value = "";
if (privEl) privEl.checked = false;
if (commentsEl) commentsEl.checked = true;
},
}),
});
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Login required to publish videos.";
this.showError(message);
}
}
/**
@@ -5669,60 +5601,21 @@ class bitvidApp {
* accounts without touching cached avatars.
*/
removeSavedProfile(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
const target = normalized || (typeof pubkey === "string" ? pubkey.trim() : "");
if (!target) {
return;
const { removed } = this.authService.removeSavedProfile(pubkey);
if (removed) {
this.renderSavedProfiles();
}
const nextProfiles = this.savedProfiles.filter(
(entry) => entry.pubkey !== target
);
if (nextProfiles.length === this.savedProfiles.length) {
return;
}
this.savedProfiles = nextProfiles;
if (this.activeProfilePubkey === target) {
this.activeProfilePubkey = null;
}
persistSavedProfiles();
this.renderSavedProfiles();
}
async switchProfile(pubkey) {
const normalizedTarget = this.normalizeHexPubkey(pubkey);
if (!normalizedTarget) {
this.showError("Unable to switch profiles: invalid account.");
return;
}
const normalizedActive = this.normalizeHexPubkey(this.activeProfilePubkey);
if (normalizedActive && normalizedActive === normalizedTarget) {
this.hideProfileModal();
return;
}
try {
await nostrClient.login({
allowAccountSelection: true,
expectPubkey: normalizedTarget,
});
const result = await this.authService.switchProfile(pubkey);
if (!result?.switched) {
this.hideProfileModal();
return;
}
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Profile switch was cancelled.";
this.showError(message);
return;
}
try {
await this.login(normalizedTarget, { persistActive: true });
} catch (error) {
console.error("Failed to finalize profile switch:", error);
console.error("Failed to switch profiles:", error);
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
@@ -5732,42 +5625,58 @@ class bitvidApp {
}
this.profileSwitcherSelectionPubkey = null;
let reordered = false;
const currentIndex = this.savedProfiles.findIndex(
(entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedTarget
);
if (currentIndex > 0) {
const [moved] = this.savedProfiles.splice(currentIndex, 1);
this.savedProfiles.unshift(moved);
reordered = true;
}
if (reordered) {
persistSavedProfiles({ persistActive: true });
}
this.renderSavedProfiles();
this.hideProfileModal();
}
async applyPostLoginState() {
await this.refreshAdminPaneState();
updateActiveProfileUI(pubkey, profile = {}) {
const displayName = profile.name || "User";
const picture = profile.picture || "assets/svg/default-profile.svg";
if (this.profileAvatar) {
this.profileAvatar.src = picture;
}
if (this.profileModalName) {
this.profileModalName.textContent = displayName;
}
if (this.profileModalAvatar) {
this.profileModalAvatar.src = picture;
}
if (this.profileModalNpub) {
const encoded = this.safeEncodeNpub(pubkey);
this.profileModalNpub.textContent = encoded
? truncateMiddle(encoded, 48)
: "Not signed in";
}
if (this.profileChannelLink) {
const targetNpub = this.safeEncodeNpub(pubkey);
if (targetNpub) {
this.profileChannelLink.href = `#view=channel-profile&npub=${targetNpub}`;
this.profileChannelLink.dataset.targetNpub = targetNpub;
this.profileChannelLink.classList.remove("hidden");
} else {
this.profileChannelLink.removeAttribute("href");
delete this.profileChannelLink.dataset.targetNpub;
this.profileChannelLink.classList.add("hidden");
}
}
}
async handleAuthLogin(detail = {}) {
if (detail?.identityChanged) {
this.resetViewLoggingState();
}
this.renderSavedProfiles();
try {
await userBlocks.loadBlocks(this.pubkey);
await this.refreshAdminPaneState();
} catch (error) {
console.warn("Failed to load personal block list:", error);
console.warn("Failed to refresh admin pane after login:", error);
}
this.populateBlockedList();
try {
await relayManager.loadRelayList(this.pubkey);
} catch (error) {
console.warn("Failed to load relay list:", error);
}
this.populateProfileRelays();
if (this.loginButton) {
@@ -5800,218 +5709,93 @@ class bitvidApp {
this.subscriptionsLink.classList.remove("hidden");
}
if (this.pubkey) {
this.loadOwnProfile(this.pubkey);
const activePubkey = detail?.pubkey || this.pubkey;
if (activePubkey && detail?.postLogin?.profile) {
this.updateActiveProfileUI(activePubkey, detail.postLogin.profile);
}
await this.loadVideos();
this.forceRefreshAllProfiles();
await this.refreshCloudflareBucketPreview();
}
/**
* Called upon successful login.
*/
async login(pubkey, options = {}) {
console.log("[app.js] login() called with pubkey =", pubkey);
let normalizedOptions = options;
if (typeof normalizedOptions === "boolean") {
normalizedOptions = { persistActive: normalizedOptions };
} else if (!normalizedOptions || typeof normalizedOptions !== "object") {
normalizedOptions = {};
}
const { persistActive = true } = normalizedOptions;
const previousActivePubkey =
this.normalizeHexPubkey(this.pubkey) ||
(typeof this.pubkey === "string" ? this.pubkey.trim() : "");
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
const nextActivePubkey =
normalizedPubkey || (typeof pubkey === "string" ? pubkey.trim() : "");
const identityChanged = previousActivePubkey !== nextActivePubkey;
if (identityChanged) {
this.resetViewLoggingState();
}
const trimmedPubkey =
typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : "";
if (normalizedPubkey) {
this.pubkey = normalizedPubkey;
if (nostrClient && typeof nostrClient === "object") {
nostrClient.pubkey = normalizedPubkey;
}
} else {
this.pubkey = pubkey;
if (nostrClient && typeof nostrClient === "object") {
nostrClient.pubkey = trimmedPubkey ? trimmedPubkey.toLowerCase() : "";
}
}
this.currentUserNpub = this.safeEncodeNpub(this.pubkey);
if (
identityChanged &&
typeof window !== "undefined" &&
typeof window.dispatchEvent === "function"
) {
window.dispatchEvent(
new CustomEvent("bitvid:auth-changed", {
detail: {
pubkey: nextActivePubkey || null,
previousPubkey: previousActivePubkey || null,
},
})
);
}
let savedProfilesMutated = false;
if (normalizedPubkey) {
const cacheEntry = this.profileCache.get(normalizedPubkey);
const cachedProfile = cacheEntry?.profile || {};
const existingIndex = this.savedProfiles.findIndex(
(candidate) => candidate.pubkey === normalizedPubkey
);
const existingEntry =
existingIndex >= 0 ? this.savedProfiles[existingIndex] : null;
const npub = this.safeEncodeNpub(normalizedPubkey);
const nextEntry = {
pubkey: normalizedPubkey,
npub: npub || existingEntry?.npub || null,
name: cachedProfile.name || existingEntry?.name || "",
picture: cachedProfile.picture || existingEntry?.picture || "",
authType: "nip07",
};
if (existingIndex >= 0) {
const currentEntry = this.savedProfiles[existingIndex];
const changed =
currentEntry.npub !== nextEntry.npub ||
currentEntry.name !== nextEntry.name ||
currentEntry.picture !== nextEntry.picture ||
currentEntry.authType !== nextEntry.authType;
if (changed) {
this.savedProfiles[existingIndex] = nextEntry;
savedProfilesMutated = true;
}
} else {
this.savedProfiles.push(nextEntry);
savedProfilesMutated = true;
}
this.persistActiveProfileSelection(normalizedPubkey, {
persist: persistActive,
});
if (!persistActive && savedProfilesMutated) {
persistSavedProfiles({ persistActive: false });
}
} else {
if (persistActive) {
console.warn(
"[app.js] login() requested storage persistence but pubkey was invalid; skipping saved profile update."
);
}
}
if (!normalizedPubkey && savedProfilesMutated) {
persistSavedProfiles({ persistActive: false });
}
await this.applyPostLoginState();
}
/**
* Logout logic
*/
async logout() {
nostrClient.logout();
const previousPubkey =
this.normalizeHexPubkey(this.pubkey) ||
(typeof this.pubkey === "string" ? this.pubkey.trim() : "");
async handleAuthLogout(detail = {}) {
this.resetViewLoggingState();
this.pubkey = null;
this.currentUserNpub = null;
if (
previousPubkey &&
typeof window !== "undefined" &&
typeof window.dispatchEvent === "function"
) {
window.dispatchEvent(
new CustomEvent("bitvid:auth-changed", {
detail: {
pubkey: null,
previousPubkey,
},
})
);
}
this.renderSavedProfiles();
this.persistActiveProfileSelection(null, { persist: true });
userBlocks.reset();
this.populateBlockedList();
relayManager.reset();
this.populateProfileRelays();
// Show the login button again
if (this.loginButton) {
this.loginButton.classList.remove("hidden");
this.loginButton.removeAttribute("hidden");
this.loginButton.style.display = "";
}
// Hide logout or userStatus
if (this.logoutButton) {
this.logoutButton.classList.add("hidden");
}
if (this.userStatus) {
this.userStatus.classList.add("hidden");
}
if (this.userPubKey) {
this.userPubKey.textContent = "";
}
// Hide upload & profile
if (this.uploadButton) {
this.uploadButton.classList.add("hidden");
this.uploadButton.setAttribute("hidden", "");
this.uploadButton.style.display = "none";
}
if (this.profileButton) {
this.profileButton.classList.add("hidden");
this.profileButton.setAttribute("hidden", "");
this.profileButton.style.display = "none";
}
if (this.profileChannelLink) {
this.profileChannelLink.classList.add("hidden");
this.profileChannelLink.removeAttribute("href");
delete this.profileChannelLink.dataset.targetNpub;
}
// Hide the Subscriptions link
if (this.subscriptionsLink) {
this.subscriptionsLink.classList.add("hidden");
}
await this.refreshAdminPaneState();
try {
await this.refreshAdminPaneState();
} catch (error) {
console.warn("Failed to refresh admin pane after logout:", error);
}
this.populateBlockedList();
this.populateProfileRelays();
// Refresh the video list so user sees only public videos again
await this.loadVideos();
// Force a fresh fetch of all profile pictures/names (public ones in this case)
this.forceRefreshAllProfiles();
await this.refreshCloudflareBucketPreview();
}
handleProfileUpdated(detail = {}) {
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.normalizeHexPubkey(this.pubkey) === normalizedPubkey) {
this.updateActiveProfileUI(normalizedPubkey, profile);
}
}
}
/**
* Cleanup resources on unload or modal close.
*/
+652
View File
@@ -0,0 +1,652 @@
// js/services/authService.js
import {
setPubkey,
setCurrentUserNpub,
getPubkey,
getCurrentUserNpub,
} from "../state/appState.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,
} from "../state/cache.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",
};
export default class AuthService {
constructor({ nostrClient, userBlocks, relayManager, logger } = {}) {
this.nostrClient = nostrClient || null;
this.userBlocks = userBlocks || null;
this.relayManager = relayManager || null;
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) {
console.warn("[AuthService] logger threw", logError);
}
});
}
log(...args) {
try {
this.logger(...args);
} catch (error) {
console.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 } = hydrateSavedProfilesFromStorage();
if (!getActiveProfilePubkey() && 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(options = {}) {
if (!this.nostrClient || typeof this.nostrClient.login !== "function") {
throw new Error("Nostr login is not available.");
}
const result = await this.nostrClient.login(options);
const pubkey =
typeof result === "string"
? result
: result && typeof result === "object"
? result.pubkey || result.publicKey || ""
: "";
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
if (!trimmed) {
return { pubkey: null };
}
if (options?.autoApply === false) {
return { pubkey: trimmed };
}
await this.login(trimmed, {
persistActive: options?.persistActive !== false,
});
return { pubkey: trimmed };
}
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 : { persistActive: true };
const persistActive =
normalizedOptions.persistActive === false ? false : true;
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;
if (normalized) {
setPubkey(normalized);
if (this.nostrClient && typeof this.nostrClient === "object") {
this.nostrClient.pubkey = normalized;
}
} else {
setPubkey(nextPubkey);
if (this.nostrClient && typeof this.nostrClient === "object") {
this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : "";
}
}
const npub = this.safeEncodeNpub(nextPubkey);
setCurrentUserNpub(npub);
let savedProfilesMutated = false;
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: "nip07",
};
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;
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 postLogin = await this.applyPostLoginState();
const detail = {
pubkey: getPubkey(),
npub: getCurrentUserNpub(),
previousPubkey: previousPubkey || null,
identityChanged,
savedProfiles: this.cloneSavedProfiles(),
activeProfilePubkey: getActiveProfilePubkey(),
postLogin,
};
this.emit("auth:login", detail);
return detail;
}
async applyPostLoginState() {
const activePubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
if (!activePubkey) {
return { pubkey: null };
}
let blocksLoaded = false;
if (this.userBlocks && typeof this.userBlocks.loadBlocks === "function") {
try {
await this.userBlocks.loadBlocks(activePubkey);
blocksLoaded = true;
} catch (error) {
this.log("[AuthService] Failed to load block list", error);
}
}
let relaysLoaded = false;
if (this.relayManager && typeof this.relayManager.loadRelayList === "function") {
try {
await this.relayManager.loadRelayList(activePubkey);
relaysLoaded = true;
} catch (error) {
this.log("[AuthService] Failed to load relay list", error);
}
}
let profile = null;
try {
profile = await this.loadOwnProfile(activePubkey);
} catch (error) {
this.log("[AuthService] Failed to load own profile", error);
}
return {
pubkey: activePubkey,
blocksLoaded,
relaysLoaded,
profile,
};
}
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 });
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) {
const normalizedTarget = this.normalizeHexPubkey(pubkey);
if (!normalizedTarget) {
throw new Error("Unable to switch profiles: invalid account.");
}
const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey());
if (normalizedActive && normalizedActive === normalizedTarget) {
return { switched: false, reason: "already-active" };
}
try {
await this.requestLogin({
allowAccountSelection: true,
expectPubkey: normalizedTarget,
});
} catch (error) {
throw error;
}
const detail = await this.login(normalizedTarget, { persistActive: true });
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 });
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 events = await this.nostrClient.pool.list(this.nostrClient.relays, [
{ kinds: [0], authors: [normalized], limit: 1 },
]);
let profile = FALLBACK_PROFILE;
if (Array.isArray(events) && events.length && events[0]?.content) {
try {
const data = JSON.parse(events[0].content);
profile = {
name: data.name || data.display_name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
};
} catch (error) {
this.log("[AuthService] Failed to parse profile metadata", error);
}
}
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: "load-own-profile",
});
return 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 results = await Promise.all(
this.nostrClient.relays.map((relayUrl) =>
this.nostrClient.pool.list([relayUrl], [
{ kinds: [0], authors: [normalized], limit: 1 },
])
)
);
const events = results.flat();
let newest = null;
for (const event of events) {
if (!event || event.pubkey !== normalized || !event.content) {
continue;
}
if (!newest || event.created_at > newest.created_at) {
newest = event;
}
}
if (newest?.content) {
const data = JSON.parse(newest.content);
const profile = {
name: data.name || data.display_name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
};
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: forceRefresh ? "force-refresh" : "fetch-profile",
});
return 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;
}
}