Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342import { EventEmitter, EventTarget } from "/js/eventEmitter.js";import { effect, Signal } from "/js/signals.js";
const MAX_PAGES = 5;
// Browsers fire auxclick rather than click for the middle button, so// we need to manually dispatch a click event for itfunction bindMiddleClickRedispatch() { if (window.__middleClickRedispatchBound) return; window.__middleClickRedispatchBound = true; window.addEventListener("auxclick", (event) => { if (event.button !== 1 || event.defaultPrevented) return; if (event.target.closest?.("a")) return; event.target.dispatchEvent( new MouseEvent("click", { bubbles: true, cancelable: true, composed: true, button: 1, }), ); });}
export function bindToPage(root, source, event, handler) { if (!source) return; const usesEmitterApi = typeof source.on === "function"; const attach = () => usesEmitterApi ? source.on(event, handler) : source.addEventListener(event, handler); const detach = () => usesEmitterApi ? source.off(event, handler) : source.removeEventListener(event, handler); root.addEventListener("page-enter", attach); root.addEventListener("page-restore", attach); root.addEventListener("page-exit", detach);}
export class Layout extends EventTarget { slot = null; mount(container) {} dispose() {}}
export function pageEffect(root, callback, options) { let dispose; const attach = () => { dispose?.(); dispose = effect(callback, options); }; const detach = () => { dispose?.(); dispose = null; }; root.addEventListener("page-enter", attach); root.addEventListener("page-restore", attach); root.addEventListener("page-exit", detach);}
export class Router extends EventEmitter { constructor() { super(); this.routes = {}; this.redirects = {}; this.notFoundView = () => {}; this.notFoundOptions = {}; this.renderFunc = () => {}; this.layout = null; this.containers = { default: null, bare: null, layout: null }; this.currentPage = null; this.currentPath = null; this.$currentRoute = new Signal.State(null); this.pages = new Map(); this.scrollStates = new Map(); bindMiddleClickRedispatch(); // Disable scroll restoration window.history.scrollRestoration = "manual"; // Save scroll when navigating away from the page window.addEventListener("pagehide", () => { if (this.currentPath != null) { this.scrollStates.set(this.currentPath, window.scrollY); } }); // Restore scroll when returning from an external page window.addEventListener("pageshow", (e) => { if (e.persisted && this.currentPath != null) { const scrollY = this.scrollStates.get(this.currentPath) ?? 0; window.scrollTo(0, scrollY); } }); // on back button, go back to the previous page window.addEventListener("popstate", async (e) => { this.emit("navigate"); await this.load(window.location.pathname + window.location.search, { isBack: true, }); }); }
addRoute(paths, viewGetter, options = {}) { for (const path of [].concat(paths)) { this.routes[path] = { viewGetter, options }; } }
addRedirects(redirects) { Object.assign(this.redirects, redirects); }
matchRedirect(pathname) { for (const [pattern, redirect] of Object.entries(this.redirects)) { const params = Router.matchPath(pathname, pattern); if (params) { return redirect(params); } } return null; }
renderRoute(renderFunc) { this.renderFunc = renderFunc; }
setNotFoundView(viewGetter, options = {}) { this.notFoundView = viewGetter; this.notFoundOptions = options; }
setLayout(layout) { this.layout = layout; }
mount(root) { // Clear any pre-mount loading state root.innerHTML = ""; let layoutContainer = null; if (this.layout) { layoutContainer = document.createElement("div"); root.append(layoutContainer); this.layout.mount(layoutContainer); if (!(this.layout.slot instanceof Element)) { throw new Error("Layout must expose a slot element for pages"); } } const bareContainer = document.createElement("div"); bareContainer.id = "bare-pages"; root.append(bareContainer); this.containers = { default: this.layout ? this.layout.slot : root, bare: bareContainer, layout: layoutContainer, }; }
#setLayoutHidden(hidden) { this.containers.layout?.classList.toggle("layout-hidden", hidden); }
static matchPath(path, route) { const trimmedPath = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path; const pathParts = trimmedPath.split("/"); const routeParts = route.split("/"); if (pathParts.length !== routeParts.length) { return null; } const params = {}; for (let i = 0; i < routeParts.length; i++) { if (routeParts[i].startsWith(":")) { params[routeParts[i].slice(1)] = decodeURIComponent(pathParts[i]); } else { if (pathParts[i] !== routeParts[i]) { return null; } } } return params; }
match(path) { // path: e.g. /profile/gracekind.net/post/3lykznxiikc2k // route: e.g. /profile/:handle/post/:rkey for (const [route, { viewGetter, options }] of Object.entries( this.routes, )) { const params = Router.matchPath(path, route); if (params) { return { route, viewGetter, params, options }; } } return { route: null, viewGetter: this.notFoundView, params: {}, options: this.notFoundOptions, }; }
hasRoute(path) { return this.match(path).route !== null; }
async load(path, { isBack = false } = {}) { const [pathnameForRedirect, query] = path.split("?"); const redirectTarget = this.matchRedirect(pathnameForRedirect); if (redirectTarget !== null) { const redirectPath = query ? `${redirectTarget}?${query}` : redirectTarget; window.history.replaceState(window.history.state, "", redirectPath); return this.load(redirectPath, { isBack }); } // Save the scroll position of the page we're leaving before swapping it out if (this.currentPath != null) { this.scrollStates.set(this.currentPath, window.scrollY); } this.currentPath = path; // used to pause videos on page exit, among other things window.dispatchEvent(new CustomEvent("page-transition")); // Strip query parameters for route matching (but keep full path for caching) const pathname = path.split("?")[0]; if (this.currentPage) { // Safari can keep processing a focused search control after its page is // hidden. Release focus before moving the page into the route cache. const activeElement = document.activeElement; if (activeElement && this.currentPage.contains(activeElement)) { activeElement.blur(); } this.currentPage.dispatchEvent(new CustomEvent("page-exit")); this.currentPage.classList.remove("page-visible"); this.currentPage.classList.add("page-hidden"); } if (this.pages.has(path)) { // Return to existing page const { el: page, routeInfo } = this.pages.get(path); this.currentPage = page; // Re-insert the page so it's at the end of the stack // This means the least recently used page is always at the start of the stack this.pages.delete(path); this.pages.set(path, { el: page, routeInfo }); this.$currentRoute.set({ path, ...routeInfo }); this.#setLayoutHidden(routeInfo.options.layout === false); const scrollY = this.scrollStates.get(path) ?? 0; this.currentPage.classList.remove("page-hidden"); this.currentPage.classList.add("page-visible"); this.currentPage.dispatchEvent( new CustomEvent("page-restore", { detail: { scrollY, isBack, }, }), ); this.emit("page-shown", this.currentPage); return; } // First load of new page const matchingRoute = this.match(pathname); const { route, viewGetter, params, options } = matchingRoute; const routeInfo = { route, params, options }; this.$currentRoute.set({ path, ...routeInfo }); this.#setLayoutHidden(options.layout === false); const view = await viewGetter();
const newPage = document.createElement("div"); newPage.classList.add("page", "page-visible"); const container = options.layout === false ? this.containers.bare : this.containers.default; container.appendChild(newPage); this.currentPage = newPage; this.pages.set(path, { el: newPage, routeInfo }); // Limit stored pages to prevent memory leaks / performance issues if (this.pages.size > MAX_PAGES) { const firstPageKey = this.pages.keys().next().value; const firstPage = this.pages.get(firstPageKey); firstPage.el.remove(); this.pages.delete(firstPageKey); } window.scrollTo(0, 0); await this.renderFunc({ view, params, layout: options.layout === false ? null : this.layout, container: this.currentPage, }); this.currentPage.dispatchEvent(new CustomEvent("page-enter")); this.emit("page-shown", this.currentPage); }
_shouldOpenInNewTab() { // If last event was a click or Enter, check for modifier keys / middle button const event = window.event; if (!event) return false; if (event instanceof MouseEvent) { return ( event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1 ); } return ( event instanceof KeyboardEvent && event.key === "Enter" && (event.metaKey || event.ctrlKey || event.shiftKey) ); }
async go(path, { replace = false } = {}) { if (this._shouldOpenInNewTab()) { window.open(path, "_blank", "noopener"); return; } if (replace) { window.history.replaceState(null, "", path); } else { window.history.pushState( { previousRoute: window.location.pathname }, "", path, ); } this.emit("navigate"); await this.load(path); }
async back({ fallbackRoute = null } = {}) { if (this._shouldOpenInNewTab()) { window.open(this.previousRoute ?? "/", "_blank", "noopener"); return; } if (this.previousRoute !== null) { window.history.back(); } else { this.go(fallbackRoute ?? "/", { replace: true }); } }
get previousRoute() { return window.history.state?.previousRoute ?? null; }}