/** * The account control at the right end of the masthead. * * Signed out it is one button, Login, which opens the site's sign-in dialog. * Signed in it is the account's own avatar, and a menu under it with the * places that are about you rather than about the site. * * It is on every page, including the blog's, which the app never runs on — so * the layout mounts it with a script of its own rather than main.ts doing it, * and this file imports nothing that would drag a screen along behind it. The * sign-in dialog and the typeahead inside it are loaded on the click that * needs them. * * It is also the only module that reads the session on every page, so the * parts of the masthead that depend on who is here are settled from it: the * links a stranger is not shown, and whether the filled link says Play or * Resume. * * All account text goes in through textContent. A handle is a domain name * someone else chose. */ import { noteSession } from "./analytics"; import { currentSession, isFinished, listMatches, logout, type Session, } from "./api"; import { PLAY_LABEL, RESUME_LABEL, isAppPage, matchesHref, profileHref, } from "./destinations"; import { el } from "./dom"; import { attachMenu } from "./menu"; import { nav } from "./nav"; import { accountName, avatarUrl, monogram } from "./profile"; /** The empty box the layout leaves in the masthead. */ const SLOT = "#account"; /** The masthead links the layout shipped hidden. See destinations.ts. */ const SIGNED_IN_LINKS = "#masthead a[data-signed-in]"; /** The one filled link on the bar, the way into a game. See destinations.ts. */ const PLAY_LINK = "#masthead a.prominent"; /** Where a change to that link is said out loud. Rendered by the layout. */ const NAV_STATUS = "#nav-status"; /** What the flip is called in the markup and in the stylesheet. */ const RESUME_CLASS = "resume"; /** Called by the layout's script, on every page. */ export function mountAccount(): void { void refreshAccount(); } /** * Read the session and put the right control up. * * Also the way the rest of the app tells the masthead that the session * changed: the account card's own Sign out calls this. */ export async function refreshAccount(): Promise { const slot = document.querySelector(SLOT); if (!slot) return; let session: Session | null = null; try { session = await currentSession(); } catch (err) { // The masthead is not the place to report that the API is down — the app // page puts up an error screen of its own, and the blog does not care. // Showing Login is the honest answer here: nobody is signed in. console.warn("account: the session could not be read", err); } slot.replaceChildren(accountControl(session)); revealSignedInLinks(session !== null); void markResumable(session); // The masthead is on every page and reads the session once, so this is the // one place that learns who is here without asking a second time. void noteSession(session); } /** * Show or hide the masthead's signed-in-only destinations. * * Here rather than in chrome.ts because this is the one module the masthead * mounts on every page, the blog's included, and chrome.ts asks the API for its * version the moment it is imported — a request the blog has no use for. */ function revealSignedInLinks(signedIn: boolean): void { const links = document.querySelectorAll(SIGNED_IN_LINKS); for (const link of links) link.hidden = !signedIn; } /** * Say Resume when there is a match to go back into. * * The one question on the bar nobody can answer at build time and nobody can * answer without asking the API, so it is asked here, where the session has * just been read and where the answer costs one request at most: * * - Signed out, nothing is asked. A reader who never signs in pays nothing for * this, which is the whole reason it is not in chrome.ts. * - Off the app's own page, nothing is asked either. The masthead is on the * blog too, where every link is a fresh page load, and the blog is not where * a player goes back into a match. The bar there stays Play, which is the * label it has always had. * - When the API cannot be reached, the bar keeps the label it was built with * and the failure is a line in the console, the same answer this file gives * for a session it could not read. The screen that is actually about matches * is the one that reports an outage. */ async function markResumable(session: Session | null): Promise { const link = document.querySelector(PLAY_LINK); if (!link) return; if (!session || !isAppPage(window.location.pathname)) { // Not a no-op: this is also the sign-out, on a page already saying Resume. setResumable(link, false); return; } try { const matches = await listMatches(); setResumable( link, matches.some((match) => !isFinished(match)), ); } catch (err) { console.warn("account: the matches could not be read", err); } } /** * Flip the label, the colour and the announcement together. * * Nothing happens when the state has not changed, which is what keeps the * announcement below to the one time it is news. */ function setResumable(link: HTMLAnchorElement, resumable: boolean): void { if (link.classList.contains(RESUME_CLASS) === resumable) return; link.classList.toggle(RESUME_CLASS, resumable); link.textContent = resumable ? RESUME_LABEL : PLAY_LABEL; announce(resumable); } /** * Tell a screen reader the link changed. * * The colour is half the message and the word is the other half, but both * arrive late: the link is in the HTML the page was served with, so by the time * this file has read the session and asked for the matches the reader has been * past it and heard "Play". A name that changes under a reader is announced by * nothing — an anchor is not a live region, and marking one would make every * ordinary re-render of the bar speak. * * So the change is said once, out of a region the layout ships empty. Emptying * it says nothing, which is right: going back to Play is not news. */ function announce(resumable: boolean): void { const status = document.querySelector(NAV_STATUS); if (status) { status.textContent = resumable ? "You have a match in progress." : ""; } } /** The control itself. Exported so it can be built without a live session. */ export function accountControl(session: Session | null): HTMLElement { return session ? signedIn(session) : signedOut(); } function signedOut(): HTMLElement { const button = el("button", { type: "button", className: "account-button", textContent: "Login", }); button.addEventListener("click", () => { void import("./signin-modal") .then((module) => module.openSignIn()) .catch((err: unknown) => { console.error("account: the sign-in dialog did not load", err); }); }); return el("div", { className: "account-control" }, [button]); } function signedIn(session: Session): HTMLElement { const name = accountName(session); const button = el("button", { type: "button", className: "account-button account-button-me", }); // The handle beside the avatar is hidden on a narrow screen, and a hidden // element names nothing, so the button carries its own name either way. button.setAttribute("aria-label", `Account menu for ${name}`); button.setAttribute("aria-haspopup", "true"); button.setAttribute("aria-expanded", "false"); button.append( avatar(session), el("span", { className: "account-handle", textContent: name }), ); const status = el("p", { className: "error", role: "alert" }); const report = el("button", { type: "button", className: "account-item", textContent: "Report an issue", }); const menu = el("div", { className: "account-menu", hidden: true }, [ el("p", { className: "account-who", textContent: name }), // Your own player page: the same address anybody else's is at, so what // you see of yourself is what you can hand somebody else. link(profileHref(session), "Your account"), // Your own record, at the same address anybody else's is at. Camo is not // here: it is a destination in the masthead, and a second way in from a // menu three items long was naming the same tab twice. link(matchesHref(session), "Your matches"), report, signOutButton(status), status, ]); const control = el("div", { className: "account-control" }, [button, menu]); const setOpen = attachMenu(control, button, menu); // A button, not a link, so attachMenu does not close it — done here, or the // menu sits open behind the dialog's backdrop. report.addEventListener("click", () => { setOpen(false); void import("./report-modal") .then((module) => module.openReport()) .catch((err: unknown) => { console.error("account: the report dialog did not load", err); }); }); return control; } function link(href: string, label: string): HTMLAnchorElement { return el("a", { href, className: "account-item", textContent: label }); } function signOutButton(status: HTMLElement): HTMLButtonElement { const button = el("button", { type: "button", className: "account-item account-signout", textContent: "Sign out", }); button.addEventListener("click", () => { button.disabled = true; status.textContent = ""; logout() .then(async () => { await refreshAccount(); // On the app page the router puts the signed-out screen up in place. // Everywhere else — the blog — there is no router to ask, and a page // built for a signed-in reader has nothing to redraw anyway. if (nav.start) await nav.start(); else window.location.reload(); }) .catch(() => { status.textContent = "Sign out failed."; button.disabled = false; }); }); return button; } /** * The avatar, or the initial it stands in for. * * Drawn as the monogram first and swapped for the picture once it has decoded, * so the masthead never holds a blank square while a full-size image comes * down, and an account with no picture — or whose PDS is unreachable — keeps * something to click. */ function avatar(session: Session): HTMLElement { const box = el("span", { className: "avatar" }, [ el("span", { className: "avatar-monogram", textContent: monogram(session), }), ]); void avatarSrc(session.did).then((src) => { if (!src) return; const image = el("img", { className: "avatar-image", src, // Decorative: the button is already named for the account. alt: "", decoding: "async", }); image.addEventListener("load", () => box.replaceChildren(image)); }); return box; } /** * Where an account's avatar is, remembered for the tab. * * Finding it costs a DID document and a record fetch, and the masthead is on * every page — including the blog's, where every link is a fresh page load. A * failure is deliberately not remembered, so one flaky lookup does not cost * the avatar for the rest of the session. */ async function avatarSrc(did: string): Promise { const key = `lb.avatar.${did}`; const remembered = remember.get(key); if (remembered !== null) return remembered === "" ? null : remembered; try { const url = await avatarUrl(did); remember.set(key, url ?? ""); return url; } catch { return null; } } /** sessionStorage, which throws rather than degrades in some privacy modes. */ const remember = { get(key: string): string | null { try { return window.sessionStorage.getItem(key); } catch { return null; } }, set(key: string, value: string): void { try { window.sessionStorage.setItem(key, value); } catch { // Not being able to remember costs a lookup on the next page. } }, };