diff --git a/web/scripts/nav.test.mjs b/web/scripts/nav.test.mjs index 8f87c32..cd8166a 100644 --- a/web/scripts/nav.test.mjs +++ b/web/scripts/nav.test.mjs @@ -18,6 +18,7 @@ import { aboutHref, isAllowed, isKnownAddress, + lobbyIdFromHash, movedRouteForHash, routeForHash, } from "../src/destinations.ts"; @@ -193,6 +194,58 @@ test("the site's addresses are its own paths, and nothing else", () => { assert.equal(isKnownAddress("/blog", ""), false); }); +// --- the one parameterized address ------------------------------------ +// +// "#lobby/" is deliberately not in DESTINATIONS - a lobby is live, +// ephemeral and per-visit, never a fixed place the masthead should list, and +// every id is different. It is matched by shape (lobbyIdFromHash) rather +// than being one more entry in the fixed list, and it must stay that way. + +test("lobby is not one of the masthead's fixed destinations", () => { + assert.equal( + DESTINATIONS.find((d) => d.id === "lobby"), + undefined, + "a lobby must stay a parameterized route, not a fixed masthead entry", + ); +}); + +test("lobbyIdFromHash reads the id out of #lobby/, tolerating #/lobby/", () => { + assert.equal(lobbyIdFromHash("#lobby/abc123"), "abc123"); + assert.equal(lobbyIdFromHash("#/lobby/abc123"), "abc123"); + // Percent-decoded, the same as every other hash id here. + assert.equal(lobbyIdFromHash("#lobby/a%2Fb"), "a/b"); +}); + +test("lobbyIdFromHash names no id for anything that is not a lobby address", () => { + for (const hash of ["#lobby/", "#lobby", "#matches", "#", "", "#lobbies/1"]) { + assert.equal( + lobbyIdFromHash(hash), + null, + `${hash} should not name a lobby`, + ); + } +}); + +test("a #lobby/ address is known; a bare #lobby/ is not", () => { + assert.equal(isKnownAddress("/", "#lobby/abc123"), true); + assert.equal(isKnownAddress("/", "#lobby/"), false); + assert.equal(isKnownAddress("/", "#lobby"), false); + // Off the app's own paths, the same as every other address. + assert.equal(isKnownAddress("/blog", "#lobby/abc123"), false); +}); + +test("a lobby id is never one of the fixed routes, and vice versa", () => { + for (const { id, href } of hashDestinations) { + if (!href.startsWith("/#")) continue; + assert.equal( + lobbyIdFromHash(href.slice(1)), + null, + `${href} (${id}) must not also parse as a lobby address`, + ); + } + assert.equal(routeForHash("lobby/abc123"), null); +}); + test("nothing is marked in the masthead on an address the site lacks", async () => { const router = await readFile(`${src}router.ts`, "utf8"); const guard = router.slice(router.indexOf("if (!isKnownRoute())")); diff --git a/web/src/chrome.ts b/web/src/chrome.ts index 9fd0ea4..631f621 100644 --- a/web/src/chrome.ts +++ b/web/src/chrome.ts @@ -17,7 +17,8 @@ export type Route = | "camo" | "hangar" | "about" - | `about/${AboutPageId}`; + | `about/${AboutPageId}` + | "lobby"; const navLinks = new Map(); diff --git a/web/src/destinations.ts b/web/src/destinations.ts index e0a4558..92d3dbd 100644 --- a/web/src/destinations.ts +++ b/web/src/destinations.ts @@ -212,6 +212,31 @@ export function routeForHash(hash: string): string | null { return HASH_ROUTES.has(id) ? id : null; } +/** + * "#lobby/", the one parameterized address the app has. + * + * Not one more entry in DESTINATIONS: a lobby is a live, ephemeral, + * per-visit thing opened from the Duel and Scenario cards or followed from + * a share link, never a fixed place the masthead should list, and every id + * is different. It is matched by shape instead of by membership — the same + * move HASH_ROUTES/ABOUT_ROUTES and MOVED both make for their own kind of + * address, none of them a list this has to be kept in sync with. + */ +const LOBBY_HASH = /^lobby\/(.+)$/; + +/** + * The lobby id a hash names, or null when the hash does not name one. + * + * `id` is taken from `hashId()` first, the same leading-slash tolerance + * every other hash here gets, then percent-decoded — defensive rather than + * exercised, since a matchId minted by `openLobby` is an opaque token with + * no "/" or "#" in it to encode in the first place. + */ +export function lobbyIdFromHash(hash: string): string | null { + const match = LOBBY_HASH.exec(hashId(hash)); + return match ? decodeURIComponent(match[1]!) : null; +} + /** * Whether the site has this address at all. * @@ -224,5 +249,5 @@ export function isKnownAddress(pathname: string, hash: string): boolean { if (!APP_PATHS.includes(pathname)) return false; if (hash === "" || hash === "#") return true; if (movedRouteForHash(hash)) return true; - return routeForHash(hash) !== null; + return routeForHash(hash) !== null || lobbyIdFromHash(hash) !== null; } diff --git a/web/src/nav.ts b/web/src/nav.ts index b6be7c0..7d05dc4 100644 --- a/web/src/nav.ts +++ b/web/src/nav.ts @@ -12,6 +12,7 @@ */ import type { Session } from "./api"; +import type { Route } from "./chrome"; type SignedInScreen = (session: Session) => Node[]; @@ -21,8 +22,20 @@ export const nav: { /** The screen the challenge flow starts from and comes back to. */ matches: SignedInScreen | null; challenge: SignedInScreen | null; + /** + * Tells the router a screen is now on display that it did not put up + * itself through start() — matches.ts's Duel/Scenario cards, which render + * lobbyScreen directly because there is no "#lobby/" address to + * navigate to until openLobby() resolves inside it. Without this the + * router's own idea of what is on screen goes stale at "matches", and a + * later click on the Matches link compares the address it names against a + * `shown` that never left — a no-op, which was the original bug report + * this route exists to fix. + */ + markShown: ((route: Route, lobbyId?: string | null) => void) | null; } = { start: null, matches: null, challenge: null, + markShown: null, };