Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
12 kB · 351 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352/** * 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<void> { const slot = document.querySelector<HTMLElement>(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<HTMLElement>(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<void> { const link = document.querySelector<HTMLAnchorElement>(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<HTMLElement>(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<string | null> { 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. } },};