From 02be522f892bfa146aca198a0cff2c9ffded6734 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 12:12:52 -0400 Subject: [PATCH] feat(player-profile): a player has a page, at an address /players/ and /players/ are the same page, resolved in either direction and served from one Astro shell. Change-Id: Id213f148e6e9cbac513b1356ccf0a0b75594019b --- plan/README.md | 2 +- plan/player-profile.md | 74 +++++++++---- web/astro.config.mjs | 40 +++++++ web/scripts/players.test.mjs | 82 +++++++++++++++ web/scripts/soon-stamp.test.mjs | 28 +++-- web/src/account.ts | 13 ++- web/src/destinations.ts | 42 ++++++++ web/src/identity.ts | 116 +++++++++++++++++++++ web/src/pages/players/index.astro | 30 ++++++ web/src/pds.ts | 46 +++++--- web/src/players.ts | 168 ++++++++++++++++++++++++++++++ web/src/profile.ts | 17 ++- web/src/screens/pilot.ts | 113 +++++++++++++++----- 13 files changed, 695 insertions(+), 76 deletions(-) create mode 100644 web/scripts/players.test.mjs create mode 100644 web/src/identity.ts create mode 100644 web/src/pages/players/index.astro create mode 100644 web/src/players.ts diff --git a/plan/README.md b/plan/README.md index ed01825..2e692df 100644 --- a/plan/README.md +++ b/plan/README.md @@ -135,7 +135,7 @@ The exit criterion is met and something is still open in the file. | [scenarios](scenarios.md) | The scenario catalog is generated and validated, not curated | open | | [user-safety](user-safety.md) | A player has somewhere to go when another player is the problem | open | | [invitations](invitations.md) | Nobody is put in a match without being asked | open | -| [player-profile](player-profile.md) | A player has a page that is about them | blocked | +| [player-profile](player-profile.md) | A player has a page that is about them | open | | [onboarding](onboarding.md) | Somebody who has never played MegaMek can finish a match | open | | [daily-challenge](daily-challenge.md) | One fight a day, the same for everyone, scored | open | | [cost-model](cost-model.md) | What a match costs is measured, and what a busy day costs is known | open | diff --git a/plan/player-profile.md b/plan/player-profile.md index 249d861..10032d8 100644 --- a/plan/player-profile.md +++ b/plan/player-profile.md @@ -1,9 +1,9 @@ --- id: player-profile title: A player has a page that is about them -status: blocked -repos: [headquarters] -dependsOn: [match-records] +status: open +repos: [headquarters, infra] +dependsOn: [] exitCriterion: > Your account in the masthead menu goes somewhere that is yours, and the numbers on it are real. @@ -11,29 +11,39 @@ exitCriterion: > # player-profile -The masthead's account menu shows the avatar, the handle, Your account, Your -matches, Your camo and Sign out — and the first three all go to the existing -`/#`, `/#matches` and `/#camo`, because there is no profile page to go to. +There is a page now, at `/players/` and `/players/`, and it is +the same page for anybody: your own account, an opponent from a lineup, or a +handle somebody pasted into a post. What is on it is what can be read without +asking us — the identity, and the camo in the player's own repository — plus +your own match count, which is only ever your own. -Home's pilot card (`web/src/screens/pilot.ts`) is the closest thing, and it is -where the gap shows: it counts a player's camo and their matches, and its Wins -and Kills tiles are placeholders drawn as an em dash, because nothing records a -match result yet. That is why this is blocked on -[match-records](match-records.md) rather than merely waiting its turn — the -page exists to show something that does not exist. +It stopped being blocked on [match-records](match-records.md) rather than +waiting for it. The page was blocked on the wrong thing: what match records +owe it is Wins and Kills, and a page that says who somebody is, shows their +camo and can be linked to is worth having with two tiles still drawn as an em +dash. Those two are the only part of this still waiting. ## Open -- [ ] **A profile screen** that Your account can point at, rather than Home. - [ ] **Real Wins and Kills**, from match records. Kills need per-unit attribution, which is `after-action`'s work, so the two tiles land at different times. -- [ ] **Somebody else's profile.** The menu is about you, but a lineup chip and - a seat select both name another player, and neither leads anywhere. This - is the first thing that wants `appview` — a profile for a player whose - matches we did not host is only answerable from the network. -- [ ] **Decide what is public.** A profile page is the first thing here that - publishes a player's record to people who are not them. +- [ ] **Another player's matches.** The tile is deliberately withheld rather + than absent: the list is session-scoped, and a match somebody else + fought is not ours to hand out until it is a record in their own + repository. [match-records](match-records.md), then, and not before. +- [ ] **A profile for a player whose matches we never hosted.** The identity + and the camo already answer for anybody on the network, because they are + read from the network; the match history is what needs + [appview](appview.md). +- [ ] **Nothing links to a profile yet except your own menu.** A lineup chip, + a seat select and the report page's forces all name another player, and + none of them leads anywhere. That is the next thing worth doing here. +- [ ] **Decide what is public.** The page publishes only what the account + itself publishes, which is what makes it safe to ship before + [user-safety](user-safety.md). A figure of ours added to it — a rating, + a streak, a win count — is the decision this item is holding, and it has + to be made once rather than per tile. ## The Matches screen @@ -69,4 +79,28 @@ now, mounted beside the account's and waiting on nothing. ## Done -Nothing closed yet. +- [x] **A page at a real address**, `/players/`, rather than a + hash route. A profile is what people link to about each other, so it has + to be a path a reader can copy out of the bar — and, more than that, an + address the distribution can route on its own, since that is what lets a + card fetcher be answered per player without the page running. +- [x] **One shell, not a page per player.** Astro has no list of players to + build from, so `web/src/pages/players/index.astro` is built once and a + viewer-request rewrite serves it for every address under the prefix. + `astro.config.mjs` carries the same rewrite for the dev server, which is + otherwise the one place a player's page 404s. +- [x] **A handle or a fully qualified DID, resolved in either direction**, in + `web/src/identity.ts`. The DID document is the authority both ways: a + handle's resolver answer has to be confirmed by the document it points + at, and a handle the document claims has to resolve back to it. An + unconfirmed handle is dropped rather than shown, because a handle is + rented and the account that used to hold one is not the account that + holds it now. The DID form is canonical for the same reason. +- [x] **The pilot card's figures work about anybody**, not just the session: + `Stat.read` takes a subject rather than a session, camo is counted out of + whoever's repository the page is about, and a figure that exists and is + not ours to show is a dash that says so instead of wearing the + site's "coming soon" stamp. +- [x] **Your account goes somewhere that is yours.** The account menu's first + item pointed at Home for want of anywhere better; it points at your own + player page, which is the same page anybody else sees of you. diff --git a/web/astro.config.mjs b/web/astro.config.mjs index c8b598d..25ac009 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -63,5 +63,45 @@ export default defineConfig({ build: { sourcemap: true, }, + + plugins: [playersRewrite()], }, }); + +/** + * What CloudFront does for /players/, for the dev server. + * + * There is one built file behind every player's address - see + * src/pages/players/index.astro - and in production the distribution's + * viewer-request function rewrites the address onto it. Nothing does that + * here, so a player's page is a 404 in development and only in development, + * which is the worst place for the difference to be. + * + * The rewrite is the same rule as infra's: the prefix decides, not the shape + * of what follows it. A handle is full of dots and a DID is full of colons, + * and neither is a file. + */ +/** @returns {import("vite").Plugin} */ +function playersRewrite() { + return { + name: "lance-blue:players-rewrite", + enforce: "pre", + /** @param {import("vite").ViteDevServer} server */ + configureServer(server) { + server.middlewares.use( + /** + * @param {{ url?: string }} req + * @param {unknown} _res + * @param {() => void} next + */ + (req, _res, next) => { + const url = req.url ?? ""; + // "/players", with no slash: trailingSlash is "never", so that is + // the address Astro built the page at. + if (url.startsWith("/players/")) req.url = "/players"; + next(); + }, + ); + }, + }; +} diff --git a/web/scripts/players.test.mjs b/web/scripts/players.test.mjs new file mode 100644 index 0000000..bebafa0 --- /dev/null +++ b/web/scripts/players.test.mjs @@ -0,0 +1,82 @@ +/** + * A player's page is an address before it is a page. + * + * Three things have to agree about `/players/` and none of + * them can see the others: destinations.ts parses the address, Astro builds + * one file for every address under the prefix, and a rewrite in front of the + * bucket is what puts the two together. The failure is silent in both + * directions - a dead link that answers 200 with the wrong page, or a page + * that never loads for a handle because something upstream took it for a file. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { actorFromPath, playerHref } from "../src/destinations.ts"; + +const src = fileURLToPath(new URL("../src/", import.meta.url)); + +test("the prefix has a page behind it", () => { + assert.ok( + existsSync(`${src}pages/players/index.astro`), + "nothing builds /players, so every player's address is a 404", + ); +}); + +test("an address names a handle or a DID, and both survive the trip", () => { + assert.equal( + actorFromPath("/players/alice.example.com"), + "alice.example.com", + ); + assert.equal(actorFromPath("/players/did:plc:abc123"), "did:plc:abc123"); + // A browser is within its rights to send the colons encoded. + assert.equal(actorFromPath("/players/did%3Aplc%3Aabc123"), "did:plc:abc123"); + // A trailing slash is the same page, not a dead end. + assert.equal(actorFromPath("/players/did:plc:abc123/"), "did:plc:abc123"); + // Nobody named. + assert.equal(actorFromPath("/players/"), null); + assert.equal(actorFromPath("/players"), null); + assert.equal(actorFromPath("/blog"), null); + // A malformed escape is not an address, and must not throw on the way out. + assert.equal(actorFromPath("/players/%E0%A4%A"), null); +}); + +test("the site links to the DID form, which is the one that cannot move", () => { + const did = "did:plc:abc123"; + assert.equal(playerHref(did), "/players/did%3Aplc%3Aabc123"); + assert.equal(actorFromPath(playerHref(did)), did); +}); + +test("the account menu's Your account goes to the player's own page", async () => { + const account = await readFile(`${src}account.ts`, "utf8"); + assert.match( + account, + /playerHref\(session\.did\)/, + "Your account points somewhere that is not the player's own page", + ); +}); + +// The dev server has no CloudFront in front of it, so the config carries the +// same rewrite. Without it a player's page is a 404 in development and only in +// development, which is the worst place for the difference to live. +test("the dev server serves the shell for an address under the prefix", async () => { + const config = await readFile( + fileURLToPath(new URL("../astro.config.mjs", import.meta.url)), + "utf8", + ); + assert.match(config, /startsWith\("\/players\/"\)/); + assert.match(config, /req\.url = "\/players"/); +}); + +test("the page reads the address rather than being built per player", async () => { + const page = await readFile(`${src}pages/players/index.astro`, "utf8"); + assert.doesNotMatch( + page, + /getStaticPaths/, + "a page per player cannot be built: there is no list of players", + ); +}); diff --git a/web/scripts/soon-stamp.test.mjs b/web/scripts/soon-stamp.test.mjs index 881eb33..269b1ae 100644 --- a/web/scripts/soon-stamp.test.mjs +++ b/web/scripts/soon-stamp.test.mjs @@ -159,12 +159,28 @@ test("a Mode with nothing behind it is stamped, and a live one is not", () => { test("a stat with no reader is stamped, and one with a reader is not", () => { assert.match(pilot, /soonStamp\("tile"\)/, "the unread stats lost the stamp"); - // The stamp goes on inside `if (!stat.read)`. Outside it, every tile on the - // card would carry one, including the two that count something real. - const guarded = pilot.slice( - pilot.indexOf("if (!stat.read)"), - pilot.indexOf("return box;", pilot.indexOf("if (!stat.read)")), - ); + // The stamp goes on inside the guard. Outside it, every tile on the card + // would carry one, including the two that count something real. + const guard = pilot.indexOf("if (!stat.read || withheld)"); + assert.ok(guard !== -1, "the placeholder guard has been renamed"); + const guarded = pilot.slice(guard, pilot.indexOf("stat\n .read(subject)")); assert.match(guarded, /soonStamp\("tile"\)/, "the stamp is not guarded"); assert.match(guarded, /notReadyNote\(\)/, "the caption carries no note"); }); + +// A figure that is recorded and is not ours to show is a dash too, and it is +// not the same dash: "coming soon" over another player's match count would +// say the site cannot count it yet, which is untrue. +test("a figure withheld about somebody else is not stamped as unbuilt", () => { + const withheld = pilot.slice( + pilot.indexOf("if (withheld) {"), + pilot.indexOf("return box;", pilot.indexOf("if (withheld) {")), + ); + assert.ok(withheld, "the withheld branch has gone"); + assert.doesNotMatch( + withheld, + /soonStamp/, + "a withheld figure wears the soon stamp", + ); + assert.match(withheld, /not public/, "nothing tells a screen reader why"); +}); diff --git a/web/src/account.ts b/web/src/account.ts index 8407b8e..f4dc28d 100644 --- a/web/src/account.ts +++ b/web/src/account.ts @@ -28,7 +28,12 @@ import { logout, type Session, } from "./api"; -import { PLAY_LABEL, RESUME_LABEL, isAppPage } from "./destinations"; +import { + PLAY_LABEL, + RESUME_LABEL, + isAppPage, + playerHref, +} from "./destinations"; import { el } from "./dom"; import { attachMenu } from "./menu"; import { nav } from "./nav"; @@ -212,9 +217,9 @@ function signedIn(session: Session): HTMLElement { }); const menu = el("div", { className: "account-menu", hidden: true }, [ el("p", { className: "account-who", textContent: name }), - // There is no profile page yet. Home is where the pilot card is, which is - // the closest thing to one. - link("/#", "Your account"), + // 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(playerHref(session.did), "Your account"), link("/#operations", "Your matches"), link("/#camo", "Your camo"), report, diff --git a/web/src/destinations.ts b/web/src/destinations.ts index cd77d98..e9cc432 100644 --- a/web/src/destinations.ts +++ b/web/src/destinations.ts @@ -291,6 +291,48 @@ export function lobbyIdFromHash(hash: string): string | null { return match ? decodeURIComponent(match[1]!) : null; } +/** + * A player's page, the site's second parameterized address. + * + * A path rather than a hash, unlike every destination above: a profile is + * what people link to about each other, and an unfurler has to be able to be + * answered per player - which needs an address the distribution can route on + * its own, without running the page. See web/src/players.ts. + * + * The segment is a handle or a fully qualified DID, and both name the same + * page; identity.ts resolves whichever arrives. The DID is the canonical + * form, so this is what everything on the site links to: a handle is rented, + * and a link written with one stops being about the same account the day it + * changes hands. + */ +const PLAYER_PREFIX = "/players/"; + +/** The page's own address for an account. */ +export function playerHref(did: string): string { + return `${PLAYER_PREFIX}${encodeURIComponent(did)}`; +} + +/** + * The handle or DID an address names, or null where it names nobody. + * + * Percent-decoded, because a DID is written with colons and a browser is + * within its rights to send them encoded. Anything after a second slash is + * ignored rather than refused: `/players//` is the same page, and a + * trailing slash is not worth a dead end. + */ +export function actorFromPath(pathname: string): string | null { + if (!pathname.startsWith(PLAYER_PREFIX)) return null; + const segment = pathname.slice(PLAYER_PREFIX.length).split("/")[0] ?? ""; + if (segment === "") return null; + try { + return decodeURIComponent(segment); + } catch { + // A malformed escape is not an address, and decodeURIComponent throws on + // one rather than answering. + return null; + } +} + /** * Whether the site has this address at all. * diff --git a/web/src/identity.ts b/web/src/identity.ts new file mode 100644 index 0000000..da9f6c4 --- /dev/null +++ b/web/src/identity.ts @@ -0,0 +1,116 @@ +/** + * Who a `/players/<...>` address names. + * + * The address takes either form — a handle, or a fully qualified DID — and + * both have to end up at the same place, so this resolves in whichever + * direction it is handed and answers with both halves. + * + * The DID document is the authority in both directions, and nothing here + * trusts a resolver: + * + * - A handle is looked up through `com.atproto.identity.resolveHandle`, + * which does live resolution (DNS, then `/.well-known/atproto-did`) + * rather than an index lookup. Then the DID it answered with is fetched + * and its `alsoKnownAs` has to name the handle that was asked for. A + * resolver that lied is caught by that check, which is the reason this + * can use somebody else's resolver at all. + * - A DID is fetched first, and the handle its `alsoKnownAs` claims is + * resolved back. A claim nobody can confirm is dropped rather than + * shown: a handle is rented, and the account that used to hold one is + * not the account that holds it now. + * + * A null handle is therefore never an error. It means the account has no + * handle we are willing to put in front of a reader, and the DID is what is + * shown instead — the same rule profile.ts's `accountName` already follows + * for the signed-in account. + */ + +import { didDocument } from "./pds"; + +/** + * The resolver a handle is put to. + * + * Somebody else's, and it does not have to be honest: every answer is + * checked against the DID document before it is used. The public appview is + * the one this site already talks to (see avatars.ts), so it is the one with + * a cache entry and a warm connection by the time a profile page asks. + */ +const RESOLVER = "https://public.api.bsky.app"; + +/** An account, as far as a page is allowed to state it. */ +export interface Actor { + readonly did: string; + /** Null where no handle resolves back to this DID. */ + readonly handle: string | null; +} + +/** Whether this segment of the address is a DID rather than a handle. */ +export function isDid(actor: string): boolean { + return actor.startsWith("did:"); +} + +/** + * The account an address names, or null where nothing does. + * + * Null is the not-found answer, and it covers a handle nobody holds as well + * as a DID with no document. It is not an error: a mistyped address is an + * ordinary thing for a page to be asked for, and the page says so itself. + */ +export async function resolveActor(actor: string): Promise { + const trimmed = actor.trim().replace(/^@/, ""); + if (trimmed === "") return null; + try { + return isDid(trimmed) + ? await fromDid(trimmed) + : await fromHandle(trimmed.toLowerCase()); + } catch (error: unknown) { + console.warn(`identity: ${trimmed} did not resolve`, error); + return null; + } +} + +/** A DID, and whatever handle it can prove. */ +async function fromDid(did: string): Promise { + const doc = await didDocument(did); + const claimed = claimedHandle(doc); + if (!claimed) return { did, handle: null }; + const confirmed = await resolveHandle(claimed); + return { did, handle: confirmed === did ? claimed : null }; +} + +/** A handle, and the DID it resolves to — if that DID claims it back. */ +async function fromHandle(handle: string): Promise { + const did = await resolveHandle(handle); + if (!did) return null; + const doc = await didDocument(did); + // The handle the document claims, not the one that was asked for: they are + // the same string in the ordinary case, and where they are not, the + // document is the one to believe. + return { did, handle: claimedHandle(doc) === handle ? handle : null }; +} + +/** The handle a DID document claims, lower-cased, or null where it claims none. */ +function claimedHandle(doc: { alsoKnownAs?: unknown }): string | null { + const names = Array.isArray(doc.alsoKnownAs) ? doc.alsoKnownAs : []; + for (const name of names) { + if (typeof name === "string" && name.startsWith("at://")) { + const handle = name.slice("at://".length).toLowerCase(); + if (handle !== "") return handle; + } + } + return null; +} + +/** The DID a handle resolves to, or null where it resolves to nothing. */ +async function resolveHandle(handle: string): Promise { + const url = `${RESOLVER}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; + const response = await fetch(url); + // 400 is what an unresolvable handle answers, and it is an answer rather + // than a failure. Anything else is the resolver having a bad day, and is + // worth throwing so the caller logs it. + if (response.status === 400) return null; + if (!response.ok) + throw new Error(`resolveHandle answered ${response.status}`); + const body = (await response.json()) as { did?: unknown }; + return typeof body.did === "string" ? body.did : null; +} diff --git a/web/src/pages/players/index.astro b/web/src/pages/players/index.astro new file mode 100644 index 0000000..af79687 --- /dev/null +++ b/web/src/pages/players/index.astro @@ -0,0 +1,30 @@ +--- +/** + * The shell every player's page is served from. + * + * One file, for /players/ and /players/ alike: there is no list + * of players for a build to prerender a page each from, so the distribution + * rewrites every address under the prefix to this file and players.ts reads + * which player off the address. See infra's index-rewrite.js. + * + * The tags in the head are the site's own, and they are not what a shared + * link unfurls with: a card fetcher asking for one of these addresses is sent + * to headquarters-api instead, which renders the tags and the picture for the + * player the address names. This page is what a person gets. + * + * No current= on the layout: a player's page is not one of the masthead's + * destinations, so none of them is marked. + */ +import Base from "../../layouts/Base.astro"; +--- + + +
+ +
+ + diff --git a/web/src/pds.ts b/web/src/pds.ts index 860b63f..013f29d 100644 --- a/web/src/pds.ts +++ b/web/src/pds.ts @@ -11,10 +11,25 @@ * canvas that draws from it cannot be read back. */ -const cache = new Map>(); +/** + * What a DID document says that anything here reads. + * + * `alsoKnownAs` is the account's own claim to a handle, and it is only a + * claim: identity.ts is what checks the handle resolves back to this DID + * before anyone is shown it. + */ +export interface DidDocument { + readonly alsoKnownAs?: unknown; + readonly service?: { id?: string; serviceEndpoint?: unknown }[]; +} + +const cache = new Map>(); -/** The PDS endpoint for a DID. Cached: a DID document does not move often. */ -export function resolvePds(did: string): Promise { +/** + * A DID's document. Cached: it does not move often, and both the server it + * names and the handle it claims are read off the one fetch. + */ +export function didDocument(did: string): Promise { const known = cache.get(did); if (known) return known; const pending = lookup(did).catch((error: unknown) => { @@ -27,7 +42,19 @@ export function resolvePds(did: string): Promise { return pending; } -async function lookup(did: string): Promise { +/** The PDS endpoint for a DID. */ +export async function resolvePds(did: string): Promise { + const doc = await didDocument(did); + const endpoint = doc.service?.find((s) => + s.id?.endsWith("#atproto_pds"), + )?.serviceEndpoint; + if (typeof endpoint !== "string") { + throw new Error("Your account's identity names no server."); + } + return endpoint.replace(/\/+$/, ""); +} + +async function lookup(did: string): Promise { let docUrl: string; if (did.startsWith("did:plc:")) { docUrl = `https://plc.directory/${encodeURIComponent(did)}`; @@ -43,16 +70,7 @@ async function lookup(did: string): Promise { const response = await fetch(docUrl); if (!response.ok) throw new Error("Your account's identity did not resolve."); - const doc = (await response.json()) as { - service?: { id?: string; serviceEndpoint?: unknown }[]; - }; - const endpoint = doc.service?.find((s) => - s.id?.endsWith("#atproto_pds"), - )?.serviceEndpoint; - if (typeof endpoint !== "string") { - throw new Error("Your account's identity names no server."); - } - return endpoint.replace(/\/+$/, ""); + return (await response.json()) as DidDocument; } /** The public URL of a blob in someone's repository. */ diff --git a/web/src/players.ts b/web/src/players.ts new file mode 100644 index 0000000..5df3fd0 --- /dev/null +++ b/web/src/players.ts @@ -0,0 +1,168 @@ +/** + * A player's page: /players/ and /players/, the same page. + * + * It is a page rather than a hash route on purpose. A profile is the thing + * people link to about each other, and an address a reader can copy out of + * the bar - and paste into a post - has to be a path. That is also what lets + * an unfurler be answered per player: the distribution routes a card fetcher + * asking for this address to headquarters-api, which renders the tags and the + * picture for whoever the address names. Nothing on this page is what a + * fetcher reads, so nothing here has to be rendered twice. + * + * Astro cannot prerender a page per player - there is no list of players to + * build from - so it builds one shell and CloudFront serves it for every + * address under the prefix. Which player is therefore read off the address at + * run time, here, rather than baked into the file. + * + * Everything on the page is public and is read from the network the account + * lives on: the identity through identity.ts, the camo count out of the + * player's own repository. The one figure that comes from us is your own + * match count, and it is only ever your own. + */ + +import { currentSession } from "./api"; +import { footer, pageHead } from "./chrome"; +import { el, render } from "./dom"; +import { actorFromPath, playerHref } from "./destinations"; +import { resolveActor, type Actor } from "./identity"; +import { accountName } from "./profile"; +import { identityBlock, statGrid } from "./screens/pilot"; + +/** + * What the page says while it is finding out who it is about. + * + * A resolution is two network round trips in the ordinary case - the handle, + * then the document that has to agree with it - so this is on screen long + * enough to be worth writing. A live region, because the shell shipped + * `aria-busy` and this is what takes its place. + */ +function looking(): Node[] { + return [ + el("section", { className: "card prose", role: "status" }, [ + el("p", { textContent: "Frobnicating the quockerwodger…" }), + ]), + footer(), + ]; +} + +/** + * Nobody by that name. + * + * A 200 rather than a 404: the address is one the site has, and it is the + * account that does not exist. What was asked for is on the page for the + * reason the not-found screen puts a path on itself - it is how somebody + * spots their own typo - and it goes in through textContent, since it is + * whatever was typed. + */ +function nobody(actor: string): Node[] { + return [ + pageHead( + "Snollygoster brabble nudiustertian", + "Absquatulate vellichor gongoozler, mumpsimus cattywampus taradiddle skedaddle.", + ), + el("section", { className: "card" }, [ + el("code", { className: "dead-path", textContent: actor }), + el("a", { + className: "pilot-action", + href: "/#", + textContent: "Go home", + }), + ]), + footer(), + ]; +} + +/** The page, once the account behind the address is known. */ +function profile(subject: Actor & { self: boolean }): HTMLElement { + const actions = el("div", { className: "pilot-actions" }, [ + el("a", { + className: "pilot-action", + href: `https://bsky.app/profile/${subject.handle ?? subject.did}`, + textContent: "On Bluesky", + rel: "noopener", + }), + ]); + if (subject.self) { + actions.prepend( + el("a", { + className: "pilot-action", + href: "/#matches", + textContent: "Your matches", + }), + el("a", { + className: "pilot-action", + href: "/#camo", + textContent: "Design camo", + }), + ); + } + + return el("section", { className: "pilot" }, [ + el("h1", { + className: "visually-hidden", + textContent: accountName(subject), + }), + identityBlock(subject), + statGrid(subject), + actions, + ]); +} + +/** + * The DID form of the address, for anything that has to name this page once. + * + * A handle is rented and a DID is not, so the canonical address is the DID's + * even when the reader arrived on the handle's. The link is written here + * rather than in the shell because the shell is one file behind every + * player - and the crawler that most wants this tag never sees this page at + * all, since the distribution sends it to the unfurl route instead. + */ +function markCanonical(did: string): void { + const href = new URL(playerHref(did), window.location.origin).href; + const existing = document.querySelector( + "link[rel=canonical]", + ); + const link = existing ?? el("link", { rel: "canonical" }); + link.href = href; + if (!existing) document.head.append(link); +} + +async function start(): Promise { + const app = document.querySelector("#app"); + const asked = actorFromPath(window.location.pathname); + + if (!asked) { + // /players with nobody after it. Your own page is the only sensible + // answer, and a stranger is told what the address is for. + const session = await currentSession().catch(() => null); + if (session) { + window.location.replace(playerHref(session.did)); + return; + } + render(...nobody(window.location.pathname)); + app?.removeAttribute("aria-busy"); + return; + } + + render(...looking()); + + // Both of these are asked for at once: the session is ours and the identity + // is the network's, and neither answer is worth waiting on the other for. + const [actor, session] = await Promise.all([ + resolveActor(asked), + currentSession().catch(() => null), + ]); + + if (!actor) { + render(...nobody(asked)); + app?.removeAttribute("aria-busy"); + return; + } + + markCanonical(actor.did); + document.title = `${accountName(actor)} · lance.blue`; + render(profile({ ...actor, self: session?.did === actor.did }), footer()); + app?.removeAttribute("aria-busy"); +} + +void start(); diff --git a/web/src/profile.ts b/web/src/profile.ts index aeeef42..51f1e28 100644 --- a/web/src/profile.ts +++ b/web/src/profile.ts @@ -10,9 +10,20 @@ * it is the account's own copy of the picture. */ -import type { Session } from "./api"; import { blobUrl, resolvePds } from "./pds"; +/** + * The two fields naming an account that this module needs. + * + * A `Session` is one of these and so is identity.ts's `Actor`: the signed-in + * account and a player whose page is being read want the same name and the + * same letter, and neither of them wants the rest of a session. + */ +export interface Named { + readonly did: string; + readonly handle: string | null; +} + /** * What to call an account on screen. * @@ -20,12 +31,12 @@ import { blobUrl, resolvePds } from "./pds"; * this DID, so it is not a name we are willing to put in front of anyone. The * DID is not friendly, but it is the account, and it is always true. */ -export function accountName(session: Session): string { +export function accountName(session: Named): string { return session.handle ? `@${session.handle}` : session.did; } /** The one letter an account stands behind until its picture has loaded. */ -export function monogram(session: Session): string { +export function monogram(session: Named): string { const source = session.handle ?? session.did.replace(/^did:[a-z]+:/, ""); const letter = [...source].find((character) => /[a-z0-9]/i.test(character)); return (letter ?? "?").toUpperCase(); diff --git a/web/src/screens/pilot.ts b/web/src/screens/pilot.ts index 54cdae1..dd72ac7 100644 --- a/web/src/screens/pilot.ts +++ b/web/src/screens/pilot.ts @@ -18,7 +18,20 @@ import { listMatches, type Session } from "../api"; import { el } from "../dom"; import { notReadyNote, soonStamp } from "../soon-stamp"; -import { accountName, avatarUrl, monogram } from "../profile"; +import { accountName, avatarUrl, monogram, type Named } from "../profile"; + +/** + * Who a figure is being read about. + * + * The same tiles stand on Home, where the subject is the signed-in account, + * and on a player's page, where it may be anybody. `self` is not a + * convenience: a figure can be public about you and unreadable about somebody + * else, and a tile has to say which of those it is rather than showing a dash + * that looks like nothing was ever recorded. + */ +export interface Subject extends Named { + readonly self: boolean; +} /** * What the card counts, in the order it shows them. @@ -33,9 +46,17 @@ import { accountName, avatarUrl, monogram } from "../profile"; type Stat = { readonly label: string; /** Absent means nothing records this yet. */ - readonly read?: (session: Session) => Promise; + readonly read?: (subject: Subject) => Promise; /** Why the figure is missing. Placeholders only. */ readonly placeholder?: string; + /** + * Readable about yourself and about nobody else, with the reason. + * + * Set where the figure exists but is not public, which is a different + * thing from `placeholder`'s "nothing records this yet" and has to read + * differently on the tile. + */ + readonly selfOnly?: string; }; export const STATS: readonly Stat[] = [ @@ -43,15 +64,19 @@ export const STATS: readonly Stat[] = [ label: "Camo", // Real, and read straight from the player's own repository — the same // public listing the editor does, so this costs headquarters nothing. - read: async (session) => { + read: async (subject) => { const repo = await import("../camo/repo"); - return (await repo.list(session.did)).length; + return (await repo.list(subject.did)).length; }, }, { label: "Matches", - // Real, as far as the control plane's own record of them goes. + // Real, as far as the control plane's own record of them goes - and only + // about you: the list is session-scoped, and a match another player + // fought is not ours to hand out until it is a record in their own + // repository. See match-records. read: async () => (await listMatches()).length, + selfOnly: "Only the player can see their matches for now.", }, // Nothing reports a result at the end of a match yet, so neither of these // has a source. They are on the card because the shape of the card is the @@ -69,23 +94,18 @@ export const STATS: readonly Stat[] = [ * moves as the numbers arrive. */ export function pilotCard(session: Session): HTMLElement { - const name = accountName(session); - - const identity = el("div", { className: "pilot-identity" }, [ - avatar(session), - el("div", { className: "pilot-who" }, [ - el("p", { className: "pilot-name", textContent: name }), - // The DID under the handle, because the handle is rented and this is not. - el("p", { className: "pilot-did", textContent: session.did }), - ]), - ]); + const subject: Subject = { + did: session.did, + handle: session.handle, + self: true, + }; return el("section", { className: "pilot" }, [ // The card names itself for the document outline. On screen the avatar and // the handle say the same thing louder, so the heading is not repeated. el("h2", { className: "visually-hidden", textContent: "Your pilot card" }), - identity, - statGrid(session), + identityBlock(subject), + statGrid(subject), el("div", { className: "pilot-actions" }, [ el("a", { className: "pilot-action", href: "/#matches" }, [ "Your matches", @@ -95,17 +115,35 @@ export function pilotCard(session: Session): HTMLElement { ]); } -function statGrid(session: Session): HTMLElement { - const grid = el( +/** + * Who the card is about: the picture, the name, and the DID under it. + * + * Exported because a player's own page shows the same block about somebody + * else, and two drawings of one identity is how a handle ends up rendered + * one way here and another way there. + */ +export function identityBlock(subject: Subject): HTMLElement { + return el("div", { className: "pilot-identity" }, [ + avatar(subject), + el("div", { className: "pilot-who" }, [ + el("p", { className: "pilot-name", textContent: accountName(subject) }), + // The DID under the handle, because the handle is rented and this is not. + el("p", { className: "pilot-did", textContent: subject.did }), + ]), + ]); +} + +/** The figures, in the order STATS declares them. */ +export function statGrid(subject: Subject): HTMLElement { + return el( "div", { className: "pilot-stats" }, - STATS.map((stat) => tile(stat, session)), + STATS.map((stat) => tile(stat, subject)), ); - return grid; } /** One figure. Em dash until its read lands, and an em dash for good if it never does. */ -function tile(stat: Stat, session: Session): HTMLElement { +function tile(stat: Stat, subject: Subject): HTMLElement { const value = el("span", { className: "pilot-value", textContent: "—" }); const label = el("span", { className: "pilot-label", @@ -113,11 +151,30 @@ function tile(stat: Stat, session: Session): HTMLElement { }); const box = el("div", { className: "pilot-stat" }, [value, label]); - if (!stat.read) { + // Two different reasons a figure is a dash, and the tile says which: there + // is no such record yet, or there is one and it is not ours to show. + const withheld = stat.selfOnly && !subject.self ? stat.selfOnly : null; + + if (!stat.read || withheld) { box.classList.add("pilot-stat-soon"); // The reason belongs on the tile, not in a legend under the card: a player // reading a dash wants to know why that one is a dash. - box.title = stat.placeholder ?? "Not recorded yet."; + box.title = withheld ?? stat.placeholder ?? "Not recorded yet."; + + if (withheld) { + // Not the same dash, and not marked with the same stamp: this figure is + // recorded and is somebody else's to show. "Coming soon" over it would + // say the site cannot count it yet, which is untrue and is the wrong + // thing to promise. + label.append( + el("span", { + className: "visually-hidden", + textContent: " (not public)", + }), + ); + return box; + } + // The site's one mark for a thing that is named and not built, rather than // the word "soon" in the brand teal that used to be here. Same mark as the // front page's facts and Play's unbuilt Modes — a dash on this tile means @@ -129,7 +186,7 @@ function tile(stat: Stat, session: Session): HTMLElement { } stat - .read(session) + .read(subject) .then((count) => { value.textContent = String(count); }) @@ -150,15 +207,15 @@ function tile(stat: Stat, session: Session): HTMLElement { * decoded — because it is the same full-size blob off the same PDS, and here * it is drawn four times as large. */ -function avatar(session: Session): HTMLElement { +function avatar(subject: Named): HTMLElement { const box = el("span", { className: "avatar pilot-avatar" }, [ el("span", { className: "avatar-monogram", - textContent: monogram(session), + textContent: monogram(subject), }), ]); - void avatarUrl(session.did) + void avatarUrl(subject.did) .then((src) => { if (!src) return; const image = el("img", { -- 2.51.2