/** * Which screen the current address means, and putting it up. * * Still no framework, but there is now a router, because there are now several * destinations rather than one screen with three states. It is a hash, a * switch and a hashchange listener — still smaller than the thing it routes. * Sub-screens of Play (challenge, waiting) are not addresses; they are states * the player is walked through and would be wrong to bookmark. The pages * under About are the other way round — `#about/faq` is an address, because a * question is a thing people link to. Neither is the blog: those are pages * the build wrote, and this file never runs on them. * * Two destinations are behind sign-in. The masthead hides one of their links * and shows Play to everyone, but neither the hiding nor the showing is the * door: this file is what actually decides, and it decides against a session * it has read rather than against anything the page was rendered with. * * One address is not in the masthead at all: "#lobby/" is a live, * ephemeral, per-visit route rather than a fixed destination, matched by * shape (destinations.ts's lobbyIdFromHash) instead of by a fixed id — see * the "lobby" branch below, which is the one place a route can carry a * parameter the rest of this file has to thread through. */ import { capturePageview } from "./analytics"; import { ApiError, currentSession } from "./api"; import { camoEditor, hasPendingCamo } from "./camo/editor"; import { footer, markCurrent, type Route } from "./chrome"; import { hasPendingChallenge, takePendingChallenge } from "./challenge-stash"; import { hasPendingDailyPlay, takePendingDailyPlay } from "./daily-play-stash"; import { type AboutPageId, isAllowed, isKnownAddress, lobbyIdFromHash, movedRouteForHash, routeForHash, } from "./destinations"; import { el, render } from "./dom"; import { hasPendingLobbyInvite, takePendingLobbyInvite, } from "./lobby-invite-stash"; import { aboutScreen } from "./screens/about"; import { dailyArchiveScreen } from "./screens/daily-archive"; import { errorScreen } from "./screens/error"; import { homeScreen } from "./screens/home"; import { lobbyScreen } from "./screens/lobby"; import { lobbyInviteScreen } from "./screens/lobby-invite"; import { matchesScreen } from "./screens/matches"; import { notFoundScreen } from "./screens/not-found"; import { isPhone } from "./phone"; import { phoneGateScreen } from "./screens/phone-gate"; import { hangarScreen } from "./screens/hangar"; /** * Which screen this address means. Everything about the address itself is * decided in destinations.ts, off the one list; what is left here is the * browser and the two answers that are not about the address at all. */ export function routeFromLocation(): Route { const route = routeForHash(window.location.hash); if (route) return route as Route; // A second kind of recognized address: "#lobby/", matched by shape in // destinations.ts rather than being one more entry in the fixed list // routeForHash reads off HASH_ROUTES. if (lobbyIdFromHash(window.location.hash)) return "lobby"; // Not a fallback and not optional: signing in from the camo editor navigates // to the player's own provider, and the OAuth callback returns to the site // root with no hash at all. This check is the only thing that puts the // player back in the editor with their work restored. if (hasPendingCamo()) return "camo"; // The same kind of return, for a visitor who was signed out when they // opened a shared lobby link: lobby-invite.ts's sign-in form stashes the // id here before the redirect, the hash having no better way to survive // the round trip than camo's editor state does. This only peeks — see // start()'s "lobby" branch for where the stash is actually read and // consumed, the same peek/consume split hasPendingCamo() and camoEditor() // use. if (hasPendingLobbyInvite()) return "lobby"; // The same return, for a reader who pressed Challenge on somebody's page // while signed out: the sign-in modal stashed who before the redirect, and // Play is where that is spent - see start()'s "matches" branch, which // opens the duel rather than the screen. Peek only, like the two above. if (hasPendingChallenge()) return "matches"; // And the third: a reader who pressed Play on the daily card while signed // out. "daily" rather than "lobby" because the slug, not a lobby id, is // what came back — see the "daily" branch in start(), which consumes it and // decides between the fight and the archive. Peek only, same as both above. if (hasPendingDailyPlay()) return "daily"; // An address with no route of its own. Home is what is put up for the bare // root; for anything else the guard below has the last word, and start() is // careful not to treat this answer as a destination before then. return "home"; } function isKnownRoute(): boolean { return isKnownAddress(window.location.pathname, window.location.hash); } /** * Send an address that used to be a destination on to where it went, before * anything else looks at it. * * The FAQ was a tab of its own before About became a hub. replaceState rather * than assigning the hash: this is a link that was already followed, not a * navigation of its own, and the old address is not worth a Back-button stop. * It fires no hashchange either, so start() carries on with the new one. */ function followMovedHash(): void { const moved = movedRouteForHash(window.location.hash); if (!moved) return; window.history.replaceState( {}, "", `${window.location.pathname}${window.location.search}#${moved}`, ); } /** The page under About this route names, or null for the hub itself. */ function aboutPage(route: Route): AboutPageId | null { return route === "about" ? null : (route.split("/")[1] as AboutPageId); } /** The OAuth callback bounces back here with ?error=… when it failed. */ function takeErrorFromUrl(): string | undefined { const params = new URLSearchParams(window.location.search); if (!params.has("error")) return undefined; params.delete("error"); const query = params.toString(); window.history.replaceState( {}, "", window.location.pathname + (query ? `?${query}` : ""), ); return "Sign-in did not complete. Please try again."; } // What is on screen now, so a nav click that resolves to the same destination // can be ignored. That is not just an optimisation: re-entering the camo // editor rebuilds it from scratch, and the player's unsaved camo would go with // the old one. // // Null is the not-found screen. It is not a destination, so there is no route // a later address could equal — which is what makes every link on the page // work again from a dead end. let shown: Route | null = null; // "lobby" alone is not enough to tell two different lobbies apart, and a // player can reach a second one without this ever leaving "lobby" — a share // link followed while another lobby's tab is still open, or the browser's // own Back/Forward between two "#lobby/" history entries. Only set when // `shown === "lobby"`; read alongside it, never on its own. let shownLobbyId: string | null = null; /** * Tells the router a screen it did not put up itself through start() is now * on screen. Wired to nav.markShown in main.ts — see that field's own doc * for why matches.ts needs it for the fresh-lobby entry. */ export function markEntered( route: Route | null, lobbyId: string | null = null, ): void { shown = route; shownLobbyId = route === "lobby" ? lobbyId : null; markCurrent(route); } export async function start(): Promise { window.scrollTo(0, 0); // Before the address is judged: #faq is not a dead link, it is the FAQ's old // address, and everything below this line should be reasoning about the one // it moved to. followMovedHash(); // First, because an address the site does not have is not a screen the rest // of this function should be reasoning about. It used to be decided after // the camo guard, on the grounds that a sign-in return carries a pending // camo and must not be called a dead link — but that return lands on the // bare root, which is an address the site has, so the guard was never the // thing standing in its way. if (!isKnownRoute()) { // Nothing in the masthead is the current page here, and nothing is on // screen that a click on Home should be a no-op against. Both used to be // wrong, because routeFromLocation() answers "home" for an address it does // not know: the marker sat on Home while the screen said the link was // dead, and Home — the one link a dead end needs — changed the address and // then did nothing, because the hash listener compared "home" against // "home" and saw no screen change. shown = null; shownLobbyId = null; markCurrent(null); // Path and hash only. The query is where the OAuth callback puts its codes, // and none of that belongs on screen. render(...notFoundScreen(window.location.pathname + window.location.hash)); return; } const route = routeFromLocation(); shown = route; // The real address answers this immediately; a stash-restored lobby // (hash still empty at this point) is patched in once the "lobby" branch // below actually resolves which id the stash named. shownLobbyId = route === "lobby" ? lobbyIdFromHash(window.location.hash) : null; markCurrent(route); // Before any of the play routes are resolved: a phone is told it cannot // play rather than being walked up to a match it will not be able to use. // Play and a lobby are two of the three ways into one; the daily branch // below carries the third, which only becomes a match once its stash says // so. See screens/phone-gate.ts. // // Above the session read, so a signed-out phone gets this rather than the // front page with a sign-in form on it. Play is in the masthead for // everyone, and "sign in first" would be an answer that leads nowhere: the // account is not what is missing. if ((route === "matches" || route === "lobby") && isPhone()) { // Spent, not left sitting: both of these are a press this gate is the // answer to, and the posture the branches below take is that reaching // one of these addresses for any reason clears a stash an earlier press // left. A phone getting turned away is still having reached it. takePendingChallenge(); takePendingLobbyInvite(); // Not a destination, the same as the not-found screen above: nothing in // the masthead is current here, and a second press of Play should put // the gate up again rather than compare "matches" against itself and do // nothing. markEntered(null); render(...phoneGateScreen()); return; } // About and the pages under it: one screen, which reads the page out of the // route. None of them touches the session. if (route.startsWith("about")) { render(...aboutScreen(aboutPage(route))); return; } // The archive of past daily challenges. Bundled like the front page, so it // does not wait on /api/session either — a list of fights that have already // happened is the same for whoever is reading it. if (route === "daily") { // Always taken, the same posture takePendingLobbyInvite() has below: a // visit to the archive for any other reason clears a stash an abandoned // sign-in left, so an old Play press cannot hijack a later one. const pending = takePendingDailyPlay(); if (pending) { // The daily's Play, pressed on a phone: the third way into a match, // and the one that is only a match once the stash says so. Above the // session read because the answer does not depend on it. The stash is // spent by now, which is what this wants — the gate is the answer to // that press, and a later one from a desktop should start fresh. if (isPhone()) { markEntered(null); render(...phoneGateScreen()); return; } // The one thing here that does need a session, and the only reason this // branch ever reads one. A failed read falls through to the archive // rather than to an error screen: the reader asked to play a fight, and // the page that lists them all is a better answer than nothing. const player = await currentSession().catch((err: unknown) => { console.warn("start: the session could not be read", err); return null; }); if (player) { // Rendered directly, with no address to navigate to — the daily mode // of the lobby screen has none, the same as the fresh lobby // matches.ts opens — so the router's own idea of what is on screen // has to be set by hand here. See nav.ts's markShown. markEntered("lobby"); render(...lobbyScreen(player, { kind: "daily", slug: pending })); return; } } render(...dailyArchiveScreen()); return; } // The camo editor runs entirely in the browser and needs no session until it // saves, so it does not wait on /api/session. // // No page head: the editor is a workbench, and a heading and a line of // explanation above it only pushed the thing down the page. The heading // stays for screen readers and for the document outline, which need a page // to be named even when the page explains itself. if (route === "camo") { render( el("h1", { className: "visually-hidden", textContent: "Camo" }), ...camoEditor(), footer(), ); return; } // Hangar is the camo editor's neighbour in this respect: the library it // searches is a published file, nothing it builds is saved yet, and so it // needs no session. Above the read rather than below it, because a screen // that waits on /api/session is a screen that goes down with the API. if (route === "hangar") { render(...hangarScreen()); return; } const error = takeErrorFromUrl(); try { const session = await currentSession(); if (session) { if (route === "matches") { // Always taken, the posture every stash here has: a visit to Play for // any other reason clears one an abandoned sign-in left, so an old // Challenge press cannot seat somebody into a lobby opened days // later. const challenged = takePendingChallenge(); if (challenged) { // A duel with them already opposite, rather than Play with a form // to fill in: the press that got here already said who. Rendered // directly and marked by hand for the reason matches.ts gives for // its own Mode cards - a fresh lobby has no address until it has // minted an id. markEntered("lobby"); render( ...lobbyScreen(session, { kind: "fresh", mode: "duel", invite: challenged, }), ); return; } render(...matchesScreen(session)); return; } } if (route === "lobby") { const fromHash = lobbyIdFromHash(window.location.hash); // Always taken, whether or not the hash already answered the // question: a direct "#lobby/" visit then still clears a stash // left by an earlier, abandoned sign-in, the same way entering the // camo editor unconditionally clears its own resume flag. That is // what stops a stale invite from hijacking an unrelated later // sign-in — the next lobby address visited for any reason clears it. const stashed = takePendingLobbyInvite(); const lobbyId = fromHash ?? stashed; // routeFromLocation() only ever answers "lobby" when one of the two // sources above would resolve — this check is here so TypeScript does // not have to take that on faith, not because it is expected to trip. if (lobbyId) { if (!fromHash) { // Restored from the stash: the address bar still says "/" until // this puts the real one back. Replaced, not pushed — this is // the same visit resuming, not a new one, the same reasoning // lobby.ts's own fresh-open path uses for its replaceState. window.history.replaceState( {}, "", `${window.location.pathname}${window.location.search}#lobby/${encodeURIComponent(lobbyId)}`, ); shownLobbyId = lobbyId; } render( ...(session ? lobbyScreen(session, { kind: "join", matchId: lobbyId }) : lobbyInviteScreen(lobbyId, error)), ); return; } } // Signed out at a signed-in address. Home is what a stranger asking for // Play gets — with the sign-in form on it, which is the answer to the // question they were really asking — and the address is rewritten to say // so rather than leaving #matches in the bar over a page that is not it. // replaceState does not fire hashchange, so this does not re-enter start(). if (!isAllowed(route, session !== null)) { shown = "home"; markCurrent("home"); window.history.replaceState( {}, "", window.location.pathname + window.location.search, ); } render( ...homeScreen( session ? { kind: "signedIn", session } : { kind: "signedOut", error }, ), ); } catch (err) { // The screen only ever shows the message. Keep the error itself where a // bug report can find it. console.error("start: the session could not be read", err); const message = err instanceof ApiError ? err.message : "The control plane did not answer as expected."; const retry = () => { void start(); }; // Play is nothing but what the API answers — the list, the scenarios, the // opponents — so there is no page underneath to keep, and the error screen // is the page. Camo and About returned above without ever reading a // session, so an unread one costs them nothing. if (route === "matches") { render(...errorScreen(message, retry)); return; } // Home is the opposite. Its headline, its pitch and its three facts are in // this bundle and came off the same static host that served the page, so // an unreachable API costs exactly one panel. Throwing the front page away // over it meant a stranger arriving during an outage was told the site was // broken instead of what the site is. // // The sign-in form would still be a lie — whatever stopped /api/session // will stop /api/login too — so the panel is not the form with an error // over it. See outage-panel.ts. render(...homeScreen({ kind: "unreachable", message, retry })); } } /** * A hash change is only a screen change when it names a different place — or * when it names nowhere. * * The second half is not covered by the first: routeFromLocation() answers * "home" for an address with no route, so following a dead link from Home * compared "home" against "home" and left the Home screen up under an address * that is not Home. */ export function onHashChange(): void { const route = routeFromLocation(); // "lobby" alone does not say which lobby: comparing the route string is // enough for every fixed destination, but two different "#lobby/" // hashes both answer "lobby" and must still count as a screen change — // see shownLobbyId's own comment for when this actually happens. const sameScreen = route === shown && (route !== "lobby" || lobbyIdFromHash(window.location.hash) === shownLobbyId); if (!sameScreen || !isKnownRoute()) { // The only place a screen change is known to have happened. Analytics // cannot work this out for itself: this router navigates by hashchange, // which PostHog's automatic pageview does not watch, and the two // replaceState calls above — which it would watch — are the two that // deliberately leave the screen alone. Left to it, every session would // report as a single pageview of whatever address it started on. capturePageview(); void start(); } }