// Copyright 2022, 2023 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { atom, useAtomValue, useSetAtom } from "jotai"; import { atomWithLocation } from "jotai-location"; import { lazy, Suspense, useTransition } from "react"; import styles from "./Router.module.css"; import Layout from "./components/Layout"; import LoadingSpinner from "./components/LoadingSpinner"; type Location = { pathname?: string; searchParams?: URLSearchParams; }; type ProfileRoute = { type: "profile" }; type SessionOverviewRoute = { type: "sessions-overview" }; type OAuth2ClientRoute = { type: "client"; id: string }; type OAuth2SessionList = { type: "oauth2-session-list" }; type BrowserSessionRoute = { type: "browser-session"; id: string }; type BrowserSessionListRoute = { type: "browser-session-list" }; type CompatSessionListRoute = { type: "compat-session-list" }; type VerifyEmailRoute = { type: "verify-email"; id: string }; type UnknownRoute = { type: "unknown"; segments: string[] }; export type Route = | SessionOverviewRoute | ProfileRoute | OAuth2ClientRoute | OAuth2SessionList | BrowserSessionRoute | BrowserSessionListRoute | CompatSessionListRoute | VerifyEmailRoute | UnknownRoute; const routeToSegments = (route: Route): string[] => { switch (route.type) { case "profile": return []; case "sessions-overview": return ["sessions-overview"]; case "verify-email": return ["emails", route.id, "verify"]; case "client": return ["clients", route.id]; case "browser-session-list": return ["browser-sessions"]; case "browser-session": return ["browser-sessions", route.id]; case "oauth2-session-list": return ["oauth2-sessions"]; case "compat-session-list": return ["compat-sessions"]; case "unknown": return route.segments; } }; const P = Symbol(); type PatternItem = string | typeof P; // Returns true if the segments match the pattern, where P is a parameter const segmentMatches = ( segments: string[], ...pattern: PatternItem[] ): boolean => { // Quick check to see if the lengths match if (segments.length !== pattern.length) return false; // Check each segment for (let i = 0; i < segments.length; i++) { // If the pattern is P, then it's a parameter and we can skip it if (pattern[i] === P) continue; // Otherwise, check that the segment matches the pattern if (segments[i] !== pattern[i]) return false; } return true; }; export const segmentsToRoute = (segments: string[]): Route => { const matches = (...pattern: PatternItem[]): boolean => segmentMatches(segments, ...pattern); // Special case for the home page if (segments.length === 0 || (segments.length === 1 && segments[0] === "")) { return { type: "profile" }; } if (matches("sessions-overview")) { return { type: "sessions-overview" }; } if (matches("browser-sessions")) { return { type: "browser-session-list" }; } if (matches("oauth2-sessions")) { return { type: "oauth2-session-list" }; } if (matches("compat-sessions")) { return { type: "compat-session-list" }; } if (matches("emails", P, "verify")) { return { type: "verify-email", id: segments[1] }; } if (matches("clients", P)) { return { type: "client", id: segments[1] }; } if (matches("browser-sessions", P)) { return { type: "browser-session", id: segments[1] }; } return { type: "unknown", segments }; }; const routeToPath = (route: Route): string => routeToSegments(route) .map((part) => encodeURIComponent(part)) .join("/"); export const appConfigAtom = atom( typeof window !== "undefined" ? window.APP_CONFIG : { root: "/" }, ); const pathToRoute = (path: string): Route => { const segments = path.split("/").map(decodeURIComponent); return segmentsToRoute(segments); }; const locationToRoute = (root: string, location: Location): Route => { if (!location.pathname || !location.pathname.startsWith(root)) { throw new Error(`Invalid location ${location.pathname}`); } const path = location.pathname.slice(root.length); return pathToRoute(path); }; export const locationAtom = atomWithLocation(); export const routeAtom = atom( (get) => { const location = get(locationAtom); const config = get(appConfigAtom); return locationToRoute(config.root, location); }, (get, set, value: Route) => { const appConfig = get(appConfigAtom); set(locationAtom, { pathname: appConfig.root + routeToPath(value), }); }, ); const SessionsOverview = lazy(() => import("./pages/SessionsOverview")); const Profile = lazy(() => import("./pages/Profile")); const OAuth2Client = lazy(() => import("./pages/OAuth2Client")); const BrowserSession = lazy(() => import("./pages/BrowserSession")); const BrowserSessionList = lazy(() => import("./pages/BrowserSessionList")); const CompatSessionList = lazy(() => import("./pages/CompatSessionList")); const OAuth2SessionList = lazy(() => import("./pages/OAuth2SessionList")); const VerifyEmail = lazy(() => import("./pages/VerifyEmail")); const InnerRouter: React.FC = () => { const route = useAtomValue(routeAtom); switch (route.type) { case "profile": return ; case "sessions-overview": return ; case "oauth2-session-list": return ; case "browser-session-list": return ; case "compat-session-list": return ; case "client": return ; case "browser-session": return ; case "verify-email": return ; case "unknown": return <>Unknown route {JSON.stringify(route.segments)}; } }; const Router: React.FC = () => ( }> ); // Filter out clicks with modifiers or that have been prevented const shouldHandleClick = (e: React.MouseEvent): boolean => !e.defaultPrevented && e.button === 0 && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey); export const Link: React.FC< { route: Route; // adds button-like styling to link element kind?: "button"; } & React.HTMLProps > = ({ route, children, kind, className, ...props }) => { const config = useAtomValue(appConfigAtom); const path = routeToPath(route); const fullUrl = config.root + path; const setRoute = useSetAtom(routeAtom); // TODO: we should probably have more user control over this const [isPending, startTransition] = useTransition(); const classNames = [ kind === "button" ? styles.linkButton : "", className, ].join(""); return ( { // Only handle left clicks without modifiers if (!shouldHandleClick(e)) { return; } e.preventDefault(); startTransition(() => { setRoute(route); }); }} className={classNames} {...props} > {isPending ? "Loading..." : children} ); }; export default Router;