diff --git a/js/hashChangeHandler.js b/js/hashChangeHandler.js new file mode 100644 index 00000000..9c05b155 --- /dev/null +++ b/js/hashChangeHandler.js @@ -0,0 +1,103 @@ +import { devLogger as defaultDevLogger, userLogger as defaultUserLogger } from "./utils/logger.js"; + +const createLoggerFacade = (logger, fallback) => { + if (logger && typeof logger === "object") { + return { + log: typeof logger.log === "function" ? logger.log.bind(logger) : fallback.log, + warn: + typeof logger.warn === "function" ? logger.warn.bind(logger) : fallback.warn, + error: + typeof logger.error === "function" + ? logger.error.bind(logger) + : fallback.error, + }; + } + + return fallback; +}; + +const noopLogger = { + log() {}, + warn() {}, + error() {}, +}; + +export function createHashChangeHandler({ + getApplication, + getApplicationReady, + loadView, + viewInitRegistry, + devLogger = defaultDevLogger, + userLogger = defaultUserLogger, +} = {}) { + const devLog = createLoggerFacade(devLogger, noopLogger); + const userLog = createLoggerFacade(userLogger, noopLogger); + + const getReadyPromise = () => { + if (typeof getApplicationReady === "function") { + return getApplicationReady(); + } + return Promise.resolve(); + }; + + const resolveIsLoggedIn = () => { + if (typeof getApplication !== "function") { + return false; + } + const app = getApplication(); + if (!app || typeof app.isUserLoggedIn !== "function") { + return false; + } + return Boolean(app.isUserLoggedIn()); + }; + + return async function handleHashChange() { + const currentHash = + typeof window !== "undefined" && window.location + ? window.location.hash + : undefined; + devLog.log("handleHashChange called, current hash =", currentHash); + + try { + await getReadyPromise(); + } catch (error) { + userLog.warn( + "Proceeding with hash handling despite application initialization failure:", + error, + ); + } + + const hash = + typeof window !== "undefined" && window.location && window.location.hash + ? window.location.hash + : ""; + const match = hash.match(/^#view=([^&]+)/); + + try { + if (!match || !match[1]) { + const defaultViewName = resolveIsLoggedIn() + ? "for-you" + : "most-recent-videos"; + await loadView(`views/${defaultViewName}.html`); + const initFn = viewInitRegistry?.[defaultViewName]; + if (typeof initFn === "function") { + await initFn(); + } + return; + } + + const viewName = match[1]; + if (typeof viewName === "string" && viewName.toLowerCase() === "history") { + } + const viewUrl = `views/${viewName}.html`; + + await loadView(viewUrl); + const initFn = viewInitRegistry?.[viewName]; + if (typeof initFn === "function") { + await initFn(); + } + } catch (error) { + userLog.error("Failed to handle hash change:", error); + } + }; +} diff --git a/js/index.js b/js/index.js index cbe8f39b..cf3ddf69 100644 --- a/js/index.js +++ b/js/index.js @@ -3,7 +3,11 @@ import { validateInstanceConfig } from "../config/validate-config.js"; import { ASSET_VERSION } from "../config/asset-version.js"; import "./bufferPolyfill.js"; -import { setApplication, setApplicationReady } from "./applicationContext.js"; +import { + getApplication, + setApplication, + setApplicationReady, +} from "./applicationContext.js"; import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; import { loadView, viewInitRegistry } from "./viewManager.js"; @@ -29,6 +33,7 @@ import { TIP_JAR_URL, isLockdownMode, } from "./config.js"; +import { createHashChangeHandler } from "./hashChangeHandler.js"; import AuthService from "./services/authService.js"; import getAuthProvider, { providers as authProviders, @@ -1357,48 +1362,11 @@ function handleQueryParams() { } } -async function handleHashChange() { - devLogger.log("handleHashChange called, current hash =", window.location.hash); - - try { - await applicationReadyPromise; - } catch (error) { - userLogger.warn( - "Proceeding with hash handling despite application initialization failure:", - error - ); - } - - const hash = window.location.hash || ""; - // Use a regex that captures up to the first ampersand or end of string. - // E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile" - const match = hash.match(/^#view=([^&]+)/); - - try { - if (!match || !match[1]) { - // No valid "#view=..." => default to "for-you" when logged in - const isLoggedIn = getApplication()?.isUserLoggedIn(); - const defaultViewName = isLoggedIn ? "for-you" : "most-recent-videos"; - await loadView(`views/${defaultViewName}.html`); - const initFn = viewInitRegistry[defaultViewName]; - if (typeof initFn === "function") { - await initFn(); - } - return; - } - - const viewName = match[1]; // only the chunk before any '&' - if (typeof viewName === "string" && viewName.toLowerCase() === "history") { - } - const viewUrl = `views/${viewName}.html`; - - // Now dynamically load that partial, then call its init function - await loadView(viewUrl); - const initFn = viewInitRegistry[viewName]; - if (typeof initFn === "function") { - await initFn(); - } - } catch (error) { - userLogger.error("Failed to handle hash change:", error); - } -} +const handleHashChange = createHashChangeHandler({ + getApplication, + getApplicationReady: () => applicationReadyPromise, + loadView, + viewInitRegistry, + devLogger, + userLogger, +}); diff --git a/tests/app/hash-change-handler.test.mjs b/tests/app/hash-change-handler.test.mjs new file mode 100644 index 00000000..512153f3 --- /dev/null +++ b/tests/app/hash-change-handler.test.mjs @@ -0,0 +1,126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import { createHashChangeHandler } from "../../js/hashChangeHandler.js"; + +const setupDom = (url) => { + const dom = new JSDOM("", { url }); + const { window } = dom; + const previousWindow = global.window; + const previousDocument = global.document; + const previousHTMLElement = global.HTMLElement; + + global.window = window; + global.document = window.document; + global.HTMLElement = window.HTMLElement; + + return () => { + global.window = previousWindow; + global.document = previousDocument; + if (previousHTMLElement === undefined) { + delete global.HTMLElement; + } else { + global.HTMLElement = previousHTMLElement; + } + }; +}; + +const createLogger = () => ({ + log() {}, + warn() {}, + error() {}, +}); + +test("handleHashChange defaults to for-you when logged in", async () => { + const restore = setupDom("https://example.com"); + + try { + const loadViewCalls = []; + let initCalled = false; + + const handleHashChange = createHashChangeHandler({ + getApplication: () => ({ isUserLoggedIn: () => true }), + getApplicationReady: () => Promise.resolve(), + loadView: async (viewUrl) => { + loadViewCalls.push(viewUrl); + }, + viewInitRegistry: { + "for-you": async () => { + initCalled = true; + }, + }, + devLogger: createLogger(), + userLogger: createLogger(), + }); + + await handleHashChange(); + + assert.deepEqual(loadViewCalls, ["views/for-you.html"]); + assert.equal(initCalled, true); + } finally { + restore(); + } +}); + +test("handleHashChange defaults to most-recent-videos when logged out", async () => { + const restore = setupDom("https://example.com"); + + try { + const loadViewCalls = []; + let initCalled = false; + + const handleHashChange = createHashChangeHandler({ + getApplication: () => ({ isUserLoggedIn: () => false }), + getApplicationReady: () => Promise.resolve(), + loadView: async (viewUrl) => { + loadViewCalls.push(viewUrl); + }, + viewInitRegistry: { + "most-recent-videos": async () => { + initCalled = true; + }, + }, + devLogger: createLogger(), + userLogger: createLogger(), + }); + + await handleHashChange(); + + assert.deepEqual(loadViewCalls, ["views/most-recent-videos.html"]); + assert.equal(initCalled, true); + } finally { + restore(); + } +}); + +test("handleHashChange respects explicit view regardless of login state", async () => { + const restore = setupDom("https://example.com/#view=subscriptions"); + + try { + const loadViewCalls = []; + let initCalled = false; + + const handleHashChange = createHashChangeHandler({ + getApplication: () => ({ isUserLoggedIn: () => true }), + getApplicationReady: () => Promise.resolve(), + loadView: async (viewUrl) => { + loadViewCalls.push(viewUrl); + }, + viewInitRegistry: { + subscriptions: async () => { + initCalled = true; + }, + }, + devLogger: createLogger(), + userLogger: createLogger(), + }); + + await handleHashChange(); + + assert.deepEqual(loadViewCalls, ["views/subscriptions.html"]); + assert.equal(initCalled, true); + } finally { + restore(); + } +});