mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
Merge pull request #818 from PR0M3TH3AN/codex/refactor-login-modal-and-profile-handling
Refactor profile add flow to use login modal controller
This commit is contained in:
3 files changed
+680
-260
No files matched your search
@@ -415,7 +415,6 @@ class Application {
|
||||
// Profile and login modal controller state
|
||||
this.profileController = null;
|
||||
this.loginModalController = null;
|
||||
this.loginModalPendingTask = null;
|
||||
this.currentUserNpub = null;
|
||||
|
||||
this.initializeLoginModalController({ logIfMissing: true });
|
||||
@@ -567,6 +566,10 @@ class Application {
|
||||
persistSavedProfiles: (options) => persistSavedProfiles(options),
|
||||
watchHistoryService,
|
||||
authService: this.authService,
|
||||
requestAddProfileLogin: (options) =>
|
||||
this.requestProfileAdditionLogin(options),
|
||||
describeLoginError: (error, fallbackMessage) =>
|
||||
this.describeLoginError(error, fallbackMessage),
|
||||
log: (...args) => this.log(...args),
|
||||
closeAllMoreMenus: (options) => this.closeAllMoreMenus(options),
|
||||
clipboard:
|
||||
@@ -595,7 +598,7 @@ class Application {
|
||||
onClose: () => this.handleProfileModalClosed(),
|
||||
onLogout: async () => this.requestLogout(),
|
||||
onChannelLink: (element) => this.handleProfileChannelLink(element),
|
||||
onAddAccount: (controller) => this.handleAddProfile(controller),
|
||||
onAddAccount: (payload) => this.handleAddProfile(payload),
|
||||
onRequestSwitchProfile: (payload) =>
|
||||
this.handleProfileSwitchRequest(payload),
|
||||
onRelayOperation: (payload) =>
|
||||
@@ -1905,17 +1908,6 @@ class Application {
|
||||
|
||||
devLogger.log("[LoginModal] Login result returned pubkey:", pubkey);
|
||||
|
||||
const pendingTask =
|
||||
this.loginModalPendingTask &&
|
||||
this.loginModalPendingTask.type === "add-profile"
|
||||
? this.loginModalPendingTask
|
||||
: null;
|
||||
|
||||
if (pendingTask) {
|
||||
pendingTask.resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result || typeof result !== "object") {
|
||||
return;
|
||||
}
|
||||
@@ -1959,36 +1951,6 @@ class Application {
|
||||
? fallbackMessage.trim()
|
||||
: "Failed to login. Please try again.";
|
||||
|
||||
const pendingTask =
|
||||
this.loginModalPendingTask &&
|
||||
this.loginModalPendingTask.type === "add-profile"
|
||||
? this.loginModalPendingTask
|
||||
: null;
|
||||
|
||||
if (pendingTask) {
|
||||
const rejectionError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(normalizedMessage || "Failed to login.");
|
||||
if (
|
||||
normalizedMessage &&
|
||||
rejectionError &&
|
||||
normalizedMessage !== rejectionError.message
|
||||
) {
|
||||
rejectionError.message = normalizedMessage;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
error.code &&
|
||||
!rejectionError.code
|
||||
) {
|
||||
rejectionError.code = error.code;
|
||||
}
|
||||
pendingTask.reject(rejectionError);
|
||||
return;
|
||||
}
|
||||
|
||||
userLogger.warn(
|
||||
provider && provider.id
|
||||
? `[LoginModal] Login failed for provider ${provider.id}.`
|
||||
@@ -2001,12 +1963,15 @@ class Application {
|
||||
this.initializeLoginModalController();
|
||||
if (
|
||||
this.loginModalController &&
|
||||
typeof this.loginModalController.setNextRequestLoginOptions ===
|
||||
"function"
|
||||
typeof this.loginModalController.requestAddProfileLogin === "function"
|
||||
) {
|
||||
try {
|
||||
return await this.openLoginModalForProfileAddition({
|
||||
return await this.loginModalController.requestAddProfileLogin({
|
||||
triggerElement,
|
||||
requestOptions: {
|
||||
allowAccountSelection: true,
|
||||
autoApply: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -2035,213 +2000,18 @@ class Application {
|
||||
});
|
||||
}
|
||||
|
||||
async openLoginModalForProfileAddition({ triggerElement } = {}) {
|
||||
const controller = this.loginModalController;
|
||||
if (!controller) {
|
||||
const error = new Error("Login modal unavailable.");
|
||||
error.code = "modal-unavailable";
|
||||
throw error;
|
||||
}
|
||||
async handleAddProfile(payload = {}) {
|
||||
const loginResult =
|
||||
payload && typeof payload === "object" ? payload.loginResult : null;
|
||||
|
||||
if (typeof controller.setNextRequestLoginOptions === "function") {
|
||||
controller.setNextRequestLoginOptions({
|
||||
allowAccountSelection: true,
|
||||
autoApply: false,
|
||||
});
|
||||
}
|
||||
|
||||
const loginModal =
|
||||
prepareStaticModal({ id: "loginModal" }) ||
|
||||
document.getElementById("loginModal");
|
||||
|
||||
if (!loginModal) {
|
||||
const error = new Error("Login modal unavailable.");
|
||||
error.code = "modal-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (this.loginModalPendingTask) {
|
||||
const error = new Error(
|
||||
"Another login operation is already in progress.",
|
||||
if (!loginResult) {
|
||||
devLogger.warn(
|
||||
"[profileModal] Ignoring add profile callback without an authentication result.",
|
||||
);
|
||||
error.code = "login-in-progress";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isModalOpen = () =>
|
||||
loginModal.getAttribute("data-open") === "true" &&
|
||||
!loginModal.classList.contains("hidden");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let observer = null;
|
||||
let pollTimer = null;
|
||||
let settled = false;
|
||||
|
||||
const pendingTask = { type: "add-profile" };
|
||||
|
||||
const finalize =
|
||||
(handler) =>
|
||||
(value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
if (
|
||||
pollTimer !== null &&
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.clearInterval === "function"
|
||||
) {
|
||||
window.clearInterval(pollTimer);
|
||||
}
|
||||
pollTimer = null;
|
||||
if (this.loginModalPendingTask === pendingTask) {
|
||||
this.loginModalPendingTask = null;
|
||||
}
|
||||
if (!isModalOpen() && typeof setGlobalModalState === "function") {
|
||||
setGlobalModalState("login", false);
|
||||
}
|
||||
handler(value);
|
||||
};
|
||||
|
||||
pendingTask.resolve = finalize((value) => resolve(value));
|
||||
pendingTask.reject = finalize((errorValue) => reject(errorValue));
|
||||
this.loginModalPendingTask = pendingTask;
|
||||
|
||||
if (typeof MutationObserver === "function") {
|
||||
observer = new MutationObserver(() => {
|
||||
if (!isModalOpen()) {
|
||||
const cancelError = new Error("Login cancelled.");
|
||||
cancelError.code = "login-cancelled";
|
||||
pendingTask.reject(cancelError);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(loginModal, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-open", "class"],
|
||||
});
|
||||
} else if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.setInterval === "function"
|
||||
) {
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (!isModalOpen()) {
|
||||
const cancelError = new Error("Login cancelled.");
|
||||
cancelError.code = "login-cancelled";
|
||||
pendingTask.reject(cancelError);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const opened = openStaticModal(loginModal, { triggerElement });
|
||||
const currentlyOpen = isModalOpen();
|
||||
|
||||
if (!opened && !currentlyOpen) {
|
||||
const openError = new Error("Unable to open login modal.");
|
||||
openError.code = "modal-open-failed";
|
||||
pendingTask.reject(openError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof setGlobalModalState === "function") {
|
||||
setGlobalModalState("login", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleAddProfile(controller) {
|
||||
const button =
|
||||
(controller && controller.addAccountButton) ||
|
||||
this.profileController?.addAccountButton ||
|
||||
null;
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (button.dataset.state === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
const titleEl = button.querySelector('[data-profile-add-title]');
|
||||
const hintEl = button.querySelector('[data-profile-add-hint]');
|
||||
const originalTitle = titleEl ? titleEl.textContent : "";
|
||||
const originalHint = hintEl ? hintEl.textContent : "";
|
||||
const originalAriaLabel = button.getAttribute("aria-label");
|
||||
const originalDisabled = button.disabled;
|
||||
|
||||
const setLoadingState = (isLoading) => {
|
||||
button.disabled = isLoading ? true : originalDisabled;
|
||||
if (isLoading) {
|
||||
button.dataset.state = "loading";
|
||||
} else {
|
||||
delete button.dataset.state;
|
||||
}
|
||||
button.setAttribute("aria-busy", isLoading ? "true" : "false");
|
||||
if (isLoading) {
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
} else if (originalDisabled) {
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
} else {
|
||||
button.removeAttribute("aria-disabled");
|
||||
}
|
||||
if (isLoading) {
|
||||
if (titleEl) {
|
||||
titleEl.textContent = "Connecting...";
|
||||
}
|
||||
if (hintEl) {
|
||||
hintEl.textContent = "Complete the login prompt from your provider.";
|
||||
}
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
"Connecting to your Nostr account",
|
||||
);
|
||||
} else {
|
||||
if (titleEl) {
|
||||
titleEl.textContent = originalTitle;
|
||||
}
|
||||
if (hintEl) {
|
||||
hintEl.textContent = originalHint;
|
||||
}
|
||||
if (originalAriaLabel === null) {
|
||||
button.removeAttribute("aria-label");
|
||||
} else {
|
||||
button.setAttribute("aria-label", originalAriaLabel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setLoadingState(true);
|
||||
|
||||
try {
|
||||
let loginResult;
|
||||
try {
|
||||
loginResult = await this.requestProfileAdditionLogin({
|
||||
triggerElement: button,
|
||||
});
|
||||
} catch (requestError) {
|
||||
const cancellationCodes = new Set([
|
||||
"login-cancelled",
|
||||
"user-cancelled",
|
||||
"modal-dismissed",
|
||||
]);
|
||||
if (
|
||||
requestError &&
|
||||
typeof requestError === "object" &&
|
||||
typeof requestError.code === "string" &&
|
||||
cancellationCodes.has(requestError.code)
|
||||
) {
|
||||
devLogger.log(
|
||||
"[profileModal] Add profile login cancelled by user.",
|
||||
requestError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw requestError;
|
||||
}
|
||||
|
||||
const { pubkey, authType: loginAuthType, providerId } =
|
||||
typeof loginResult === "object" && loginResult
|
||||
? loginResult
|
||||
@@ -2257,7 +2027,12 @@ class Application {
|
||||
: null;
|
||||
|
||||
const resolvedAuthType = (() => {
|
||||
const candidates = [detailAuthType, loginAuthType, detailProviderId, providerId];
|
||||
const candidates = [
|
||||
detailAuthType,
|
||||
loginAuthType,
|
||||
detailProviderId,
|
||||
providerId,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
@@ -2292,8 +2067,7 @@ class Application {
|
||||
}
|
||||
|
||||
const alreadySaved = this.savedProfiles.some(
|
||||
(entry) =>
|
||||
this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey,
|
||||
(entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey,
|
||||
);
|
||||
if (alreadySaved) {
|
||||
this.showSuccess("That profile is already saved on this device.");
|
||||
@@ -2345,13 +2119,31 @@ class Application {
|
||||
"[profileModal] Failed to add profile via authentication provider:",
|
||||
error,
|
||||
);
|
||||
|
||||
const message = this.describeLoginError(
|
||||
error,
|
||||
"Couldn't add that profile. Please try again.",
|
||||
);
|
||||
this.showError(message);
|
||||
} finally {
|
||||
setLoadingState(false);
|
||||
|
||||
const rejectionError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(message || "Couldn't add that profile. Please try again.");
|
||||
|
||||
if (message && rejectionError.message !== message) {
|
||||
rejectionError.message = message;
|
||||
}
|
||||
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
error.code &&
|
||||
!rejectionError.code
|
||||
) {
|
||||
rejectionError.code = error.code;
|
||||
}
|
||||
|
||||
throw rejectionError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2401,12 +2193,26 @@ class Application {
|
||||
return false;
|
||||
}
|
||||
|
||||
const preparedModal =
|
||||
prepareStaticModal({ root: loginModalElement }) || loginModalElement;
|
||||
|
||||
loginModalElement = preparedModal;
|
||||
|
||||
const closeLoginModal = () => {
|
||||
if (closeStaticModal("loginModal")) {
|
||||
if (closeStaticModal(loginModalElement)) {
|
||||
setGlobalModalState("login", false);
|
||||
}
|
||||
};
|
||||
|
||||
const openLoginModal = ({ modal, triggerElement } = {}) => {
|
||||
const target =
|
||||
modal instanceof HTMLElement ? modal : loginModalElement;
|
||||
return openStaticModal(target, { triggerElement });
|
||||
};
|
||||
|
||||
const prepareLoginModal = (modal) =>
|
||||
prepareStaticModal({ root: modal || loginModalElement });
|
||||
|
||||
try {
|
||||
this.loginModalController = new LoginModalController({
|
||||
modalElement: loginModalElement,
|
||||
@@ -2444,6 +2250,11 @@ class Application {
|
||||
},
|
||||
helpers: {
|
||||
closeModal: closeLoginModal,
|
||||
openModal: ({ modal, triggerElement } = {}) =>
|
||||
openLoginModal({ modal, triggerElement }),
|
||||
prepareModal: (modal) => prepareLoginModal(modal),
|
||||
setModalState: (_, isOpen) =>
|
||||
setGlobalModalState("login", isOpen === undefined ? false : !!isOpen),
|
||||
describeLoginError: (error, fallbackMessage) =>
|
||||
this.describeLoginError(
|
||||
error,
|
||||
|
||||
@@ -3,6 +3,15 @@ import { renderQrCode } from "../utils/qrcode.js";
|
||||
|
||||
const noop = () => {};
|
||||
const SLOW_PROVIDER_DELAY_MS = 8_000;
|
||||
const MODAL_CLOSE_POLL_INTERVAL_MS = 200;
|
||||
|
||||
function createError(code, message) {
|
||||
const error = new Error(message);
|
||||
if (code) {
|
||||
error.code = code;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function toArray(input) {
|
||||
if (!input) {
|
||||
@@ -247,6 +256,12 @@ export default class LoginModalController {
|
||||
typeof helpers.describeLoginError === "function"
|
||||
? helpers.describeLoginError
|
||||
: (_, fallbackMessage) => fallbackMessage,
|
||||
openModal:
|
||||
typeof helpers.openModal === "function" ? helpers.openModal : null,
|
||||
prepareModal:
|
||||
typeof helpers.prepareModal === "function" ? helpers.prepareModal : null,
|
||||
setModalState:
|
||||
typeof helpers.setModalState === "function" ? helpers.setModalState : null,
|
||||
};
|
||||
|
||||
this.providerContainer = null;
|
||||
@@ -267,6 +282,8 @@ export default class LoginModalController {
|
||||
this.lastRemoteSignerStatus = null;
|
||||
this.nextRequestLoginOptions = null;
|
||||
this.nextRequestLoginOptionsResolver = null;
|
||||
this.pendingTask = null;
|
||||
this.modalPrepared = false;
|
||||
|
||||
this.initializeRemoteSignerStatus();
|
||||
this.initialized = false;
|
||||
@@ -299,6 +316,309 @@ export default class LoginModalController {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
ensureModalPrepared() {
|
||||
if (!(this.modalElement instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.modalPrepared && typeof this.helpers.prepareModal === "function") {
|
||||
try {
|
||||
const prepared = this.helpers.prepareModal(this.modalElement);
|
||||
if (prepared instanceof HTMLElement) {
|
||||
this.modalElement = prepared;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to prepare login modal for accessibility:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.modalPrepared = true;
|
||||
return this.modalElement instanceof HTMLElement ? this.modalElement : null;
|
||||
}
|
||||
|
||||
isModalOpen() {
|
||||
const modal = this.modalElement;
|
||||
if (!(modal instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof modal.dataset?.open === "string") {
|
||||
return modal.dataset.open === "true";
|
||||
}
|
||||
|
||||
const attr = modal.getAttribute("data-open");
|
||||
if (typeof attr === "string") {
|
||||
return attr === "true";
|
||||
}
|
||||
|
||||
return !modal.classList.contains("hidden");
|
||||
}
|
||||
|
||||
openModal({ triggerElement } = {}) {
|
||||
const modal = this.ensureModalPrepared();
|
||||
if (!(modal instanceof HTMLElement)) {
|
||||
throw this.createModalUnavailableError();
|
||||
}
|
||||
|
||||
if (typeof this.helpers.openModal === "function") {
|
||||
try {
|
||||
const result = this.helpers.openModal({
|
||||
modal,
|
||||
triggerElement,
|
||||
});
|
||||
return result !== false;
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to open login modal via helper:",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
modal.classList.remove("hidden");
|
||||
modal.setAttribute("data-open", "true");
|
||||
return true;
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to toggle login modal visibility:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
createModalUnavailableError() {
|
||||
return createError("modal-unavailable", "Login modal unavailable.");
|
||||
}
|
||||
|
||||
createLoginInProgressError() {
|
||||
return createError(
|
||||
"login-in-progress",
|
||||
"Another login operation is already in progress.",
|
||||
);
|
||||
}
|
||||
|
||||
createModalOpenFailedError() {
|
||||
return createError("modal-open-failed", "Unable to open login modal.");
|
||||
}
|
||||
|
||||
createCancellationError() {
|
||||
return createError("login-cancelled", "Login cancelled.");
|
||||
}
|
||||
|
||||
startModalCloseWatch(task) {
|
||||
const modal = this.modalElement;
|
||||
if (!(modal instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (task.settled) {
|
||||
return;
|
||||
}
|
||||
task.reject(this.createCancellationError());
|
||||
};
|
||||
|
||||
if (typeof MutationObserver === "function") {
|
||||
try {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!this.isModalOpen()) {
|
||||
handleClose();
|
||||
}
|
||||
});
|
||||
observer.observe(modal, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-open", "class"],
|
||||
});
|
||||
task.observer = observer;
|
||||
return;
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to observe login modal close state:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.window &&
|
||||
typeof this.window.setInterval === "function" &&
|
||||
typeof this.window.clearInterval === "function"
|
||||
) {
|
||||
const timerId = this.window.setInterval(() => {
|
||||
if (!this.isModalOpen()) {
|
||||
handleClose();
|
||||
}
|
||||
}, MODAL_CLOSE_POLL_INTERVAL_MS);
|
||||
task.pollTimer = timerId;
|
||||
}
|
||||
}
|
||||
|
||||
stopModalCloseWatch(task) {
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.observer && typeof task.observer.disconnect === "function") {
|
||||
try {
|
||||
task.observer.disconnect();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to disconnect login modal observer:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
task.observer = null;
|
||||
|
||||
if (
|
||||
typeof task.pollTimer === "number" &&
|
||||
this.window &&
|
||||
typeof this.window.clearInterval === "function"
|
||||
) {
|
||||
try {
|
||||
this.window.clearInterval(task.pollTimer);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to clear login modal polling timer:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
task.pollTimer = null;
|
||||
}
|
||||
|
||||
createPendingModalTask(type, { triggerElement } = {}) {
|
||||
const modal = this.ensureModalPrepared();
|
||||
if (!(modal instanceof HTMLElement)) {
|
||||
throw this.createModalUnavailableError();
|
||||
}
|
||||
|
||||
if (this.pendingTask) {
|
||||
throw this.createLoginInProgressError();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const task = {
|
||||
type,
|
||||
resolve: null,
|
||||
reject: null,
|
||||
settled: false,
|
||||
observer: null,
|
||||
pollTimer: null,
|
||||
};
|
||||
|
||||
const finalize = (handler) => (value) => {
|
||||
if (task.settled) {
|
||||
return;
|
||||
}
|
||||
task.settled = true;
|
||||
this.stopModalCloseWatch(task);
|
||||
if (this.pendingTask === task) {
|
||||
this.pendingTask = null;
|
||||
}
|
||||
if (
|
||||
typeof this.helpers.setModalState === "function" &&
|
||||
!this.isModalOpen()
|
||||
) {
|
||||
try {
|
||||
this.helpers.setModalState("login", false);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to synchronize login modal state:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
handler(value);
|
||||
};
|
||||
|
||||
task.resolve = finalize((value) => resolve(value));
|
||||
task.reject = finalize((errorValue) => reject(errorValue));
|
||||
|
||||
this.pendingTask = task;
|
||||
|
||||
this.startModalCloseWatch(task);
|
||||
|
||||
let opened = false;
|
||||
try {
|
||||
opened = this.openModal({ triggerElement });
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Opening login modal threw:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!opened && !this.isModalOpen()) {
|
||||
task.reject(this.createModalOpenFailedError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.helpers.setModalState === "function") {
|
||||
try {
|
||||
this.helpers.setModalState("login", true);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to mark login modal as open in app state:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolvePendingTask(result, { type } = {}) {
|
||||
const task = this.pendingTask;
|
||||
if (!task || (type && task.type !== type) || typeof task.resolve !== "function") {
|
||||
return false;
|
||||
}
|
||||
task.resolve(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
rejectPendingTask(error, { type } = {}) {
|
||||
const task = this.pendingTask;
|
||||
if (!task || (type && task.type !== type) || typeof task.reject !== "function") {
|
||||
return false;
|
||||
}
|
||||
task.reject(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
requestAddProfileLogin({ triggerElement, requestOptions } = {}) {
|
||||
const options = {
|
||||
allowAccountSelection: true,
|
||||
autoApply: false,
|
||||
...(requestOptions && typeof requestOptions === "object"
|
||||
? requestOptions
|
||||
: {}),
|
||||
};
|
||||
|
||||
let promise;
|
||||
|
||||
try {
|
||||
promise = this.createPendingModalTask("add-profile", { triggerElement });
|
||||
} catch (error) {
|
||||
this.setNextRequestLoginOptions(null);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.setNextRequestLoginOptions(() => ({ ...options }));
|
||||
|
||||
return promise.finally(() => {
|
||||
this.setNextRequestLoginOptions(null);
|
||||
});
|
||||
}
|
||||
|
||||
getNostrClient() {
|
||||
const direct = this.services?.nostrClient;
|
||||
if (direct && typeof direct === "object") {
|
||||
@@ -1742,14 +2062,25 @@ export default class LoginModalController {
|
||||
result?.pubkey,
|
||||
);
|
||||
|
||||
const consumed = this.resolvePendingTask(result, { type: "add-profile" });
|
||||
const shouldClose =
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
typeof result.pubkey === "string" &&
|
||||
result.pubkey.trim();
|
||||
|
||||
if (shouldClose) {
|
||||
this.helpers.closeModal();
|
||||
}
|
||||
|
||||
if (consumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await safeInvokeAsync(this.callbacks.onLoginSuccess, {
|
||||
provider: entry.provider.source || entry.provider,
|
||||
result,
|
||||
});
|
||||
|
||||
if (result && typeof result.pubkey === "string" && result.pubkey.trim()) {
|
||||
this.helpers.closeModal();
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.error(
|
||||
`[LoginModalController] Login failed for ${providerId}:`,
|
||||
@@ -1770,9 +2101,35 @@ export default class LoginModalController {
|
||||
);
|
||||
}
|
||||
|
||||
const rejectionError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(message || "Failed to login. Please try again.");
|
||||
|
||||
if (message && message !== rejectionError.message) {
|
||||
rejectionError.message = message;
|
||||
}
|
||||
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
typeof error.code === "string" &&
|
||||
!rejectionError.code
|
||||
) {
|
||||
rejectionError.code = error.code;
|
||||
}
|
||||
|
||||
const consumed = this.rejectPendingTask(rejectionError, {
|
||||
type: "add-profile",
|
||||
});
|
||||
|
||||
if (consumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await safeInvokeAsync(this.callbacks.onLoginError, {
|
||||
provider: entry.provider.source || entry.provider,
|
||||
error,
|
||||
error: rejectionError,
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
@@ -1836,6 +2193,19 @@ export default class LoginModalController {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.pendingTask && typeof this.pendingTask.reject === "function") {
|
||||
try {
|
||||
this.pendingTask.reject(this.createCancellationError());
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to cancel pending login task during destroy:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingTask = null;
|
||||
|
||||
if (this.providerContainer && this.boundClickHandler) {
|
||||
this.providerContainer.removeEventListener("click", this.boundClickHandler);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ const SECRET_PLACEHOLDER = "*****";
|
||||
const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000;
|
||||
const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile";
|
||||
|
||||
const ADD_PROFILE_CANCELLATION_CODES = new Set([
|
||||
"login-cancelled",
|
||||
"user-cancelled",
|
||||
"modal-dismissed",
|
||||
]);
|
||||
|
||||
const PROVIDER_BADGE_BASE_CLASS =
|
||||
"text-3xs font-semibold uppercase tracking-extra-wide";
|
||||
const PROVIDER_BADGE_VARIANT_CLASS_MAP = Object.freeze({
|
||||
@@ -154,6 +160,25 @@ const SERVICE_CONTRACT = [
|
||||
"Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
|
||||
fallback: () => (value) => (typeof value === "string" ? value : ""),
|
||||
},
|
||||
{
|
||||
key: "requestAddProfileLogin",
|
||||
type: "function",
|
||||
description:
|
||||
"Opens the login modal and resolves with the authentication result for the add-profile flow.",
|
||||
fallback: () => async () => {
|
||||
throw new Error("Login service unavailable.");
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "describeLoginError",
|
||||
type: "function",
|
||||
description:
|
||||
"Maps authentication errors to human-readable strings for add-profile messaging.",
|
||||
fallback: () => (_, fallbackMessage) =>
|
||||
typeof fallbackMessage === "string" && fallbackMessage.trim()
|
||||
? fallbackMessage.trim()
|
||||
: "Failed to login. Please try again.",
|
||||
},
|
||||
{
|
||||
key: "getProfileCacheEntry",
|
||||
type: "function",
|
||||
@@ -766,6 +791,14 @@ export class ProfileModalController {
|
||||
this.describeAdminErrorService = this.services.describeAdminError;
|
||||
this.describeNotificationErrorService =
|
||||
this.services.describeNotificationError;
|
||||
this.describeLoginErrorService = this.services.describeLoginError;
|
||||
this.requestAddProfileLoginService = this.services.requestAddProfileLogin;
|
||||
this.log =
|
||||
typeof this.services.log === "function"
|
||||
? this.services.log
|
||||
: (...args) => {
|
||||
devLogger.log(...args);
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
onClose: callbacks.onClose || noop,
|
||||
@@ -920,6 +953,7 @@ export class ProfileModalController {
|
||||
this.lastMobileViewState = "menu";
|
||||
this.setActivePane(this.getActivePane());
|
||||
this.setWalletPaneBusy(this.isWalletBusy());
|
||||
this.addAccountButtonState = null;
|
||||
this.adminEmptyMessages = new Map();
|
||||
}
|
||||
|
||||
@@ -1223,7 +1257,7 @@ export class ProfileModalController {
|
||||
|
||||
if (this.addAccountButton instanceof HTMLElement) {
|
||||
this.addAccountButton.addEventListener("click", () => {
|
||||
this.callbacks.onAddAccount(this);
|
||||
void this.handleAddAccountRequest();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1382,6 +1416,211 @@ export class ProfileModalController {
|
||||
}
|
||||
}
|
||||
|
||||
resolveAddAccountLoginError(error, fallbackMessage = "") {
|
||||
const describe = this.describeLoginErrorService;
|
||||
const fallback =
|
||||
typeof fallbackMessage === "string" && fallbackMessage.trim()
|
||||
? fallbackMessage.trim()
|
||||
: "Couldn't add that profile. Please try again.";
|
||||
|
||||
if (typeof describe === "function") {
|
||||
try {
|
||||
const message = describe(error, fallback);
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message.trim();
|
||||
}
|
||||
} catch (describeError) {
|
||||
devLogger.warn(
|
||||
"[ProfileModalController] describeLoginError service threw:",
|
||||
describeError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
setAddAccountLoading(isLoading) {
|
||||
if (!(this.addAccountButton instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = this.addAccountButton;
|
||||
const titleEl = button.querySelector("[data-profile-add-title]");
|
||||
const hintEl = button.querySelector("[data-profile-add-hint]");
|
||||
|
||||
if (isLoading) {
|
||||
this.addAccountButtonState = {
|
||||
originalDisabled: button.disabled,
|
||||
originalAriaLabel: button.getAttribute("aria-label"),
|
||||
titleElement: titleEl instanceof HTMLElement ? titleEl : null,
|
||||
hintElement: hintEl instanceof HTMLElement ? hintEl : null,
|
||||
originalTitle:
|
||||
titleEl instanceof HTMLElement ? titleEl.textContent || "" : "",
|
||||
originalHint:
|
||||
hintEl instanceof HTMLElement ? hintEl.textContent || "" : "",
|
||||
};
|
||||
|
||||
button.disabled = true;
|
||||
button.dataset.state = "loading";
|
||||
button.setAttribute("aria-busy", "true");
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
|
||||
if (this.addAccountButtonState.titleElement) {
|
||||
this.addAccountButtonState.titleElement.textContent = "Connecting...";
|
||||
}
|
||||
|
||||
if (this.addAccountButtonState.hintElement) {
|
||||
this.addAccountButtonState.hintElement.textContent =
|
||||
"Complete the login prompt from your provider.";
|
||||
}
|
||||
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
"Connecting to your Nostr account",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.addAccountButtonState;
|
||||
|
||||
if (state) {
|
||||
button.disabled = !!state.originalDisabled;
|
||||
if (state.originalDisabled) {
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
} else {
|
||||
button.removeAttribute("aria-disabled");
|
||||
}
|
||||
|
||||
if (state.titleElement) {
|
||||
state.titleElement.textContent = state.originalTitle || "";
|
||||
}
|
||||
|
||||
if (state.hintElement) {
|
||||
state.hintElement.textContent = state.originalHint || "";
|
||||
}
|
||||
|
||||
if (state.originalAriaLabel === null) {
|
||||
button.removeAttribute("aria-label");
|
||||
} else if (typeof state.originalAriaLabel === "string") {
|
||||
button.setAttribute("aria-label", state.originalAriaLabel);
|
||||
}
|
||||
} else {
|
||||
if (button.disabled) {
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
} else {
|
||||
button.removeAttribute("aria-disabled");
|
||||
}
|
||||
}
|
||||
|
||||
button.setAttribute("aria-busy", "false");
|
||||
delete button.dataset.state;
|
||||
|
||||
this.addAccountButtonState = null;
|
||||
}
|
||||
|
||||
isAddAccountCancellationError(error) {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code =
|
||||
typeof error.code === "string" && error.code.trim()
|
||||
? error.code.trim()
|
||||
: "";
|
||||
|
||||
if (!code) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ADD_PROFILE_CANCELLATION_CODES.has(code);
|
||||
}
|
||||
|
||||
async handleAddAccountRequest() {
|
||||
if (!(this.addAccountButton instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.addAccountButton.dataset.state === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestLogin = this.requestAddProfileLoginService;
|
||||
if (typeof requestLogin !== "function") {
|
||||
devLogger.warn(
|
||||
"[ProfileModalController] requestAddProfileLogin service unavailable.",
|
||||
);
|
||||
this.showError("Login is unavailable right now. Please try again later.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setAddAccountLoading(true);
|
||||
|
||||
try {
|
||||
const loginResult = await requestLogin({
|
||||
controller: this,
|
||||
triggerElement: this.addAccountButton,
|
||||
});
|
||||
|
||||
if (loginResult === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.invokeAddAccountCallback(loginResult);
|
||||
} catch (error) {
|
||||
if (this.isAddAccountCancellationError(error)) {
|
||||
try {
|
||||
this.log(
|
||||
"[ProfileModalController] Add profile flow cancelled by user.",
|
||||
error,
|
||||
);
|
||||
} catch (logError) {
|
||||
devLogger.warn(
|
||||
"[ProfileModalController] Failed to log cancellation event:",
|
||||
logError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
devLogger.error(
|
||||
"[ProfileModalController] Failed to complete add profile authentication:",
|
||||
error,
|
||||
);
|
||||
|
||||
const message = this.resolveAddAccountLoginError(
|
||||
error,
|
||||
"Couldn't add that profile. Please try again.",
|
||||
);
|
||||
|
||||
if (message) {
|
||||
this.showError(message);
|
||||
}
|
||||
} finally {
|
||||
this.setAddAccountLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async invokeAddAccountCallback(loginResult) {
|
||||
if (typeof this.callbacks.onAddAccount !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.callbacks.onAddAccount({
|
||||
controller: this,
|
||||
loginResult,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[ProfileModalController] onAddAccount callback threw:",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
renderSavedProfiles() {
|
||||
const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey());
|
||||
const entriesNeedingFetch = new Set();
|
||||
|
||||
Reference in new issue
Block a user