mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
Merge pull request #753 from PR0M3TH3AN/codex/refactor-login-modal-and-controller-setup
Refactor login modal to provider-driven controller
This commit is contained in:
4 files changed
+420
-117
No files matched your search
@@ -59,20 +59,8 @@
|
||||
<!-- Simple horizontal rule -->
|
||||
<div class="my-2 border-t border-gray-700"></div>
|
||||
|
||||
<!-- Existing login buttons -->
|
||||
<button
|
||||
id="loginNIP07"
|
||||
class="w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Login with Extension (NIP-07)
|
||||
</button>
|
||||
<button
|
||||
id="loginNSEC"
|
||||
class="w-full bg-gray-500 text-white px-4 py-2 rounded-md cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
Login with NSEC (Coming Soon)
|
||||
</button>
|
||||
<!-- Login options injected by controller -->
|
||||
<div data-login-options class="space-y-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import watchHistoryService from "./watchHistoryService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import PlaybackService from "./services/playbackService.js";
|
||||
import AuthService from "./services/authService.js";
|
||||
import { listAuthProviders } from "./services/authProviders/index.js";
|
||||
import NwcSettingsService from "./services/nwcSettingsService.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import DiscussionCountService from "./services/discussionCountService.js";
|
||||
@@ -40,6 +41,7 @@ import { initQuickR2Upload } from "./r2-quick.js";
|
||||
import { createWatchHistoryRenderer } from "./historyView.js";
|
||||
import WatchHistoryController from "./ui/watchHistoryController.js";
|
||||
import WatchHistoryTelemetry from "./services/watchHistoryTelemetry.js";
|
||||
import LoginModalController from "./ui/loginModalController.js";
|
||||
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
||||
import { subscriptions } from "./subscriptions.js";
|
||||
import { isWatchHistoryDebugEnabled } from "./watchHistoryDebug.js";
|
||||
@@ -348,6 +350,37 @@ class Application {
|
||||
)
|
||||
);
|
||||
|
||||
this.loginModalController = null;
|
||||
|
||||
const loginModalElement = document.getElementById("loginModal") || null;
|
||||
const loginOptionsContainer =
|
||||
loginModalElement?.querySelector("[data-login-options]") || null;
|
||||
const closeLoginModalButton =
|
||||
document.getElementById("closeLoginModal") || null;
|
||||
|
||||
if (loginModalElement) {
|
||||
try {
|
||||
this.loginModalController = new LoginModalController({
|
||||
modalElement: loginModalElement,
|
||||
optionsContainer: loginOptionsContainer,
|
||||
closeButton: closeLoginModalButton,
|
||||
providers: listAuthProviders(),
|
||||
authService: this.authService,
|
||||
callbacks: {
|
||||
onOpen: () => setGlobalModalState("login", true),
|
||||
onClose: () => setGlobalModalState("login", false),
|
||||
onError: (message) => this.showError(message),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[app.js] Failed to initialize LoginModalController:",
|
||||
error,
|
||||
);
|
||||
this.loginModalController = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: a "profile" button or avatar (if used)
|
||||
this.profileButton = document.getElementById("profileButton") || null;
|
||||
this.profileAvatar = document.getElementById("profileAvatar") || null;
|
||||
@@ -910,10 +943,6 @@ class Application {
|
||||
this.moreMenuController.attachVideoListView(this.videoListView);
|
||||
}
|
||||
|
||||
// NEW: reference to the login modal's close button
|
||||
this.closeLoginModalBtn =
|
||||
document.getElementById("closeLoginModal") || null;
|
||||
|
||||
// Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties
|
||||
this.blacklistedEventIds = new Set();
|
||||
for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) {
|
||||
@@ -1659,11 +1688,25 @@ class Application {
|
||||
setLoadingState(true);
|
||||
|
||||
try {
|
||||
const { pubkey } = await this.authService.requestLogin({
|
||||
const {
|
||||
pubkey,
|
||||
authType: loginAuthType,
|
||||
providerId: selectedProviderId,
|
||||
} = await this.authService.requestLogin({
|
||||
allowAccountSelection: true,
|
||||
autoApply: false,
|
||||
});
|
||||
|
||||
const resolvedAuthType = (() => {
|
||||
if (typeof loginAuthType === "string" && loginAuthType.trim()) {
|
||||
return loginAuthType.trim();
|
||||
}
|
||||
if (typeof selectedProviderId === "string" && selectedProviderId.trim()) {
|
||||
return selectedProviderId.trim();
|
||||
}
|
||||
return "nip07";
|
||||
})();
|
||||
|
||||
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
throw new Error(
|
||||
@@ -1697,7 +1740,7 @@ class Application {
|
||||
npub,
|
||||
name,
|
||||
picture,
|
||||
authType: "nip07",
|
||||
authType: resolvedAuthType,
|
||||
});
|
||||
|
||||
persistSavedProfiles({ persistActive: false });
|
||||
@@ -1847,6 +1890,10 @@ class Application {
|
||||
if (this.loginButton) {
|
||||
this.loginButton.addEventListener("click", () => {
|
||||
console.log("Login button clicked!");
|
||||
if (this.loginModalController) {
|
||||
this.loginModalController.show();
|
||||
return;
|
||||
}
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.remove("hidden");
|
||||
@@ -1855,103 +1902,7 @@ class Application {
|
||||
});
|
||||
}
|
||||
|
||||
// 5) Close login modal button => hide modal
|
||||
if (this.closeLoginModalBtn) {
|
||||
this.closeLoginModalBtn.addEventListener("click", () => {
|
||||
console.log("[app.js] closeLoginModal button clicked!");
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
setGlobalModalState("login", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 6) NIP-07 button inside the login modal => call the extension & login
|
||||
const nip07Button = document.getElementById("loginNIP07");
|
||||
if (nip07Button) {
|
||||
const originalLabel = nip07Button.textContent;
|
||||
let slowExtensionTimer = null;
|
||||
const slowExtensionDelayMs = 8_000;
|
||||
|
||||
const clearSlowExtensionTimer = () => {
|
||||
if (slowExtensionTimer) {
|
||||
clearTimeout(slowExtensionTimer);
|
||||
slowExtensionTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setLoadingState = (isLoading) => {
|
||||
if (isLoading) {
|
||||
nip07Button.disabled = true;
|
||||
nip07Button.dataset.loading = "true";
|
||||
nip07Button.setAttribute("aria-busy", "true");
|
||||
nip07Button.textContent = "Connecting to NIP-07 extension...";
|
||||
|
||||
clearSlowExtensionTimer();
|
||||
slowExtensionTimer = window.setTimeout(() => {
|
||||
if (nip07Button.dataset.loading === "true") {
|
||||
nip07Button.textContent = "Waiting for the extension prompt…";
|
||||
}
|
||||
}, slowExtensionDelayMs);
|
||||
} else {
|
||||
nip07Button.disabled = false;
|
||||
nip07Button.dataset.loading = "false";
|
||||
nip07Button.setAttribute("aria-busy", "false");
|
||||
clearSlowExtensionTimer();
|
||||
nip07Button.textContent = originalLabel;
|
||||
}
|
||||
};
|
||||
|
||||
nip07Button.addEventListener("click", async () => {
|
||||
if (nip07Button.dataset.loading === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingState(true);
|
||||
console.log(
|
||||
"[app.js] loginNIP07 clicked! Attempting extension login..."
|
||||
);
|
||||
try {
|
||||
const { pubkey, detail } = await this.authService.requestLogin();
|
||||
console.log("[NIP-07] login returned pubkey:", pubkey);
|
||||
|
||||
if (pubkey) {
|
||||
if (
|
||||
detail &&
|
||||
typeof detail === "object" &&
|
||||
detail.__handled !== true
|
||||
) {
|
||||
try {
|
||||
await this.handleAuthLogin(detail);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NIP-07] handleAuthLogin fallback failed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const loginModal = document.getElementById("loginModal");
|
||||
if (loginModal) {
|
||||
loginModal.classList.add("hidden");
|
||||
setGlobalModalState("login", false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NIP-07 login error]", err);
|
||||
const message =
|
||||
err && typeof err.message === "string" && err.message.trim()
|
||||
? err.message.trim()
|
||||
: "Failed to login with NIP-07. Please try again.";
|
||||
this.showError(message);
|
||||
} finally {
|
||||
setLoadingState(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 7) Cleanup on page unload
|
||||
// 5) Cleanup on page unload
|
||||
window.addEventListener("beforeunload", () => {
|
||||
this.flushWatchHistory("session-end", "beforeunload").catch((error) => {
|
||||
if (isDevMode) {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
const PROVIDER_ID = "nip07";
|
||||
const PROVIDER_LABEL = "NIP-07 browser extension";
|
||||
const PROVIDER_BUTTON_CLASS =
|
||||
"w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors";
|
||||
const PROVIDER_BUTTON_LABEL = "Login with Extension (NIP-07)";
|
||||
const PROVIDER_LOADING_LABEL = "Connecting to NIP-07 extension...";
|
||||
const PROVIDER_SLOW_HINT = "Waiting for the extension prompt…";
|
||||
const PROVIDER_ERROR_MESSAGE =
|
||||
"Failed to login with NIP-07. Please try again.";
|
||||
|
||||
async function login({ nostrClient, options } = {}) {
|
||||
if (!nostrClient || typeof nostrClient.login !== "function") {
|
||||
@@ -33,4 +40,11 @@ export default Object.freeze({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
login,
|
||||
ui: Object.freeze({
|
||||
buttonClass: PROVIDER_BUTTON_CLASS,
|
||||
buttonLabel: PROVIDER_BUTTON_LABEL,
|
||||
loadingLabel: PROVIDER_LOADING_LABEL,
|
||||
slowHint: PROVIDER_SLOW_HINT,
|
||||
errorMessage: PROVIDER_ERROR_MESSAGE,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
const DEFAULT_SLOW_EXTENSION_DELAY_MS = 8000;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function isElement(value) {
|
||||
return value instanceof HTMLElement;
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
return Array.isArray(value) ? value.slice() : [];
|
||||
}
|
||||
|
||||
function getTimerHost() {
|
||||
if (typeof window !== "undefined" && window && window.setTimeout) {
|
||||
return window;
|
||||
}
|
||||
if (typeof globalThis !== "undefined" && globalThis.setTimeout) {
|
||||
return globalThis;
|
||||
}
|
||||
return {
|
||||
setTimeout: () => null,
|
||||
clearTimeout: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
export default class LoginModalController {
|
||||
constructor({
|
||||
modalElement,
|
||||
optionsContainer,
|
||||
closeButton,
|
||||
providers = [],
|
||||
authService = null,
|
||||
callbacks = {},
|
||||
slowExtensionDelayMs = DEFAULT_SLOW_EXTENSION_DELAY_MS,
|
||||
} = {}) {
|
||||
this.modalElement = isElement(modalElement) ? modalElement : null;
|
||||
this.optionsContainer = isElement(optionsContainer)
|
||||
? optionsContainer
|
||||
: this.modalElement?.querySelector("[data-login-options]") || null;
|
||||
this.closeButton = isElement(closeButton) ? closeButton : null;
|
||||
this.authService = authService && typeof authService === "object"
|
||||
? authService
|
||||
: null;
|
||||
this.callbacks = {
|
||||
onOpen: noop,
|
||||
onClose: noop,
|
||||
onSuccess: noop,
|
||||
onError: noop,
|
||||
...(callbacks && typeof callbacks === "object" ? callbacks : {}),
|
||||
};
|
||||
this.providers = toArray(providers);
|
||||
this.slowExtensionDelayMs =
|
||||
Number.isFinite(slowExtensionDelayMs) && slowExtensionDelayMs >= 0
|
||||
? slowExtensionDelayMs
|
||||
: DEFAULT_SLOW_EXTENSION_DELAY_MS;
|
||||
this.providerStates = new Map();
|
||||
this.timerHost = getTimerHost();
|
||||
|
||||
this.boundCloseHandler = () => this.hide();
|
||||
|
||||
if (this.closeButton) {
|
||||
this.closeButton.addEventListener("click", this.boundCloseHandler);
|
||||
}
|
||||
|
||||
this.syncAriaHidden();
|
||||
this.render();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.closeButton && this.boundCloseHandler) {
|
||||
this.closeButton.removeEventListener("click", this.boundCloseHandler);
|
||||
}
|
||||
|
||||
for (const state of this.providerStates.values()) {
|
||||
this.teardownProviderState(state);
|
||||
}
|
||||
this.providerStates.clear();
|
||||
}
|
||||
|
||||
syncAriaHidden() {
|
||||
if (!this.modalElement) {
|
||||
return;
|
||||
}
|
||||
const isHidden = this.modalElement.classList.contains("hidden");
|
||||
this.modalElement.setAttribute("aria-hidden", isHidden ? "true" : "false");
|
||||
}
|
||||
|
||||
setProviders(providers) {
|
||||
this.providers = toArray(providers);
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.optionsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const state of this.providerStates.values()) {
|
||||
this.teardownProviderState(state);
|
||||
}
|
||||
this.providerStates.clear();
|
||||
|
||||
this.optionsContainer.innerHTML = "";
|
||||
|
||||
if (!this.providers.length) {
|
||||
const fallbackMessage = document.createElement("p");
|
||||
fallbackMessage.className = "text-sm text-gray-400";
|
||||
fallbackMessage.textContent = "No login options are available right now.";
|
||||
this.optionsContainer.appendChild(fallbackMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const provider of this.providers) {
|
||||
const button = this.createProviderButton(provider);
|
||||
if (button) {
|
||||
fragment.appendChild(button);
|
||||
}
|
||||
}
|
||||
this.optionsContainer.appendChild(fragment);
|
||||
}
|
||||
|
||||
createProviderButton(provider) {
|
||||
if (!provider || typeof provider.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerId = provider.id.trim();
|
||||
if (!providerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ui = provider.ui && typeof provider.ui === "object" ? provider.ui : {};
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.dataset.providerId = providerId;
|
||||
button.className =
|
||||
typeof ui.buttonClass === "string" && ui.buttonClass.trim()
|
||||
? ui.buttonClass
|
||||
: "w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors";
|
||||
|
||||
const defaultLabel =
|
||||
typeof ui.buttonLabel === "string" && ui.buttonLabel.trim()
|
||||
? ui.buttonLabel.trim()
|
||||
: provider.label || providerId;
|
||||
button.textContent = defaultLabel;
|
||||
button.dataset.loading = "false";
|
||||
button.setAttribute("aria-busy", "false");
|
||||
|
||||
const disabledLabel =
|
||||
typeof ui.disabledLabel === "string" && ui.disabledLabel.trim()
|
||||
? ui.disabledLabel.trim()
|
||||
: "";
|
||||
const isDisabled = ui.disabled === true;
|
||||
|
||||
if (isDisabled) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
if (disabledLabel) {
|
||||
button.textContent = disabledLabel;
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
provider,
|
||||
button,
|
||||
defaultLabel: defaultLabel,
|
||||
disabledLabel,
|
||||
isDisabled,
|
||||
loadingLabel:
|
||||
typeof ui.loadingLabel === "string" && ui.loadingLabel.trim()
|
||||
? ui.loadingLabel.trim()
|
||||
: `Connecting to ${provider.label || providerId}...`,
|
||||
slowHint:
|
||||
typeof ui.slowHint === "string" && ui.slowHint.trim()
|
||||
? ui.slowHint.trim()
|
||||
: "",
|
||||
slowTimer: null,
|
||||
clickHandler: null,
|
||||
};
|
||||
|
||||
if (!isDisabled) {
|
||||
state.clickHandler = (event) => {
|
||||
event.preventDefault();
|
||||
this.handleProviderClick(providerId);
|
||||
};
|
||||
button.addEventListener("click", state.clickHandler);
|
||||
}
|
||||
|
||||
this.providerStates.set(providerId, state);
|
||||
return button;
|
||||
}
|
||||
|
||||
teardownProviderState(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (state.clickHandler && state.button) {
|
||||
state.button.removeEventListener("click", state.clickHandler);
|
||||
}
|
||||
this.clearSlowTimer(state);
|
||||
}
|
||||
|
||||
invokeCallback(name, ...args) {
|
||||
const callback = this.callbacks?.[name];
|
||||
if (typeof callback !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (error) {
|
||||
console.error(`[LoginModalController] ${name} callback threw:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
clearSlowTimer(state) {
|
||||
if (!state || !state.slowTimer) {
|
||||
return;
|
||||
}
|
||||
this.timerHost.clearTimeout(state.slowTimer);
|
||||
state.slowTimer = null;
|
||||
}
|
||||
|
||||
setLoadingState(providerId, isLoading) {
|
||||
const state = this.providerStates.get(providerId);
|
||||
if (!state || !state.button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { button } = state;
|
||||
|
||||
if (isLoading) {
|
||||
button.disabled = true;
|
||||
button.dataset.loading = "true";
|
||||
button.setAttribute("aria-busy", "true");
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
button.textContent = state.loadingLabel;
|
||||
this.clearSlowTimer(state);
|
||||
if (state.slowHint && this.slowExtensionDelayMs > 0) {
|
||||
state.slowTimer = this.timerHost.setTimeout(() => {
|
||||
if (button.dataset.loading === "true") {
|
||||
button.textContent = state.slowHint;
|
||||
}
|
||||
}, this.slowExtensionDelayMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
button.dataset.loading = "false";
|
||||
button.setAttribute("aria-busy", "false");
|
||||
this.clearSlowTimer(state);
|
||||
|
||||
if (state.isDisabled) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
button.textContent = state.disabledLabel || state.defaultLabel;
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-disabled");
|
||||
button.textContent = state.defaultLabel;
|
||||
}
|
||||
}
|
||||
|
||||
resetLoadingStates() {
|
||||
for (const providerId of this.providerStates.keys()) {
|
||||
this.setLoadingState(providerId, false);
|
||||
}
|
||||
}
|
||||
|
||||
describeError(error, provider) {
|
||||
if (error && typeof error.message === "string" && error.message.trim()) {
|
||||
return error.message.trim();
|
||||
}
|
||||
|
||||
const ui = provider && typeof provider.ui === "object" ? provider.ui : {};
|
||||
|
||||
if (ui && typeof ui.errorMessage === "string" && ui.errorMessage.trim()) {
|
||||
return ui.errorMessage.trim();
|
||||
}
|
||||
|
||||
if (provider && typeof provider.label === "string" && provider.label) {
|
||||
return `Failed to login with ${provider.label}. Please try again.`;
|
||||
}
|
||||
|
||||
return "Failed to login. Please try again.";
|
||||
}
|
||||
|
||||
async handleProviderClick(providerId) {
|
||||
const provider = this.providers.find(
|
||||
(entry) => entry && entry.id === providerId,
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.providerStates.get(providerId);
|
||||
if (!state || state.button.dataset.loading === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authService || typeof this.authService.requestLogin !== "function") {
|
||||
const message = this.describeError(
|
||||
new Error("Login is not available."),
|
||||
provider,
|
||||
);
|
||||
this.invokeCallback("onError", message, { provider, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoadingState(providerId, true);
|
||||
|
||||
try {
|
||||
const result = await this.authService.requestLogin({ providerId });
|
||||
this.hide();
|
||||
this.invokeCallback("onSuccess", result, { provider });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[LoginModalController] Failed to login with provider "${providerId}":`,
|
||||
error,
|
||||
);
|
||||
const message = this.describeError(error, provider);
|
||||
this.invokeCallback("onError", message, { provider, error });
|
||||
} finally {
|
||||
this.setLoadingState(providerId, false);
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.modalElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalElement.classList.remove("hidden");
|
||||
this.syncAriaHidden();
|
||||
this.invokeCallback("onOpen");
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.modalElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalElement.classList.add("hidden");
|
||||
this.resetLoadingStates();
|
||||
this.syncAriaHidden();
|
||||
this.invokeCallback("onClose");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user