diff --git a/plan/player-profile.md b/plan/player-profile.md --- a/plan/player-profile.md +++ b/plan/player-profile.md @@ -11,7 +11,7 @@ # player-profile -There is a page now, at `/players/` and `/players/`, and it is +There is a page now, at `/profile/` and `/profile/`, 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 @@ -117,13 +117,13 @@ the page waits for it and a failure shows nothing at all. Both directions are read; only "you follow them" is drawn today. -- [x] **A page at a real address**, `/players/`, rather than a +- [x] **A page at a real address**, `/profile/`, 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 + build from, so `web/src/pages/profile/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. @@ -144,8 +144,8 @@ will be earned rather than rated, at which point it goes in the title and on the card together. - [x] **The address is the handle, with its @ and nothing escaped.** - `/players/@alice.example` is an address somebody can read out; - `/players/did%3Aplc%3Ah7k...` is one nobody can. Both forms answer, and + `/profile/@alice.example` is an address somebody can read out; + `/profile/did%3Aplc%3Ah7k...` is one nobody can. Both forms answer, and the handle's is what the site writes, what the canonical link names and what a card advertises as its own. The DID form covers the case the handle cannot - an account with no handle anybody can confirm - and a @@ -162,7 +162,7 @@ running out of it, the picture under corner ticks, a standing read as instruments and counts read as a ruled row - and they are not the same object: the card is one fixed 1200x630 drawing and the page reflows, - links onwards and has a match history coming. `players.test.mjs` holds + links onwards and has a match history coming. `profile-page.test.mjs` holds the two to the same figures in the same two kinds, since nothing else links a TypeScript screen to a Rust drawing. - [x] **A figure is one of two kinds, and is drawn as what it is.** A rating @@ -176,7 +176,7 @@ how a person does. What it was taking is the bio's room. - [x] **A shared link previews as the player, not as the site.** An unfurler runs no JavaScript, so one shell would preview as one generic card for - everybody. `services/api/src/unfurl.rs` answers `/unfurl/players/<...>` + everybody. `services/api/src/unfurl.rs` answers `/unfurl/profile/<...>` with the tags and a card drawn for whoever the address names, and the distribution sends card fetchers and crawlers there while everybody else is served the page from the bucket. The card is type on the site's diff --git a/web/astro.config.mjs b/web/astro.config.mjs --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -64,15 +64,15 @@ sourcemap: true, }, - plugins: [playersRewrite()], + plugins: [profileRewrite()], }, }); /** - * What CloudFront does for /players/, for the dev server. + * What CloudFront does for /profile/, 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 + * src/pages/profile/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. @@ -82,9 +82,9 @@ * and neither is a file. */ /** @returns {import("vite").Plugin} */ -function playersRewrite() { +function profileRewrite() { return { - name: "lance-blue:players-rewrite", + name: "lance-blue:profile-rewrite", enforce: "pre", /** @param {import("vite").ViteDevServer} server */ configureServer(server) { @@ -96,9 +96,9 @@ */ (req, _res, next) => { const url = req.url ?? ""; - // "/players", with no slash: trailingSlash is "never", so that is + // "/profile", with no slash: trailingSlash is "never", so that is // the address Astro built the page at. - if (url.startsWith("/players/")) req.url = "/players"; + if (url.startsWith("/profile/")) req.url = "/profile"; next(); }, ); diff --git a/web/scripts/players.test.mjs b/web/scripts/players.test.mjs deleted file mode 100644 --- a/web/scripts/players.test.mjs +++ /dev/null @@ -1,245 +0,0 @@ -/** - * 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 writes the handle, and the DID where there is no handle", () => { - const did = "did:plc:abc123"; - // Nothing is escaped: an @ and a colon are both legal in a path segment, - // and an address is written to be read. - assert.equal( - playerHref({ did, handle: "alice.example" }), - "/players/@alice.example", - ); - assert.equal(playerHref({ did, handle: null }), `/players/${did}`); - - // Both forms come back out of the address they went into, and so does one - // that arrived escaped from somewhere that tidied it. - assert.equal( - actorFromPath(playerHref({ did, handle: "alice.example" })), - "@alice.example", - ); - assert.equal(actorFromPath(playerHref({ did, handle: null })), did); - assert.equal(actorFromPath("/players/%40alice.example"), "@alice.example"); -}); - -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\)/, - "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", - ); -}); - -// The page and the card are two drawings of one claim, in two languages, in -// two repositories' worth of code. Nothing links them, and the ways they can -// drift are all quiet: a figure renamed on one, a figure that is a score here -// and a count there, or the card saying "camo" while the page says -// "patterns". Somebody reading a post and then the page it points at sees -// both within a second of each other. -test("the page and the card count the same things, in the same two kinds", async () => { - const page = await readFile(`${src}players.ts`, "utf8"); - const card = await readFile( - fileURLToPath(new URL("../../services/api/src/unfurl.rs", import.meta.url)), - "utf8", - ); - - // Only what is declared as a label: the blocks also name a module to - // import and a figure to read, and neither is what is printed. - const labels = (source, block, { pattern, closes }) => { - const start = source.indexOf(block); - assert.ok(start !== -1, `${block} is gone`); - const end = source.indexOf(closes, start); - assert.ok(end !== -1, `${block} does not close`); - return [...source.slice(start, end).matchAll(pattern)].map((m) => m[1]); - }; - - // A TypeScript array of specs, and a Rust vec! of pairs. Each ends where - // its own language ends it. - const written = { pattern: /label:\s*"([^"]+)"/g, closes: "\n];" }; - const owned = { pattern: /"([^"]+)"\.to_owned\(\)/g, closes: "\n }" }; - - assert.deepEqual(labels(page, "const SCORES", written), ["Rating", "K/D"]); - assert.deepEqual(labels(page, "const COUNTS", written), [ - "Matches", - "Forces", - "Camo", - ]); - // The card builds its own from two functions rather than two constants. - assert.deepEqual(labels(card, "fn scores(&self)", owned), ["Rating", "K/D"]); - assert.deepEqual(labels(card, "fn activity(&self)", owned), [ - "Matches", - "Forces", - "Camo", - ]); -}); - -// --- the Challenge button ------------------------------------------------- -// -// A press on a player's page has to survive a navigation, and sometimes a -// whole OAuth round trip, before anything can act on it. The stash is the one -// piece of that with logic worth asserting: peek must not spend the answer -// the router is about to read, and a taken challenge must not fire twice. -test("a pending challenge is peeked without being spent, and taken once", async () => { - const store = new Map(); - globalThis.sessionStorage = { - getItem: (key) => store.get(key) ?? null, - setItem: (key, value) => store.set(key, String(value)), - removeItem: (key) => store.delete(key), - }; - const stash = await import("../src/challenge-stash.ts"); - - assert.equal(stash.hasPendingChallenge(), false); - assert.equal(stash.takePendingChallenge(), null); - - stash.stashChallenge("@alice.example"); - assert.equal(stash.hasPendingChallenge(), true); - // Peeking twice still leaves it there: the router decides which screen to - // put up from this before it decides to spend it. - assert.equal(stash.hasPendingChallenge(), true); - assert.equal(stash.takePendingChallenge(), "@alice.example"); - assert.equal(stash.hasPendingChallenge(), false); -}); - -test("the challenge opens a duel with them in it, not the Play screen", async () => { - const router = await readFile(`${src}router.ts`, "utf8"); - assert.match( - router, - /takePendingChallenge\(\)/, - "the router never spends the stash, so a Challenge press lands on Play", - ); - assert.match( - router, - /kind: "fresh",\s*\n?\s*mode: "duel",\s*\n?\s*invite: challenged,/, - "the lobby is opened without the player who was challenged", - ); - - // The lobby seats them once and only from a snapshot that has seats: a - // fresh lobby has no slots until a scenario is chosen. - const lobby = await readFile(`${src}screens/lobby.ts`, "utf8"); - const fn = lobby.match(/function seatTheChallenged\([\s\S]*?\n \}/); - assert.ok(fn, "seatTheChallenged moved or was renamed"); - assert.match(fn[0], /if \(!toSeat \|\| !seats\.length\) return;/); - assert.match(fn[0], /toSeat = null;/, "a pending invite could fire twice"); - assert.match(fn[0], /socket\?\.inviteHandle\(theirs\.slot/); -}); - -// Signed out, Challenge is still a press: it opens the site's one sign-in -// dialog and stashes the challenge on the way out, so the round trip to the -// provider comes back into the fight rather than onto the front page. A -// disabled button would be a dead end for the reader this page is most for. -test("signed out, Challenge signs you in and remembers what you pressed", async () => { - const page = await readFile(`${src}players.ts`, "utf8"); - const fn = page.match(/function challengeButton\([\s\S]*?\n\}/); - assert.ok(fn, "challengeButton moved or was renamed"); - - assert.match( - fn[0], - /openSignIn\(\{ onBeforeConnect: \(\) => stashChallenge\(who\) \}\)/, - ); - // The session is read at press time, not at render time: this page is a - // document somebody can leave open while their session ends. - assert.match(fn[0], /currentSession\(\)/); - // And it is absent on your own page, where the answer is nobody. - assert.match(fn[0], /session\?\.did === actor\.did\) return null;/); - - // The other half of the round trip: the OAuth callback comes back to the - // bare root with no hash, so something has to recognise the stash as a - // destination or the challenge is lost on the doorstep. - const router = await readFile(`${src}router.ts`, "utf8"); - assert.match( - router, - /if \(hasPendingChallenge\(\)\) return "matches";/, - "a challenge stashed before sign-in has nowhere to land on the way back", - ); -}); - -test("neither Challenge nor the follow tick is drawn about yourself", async () => { - const page = await readFile(`${src}players.ts`, "utf8"); - const mark = page.match(/function accountMark\([\s\S]*?\n\}/); - assert.ok(mark, "accountMark moved or was renamed"); - // "You follow yourself" is not a fact, and there is no relationship to ask - // the appview about. - assert.match(mark[0], /session\.did !== actor\.did/); -}); - -// The display face is applied through one closed list of selectors, and a -// heading that is not named in it silently inherits the body face — which is -// how a player's handle shipped set in the wrong family. This holds the one -// heading on this page to that list. -test("a player's handle is set in the site's loud face", async () => { - const css = await readFile(`${src}styles.css`, "utf8"); - const list = css.slice( - css.indexOf(".brand-name,"), - css.indexOf("font-family: var(--font-display);"), - ); - assert.match( - list, - /\.profile-name,/, - "the handle is not in the display-face list, so it falls back to body text", - ); -}); diff --git a/web/scripts/profile-page.test.mjs b/web/scripts/profile-page.test.mjs new file mode 100644 --- /dev/null +++ b/web/scripts/profile-page.test.mjs @@ -0,0 +1,245 @@ +/** + * A player's page is an address before it is a page. + * + * Three things have to agree about `/profile/` 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, profileHref } 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/profile/index.astro`), + "nothing builds /profile, 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("/profile/alice.example.com"), + "alice.example.com", + ); + assert.equal(actorFromPath("/profile/did:plc:abc123"), "did:plc:abc123"); + // A browser is within its rights to send the colons encoded. + assert.equal(actorFromPath("/profile/did%3Aplc%3Aabc123"), "did:plc:abc123"); + // A trailing slash is the same page, not a dead end. + assert.equal(actorFromPath("/profile/did:plc:abc123/"), "did:plc:abc123"); + // Nobody named. + assert.equal(actorFromPath("/profile/"), null); + assert.equal(actorFromPath("/profile"), null); + assert.equal(actorFromPath("/blog"), null); + // A malformed escape is not an address, and must not throw on the way out. + assert.equal(actorFromPath("/profile/%E0%A4%A"), null); +}); + +test("the site writes the handle, and the DID where there is no handle", () => { + const did = "did:plc:abc123"; + // Nothing is escaped: an @ and a colon are both legal in a path segment, + // and an address is written to be read. + assert.equal( + profileHref({ did, handle: "alice.example" }), + "/profile/@alice.example", + ); + assert.equal(profileHref({ did, handle: null }), `/profile/${did}`); + + // Both forms come back out of the address they went into, and so does one + // that arrived escaped from somewhere that tidied it. + assert.equal( + actorFromPath(profileHref({ did, handle: "alice.example" })), + "@alice.example", + ); + assert.equal(actorFromPath(profileHref({ did, handle: null })), did); + assert.equal(actorFromPath("/profile/%40alice.example"), "@alice.example"); +}); + +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, + /profileHref\(session\)/, + "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\("\/profile\/"\)/); + assert.match(config, /req\.url = "\/profile"/); +}); + +test("the page reads the address rather than being built per player", async () => { + const page = await readFile(`${src}pages/profile/index.astro`, "utf8"); + assert.doesNotMatch( + page, + /getStaticPaths/, + "a page per player cannot be built: there is no list of players", + ); +}); + +// The page and the card are two drawings of one claim, in two languages, in +// two repositories' worth of code. Nothing links them, and the ways they can +// drift are all quiet: a figure renamed on one, a figure that is a score here +// and a count there, or the card saying "camo" while the page says +// "patterns". Somebody reading a post and then the page it points at sees +// both within a second of each other. +test("the page and the card count the same things, in the same two kinds", async () => { + const page = await readFile(`${src}profile-page.ts`, "utf8"); + const card = await readFile( + fileURLToPath(new URL("../../services/api/src/unfurl.rs", import.meta.url)), + "utf8", + ); + + // Only what is declared as a label: the blocks also name a module to + // import and a figure to read, and neither is what is printed. + const labels = (source, block, { pattern, closes }) => { + const start = source.indexOf(block); + assert.ok(start !== -1, `${block} is gone`); + const end = source.indexOf(closes, start); + assert.ok(end !== -1, `${block} does not close`); + return [...source.slice(start, end).matchAll(pattern)].map((m) => m[1]); + }; + + // A TypeScript array of specs, and a Rust vec! of pairs. Each ends where + // its own language ends it. + const written = { pattern: /label:\s*"([^"]+)"/g, closes: "\n];" }; + const owned = { pattern: /"([^"]+)"\.to_owned\(\)/g, closes: "\n }" }; + + assert.deepEqual(labels(page, "const SCORES", written), ["Rating", "K/D"]); + assert.deepEqual(labels(page, "const COUNTS", written), [ + "Matches", + "Forces", + "Camo", + ]); + // The card builds its own from two functions rather than two constants. + assert.deepEqual(labels(card, "fn scores(&self)", owned), ["Rating", "K/D"]); + assert.deepEqual(labels(card, "fn activity(&self)", owned), [ + "Matches", + "Forces", + "Camo", + ]); +}); + +// --- the Challenge button ------------------------------------------------- +// +// A press on a player's page has to survive a navigation, and sometimes a +// whole OAuth round trip, before anything can act on it. The stash is the one +// piece of that with logic worth asserting: peek must not spend the answer +// the router is about to read, and a taken challenge must not fire twice. +test("a pending challenge is peeked without being spent, and taken once", async () => { + const store = new Map(); + globalThis.sessionStorage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + }; + const stash = await import("../src/challenge-stash.ts"); + + assert.equal(stash.hasPendingChallenge(), false); + assert.equal(stash.takePendingChallenge(), null); + + stash.stashChallenge("@alice.example"); + assert.equal(stash.hasPendingChallenge(), true); + // Peeking twice still leaves it there: the router decides which screen to + // put up from this before it decides to spend it. + assert.equal(stash.hasPendingChallenge(), true); + assert.equal(stash.takePendingChallenge(), "@alice.example"); + assert.equal(stash.hasPendingChallenge(), false); +}); + +test("the challenge opens a duel with them in it, not the Play screen", async () => { + const router = await readFile(`${src}router.ts`, "utf8"); + assert.match( + router, + /takePendingChallenge\(\)/, + "the router never spends the stash, so a Challenge press lands on Play", + ); + assert.match( + router, + /kind: "fresh",\s*\n?\s*mode: "duel",\s*\n?\s*invite: challenged,/, + "the lobby is opened without the player who was challenged", + ); + + // The lobby seats them once and only from a snapshot that has seats: a + // fresh lobby has no slots until a scenario is chosen. + const lobby = await readFile(`${src}screens/lobby.ts`, "utf8"); + const fn = lobby.match(/function seatTheChallenged\([\s\S]*?\n \}/); + assert.ok(fn, "seatTheChallenged moved or was renamed"); + assert.match(fn[0], /if \(!toSeat \|\| !seats\.length\) return;/); + assert.match(fn[0], /toSeat = null;/, "a pending invite could fire twice"); + assert.match(fn[0], /socket\?\.inviteHandle\(theirs\.slot/); +}); + +// Signed out, Challenge is still a press: it opens the site's one sign-in +// dialog and stashes the challenge on the way out, so the round trip to the +// provider comes back into the fight rather than onto the front page. A +// disabled button would be a dead end for the reader this page is most for. +test("signed out, Challenge signs you in and remembers what you pressed", async () => { + const page = await readFile(`${src}profile-page.ts`, "utf8"); + const fn = page.match(/function challengeButton\([\s\S]*?\n\}/); + assert.ok(fn, "challengeButton moved or was renamed"); + + assert.match( + fn[0], + /openSignIn\(\{ onBeforeConnect: \(\) => stashChallenge\(who\) \}\)/, + ); + // The session is read at press time, not at render time: this page is a + // document somebody can leave open while their session ends. + assert.match(fn[0], /currentSession\(\)/); + // And it is absent on your own page, where the answer is nobody. + assert.match(fn[0], /session\?\.did === actor\.did\) return null;/); + + // The other half of the round trip: the OAuth callback comes back to the + // bare root with no hash, so something has to recognise the stash as a + // destination or the challenge is lost on the doorstep. + const router = await readFile(`${src}router.ts`, "utf8"); + assert.match( + router, + /if \(hasPendingChallenge\(\)\) return "matches";/, + "a challenge stashed before sign-in has nowhere to land on the way back", + ); +}); + +test("neither Challenge nor the follow tick is drawn about yourself", async () => { + const page = await readFile(`${src}profile-page.ts`, "utf8"); + const mark = page.match(/function accountMark\([\s\S]*?\n\}/); + assert.ok(mark, "accountMark moved or was renamed"); + // "You follow yourself" is not a fact, and there is no relationship to ask + // the appview about. + assert.match(mark[0], /session\.did !== actor\.did/); +}); + +// The display face is applied through one closed list of selectors, and a +// heading that is not named in it silently inherits the body face — which is +// how a player's handle shipped set in the wrong family. This holds the one +// heading on this page to that list. +test("a player's handle is set in the site's loud face", async () => { + const css = await readFile(`${src}styles.css`, "utf8"); + const list = css.slice( + css.indexOf(".brand-name,"), + css.indexOf("font-family: var(--font-display);"), + ); + assert.match( + list, + /\.profile-name,/, + "the handle is not in the display-face list, so it falls back to body text", + ); +}); diff --git a/web/src/account.ts b/web/src/account.ts --- a/web/src/account.ts +++ b/web/src/account.ts @@ -32,7 +32,7 @@ PLAY_LABEL, RESUME_LABEL, isAppPage, - playerHref, + profileHref, } from "./destinations"; import { el } from "./dom"; import { attachMenu } from "./menu"; @@ -219,7 +219,7 @@ 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(playerHref(session), "Your account"), + link(profileHref(session), "Your account"), link("/#operations", "Your matches"), link("/#camo", "Your camo"), report, diff --git a/web/src/challenge-stash.ts b/web/src/challenge-stash.ts --- a/web/src/challenge-stash.ts +++ b/web/src/challenge-stash.ts @@ -2,7 +2,7 @@ * Carrying "the player somebody pressed Challenge on" from their page into * the app. * - * A player's page is its own page — /players/@alice.example is a file the + * A player's page is its own page — /profile/@alice.example is a file the * build wrote, not a hash the app routes — so pressing Challenge there is a * navigation rather than a screen change. The intent has to survive that hop, * and it has to survive the longer one too: a signed-out reader presses diff --git a/web/src/destinations.ts b/web/src/destinations.ts --- a/web/src/destinations.ts +++ b/web/src/destinations.ts @@ -306,19 +306,19 @@ * 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. + * its own, without running the page. See web/src/profile-page.ts. * * The segment is a handle or a fully qualified DID, and both name the same * page; identity.ts resolves whichever arrives. What the site *writes* is the - * handle, with its @: `/players/@alice.example` is an address somebody can - * read out, and `/players/did:plc:h7k...` is one nobody can. The DID form + * handle, with its @: `/profile/@alice.example` is an address somebody can + * read out, and `/profile/did:plc:h7k...` is one nobody can. The DID form * stays valid for exactly the case the handle cannot cover - an account whose * handle nothing can confirm - and for a link written before a handle moved. * * Neither is percent-encoded. An @ and a colon are both legal in a path * segment, browsers send them as typed, and an address is written to be read. */ -const PLAYER_PREFIX = "/players/"; +const PROFILE_PREFIX = "/profile/"; /** * The page's address for an account. @@ -326,11 +326,11 @@ * Handed the handle where there is one and the DID where there is not, which * is the same rule the page and the card follow for what to call somebody. */ -export function playerHref(actor: { +export function profileHref(actor: { did: string; handle: string | null; }): string { - return `${PLAYER_PREFIX}${actor.handle ? `@${actor.handle}` : actor.did}`; + return `${PROFILE_PREFIX}${actor.handle ? `@${actor.handle}` : actor.did}`; } /** @@ -339,12 +339,12 @@ * A leading @ is part of the handle's address and is left on: identity.ts * takes it off before resolving, the way it takes one off anything typed. * Anything after a second slash is ignored rather than refused: - * `/players/@alice.example/` is the same page, and a trailing slash is not + * `/profile/@alice.example/` 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 (!pathname.startsWith(PROFILE_PREFIX)) return null; + const segment = pathname.slice(PROFILE_PREFIX.length).split("/")[0] ?? ""; if (segment === "") return null; try { // Nothing here writes an encoded address any more, and one can still diff --git a/web/src/identity.ts b/web/src/identity.ts --- a/web/src/identity.ts +++ b/web/src/identity.ts @@ -1,5 +1,5 @@ /** - * Who a `/players/<...>` address names. + * Who a `/profile/<...>` 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 diff --git a/web/src/players.ts b/web/src/players.ts deleted file mode 100644 --- a/web/src/players.ts +++ /dev/null @@ -1,472 +0,0 @@ -/** - * 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, listMatches, type Session } from "./api"; -import { stashChallenge } from "./challenge-stash"; -import { footer, pageHead } from "./chrome"; -import { el, render } from "./dom"; -import { actorFromPath, playerHref } from "./destinations"; -import { resolveActor, type Actor } from "./identity"; -import { relationship } from "./relationships"; -import { avatarUrl, monogram } from "./profile"; - -/** - * 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(), - ]; -} - -/** - * What the page counts, and which kind of figure each one is. - * - * The same split the card draws, because they are the same claim made twice: - * a rating and a kill ratio are read against other players, so they are - * instruments with a leader running out to them; matches, forces and camo - * are counts of what somebody has done, read one at a time, so they are a - * ruled row. The page is not the card - it is wider, it is responsive, it - * links onwards and it will hold a match history - but a reader arriving from - * a post should recognise it as the same thing. - * - * `read` is what makes a figure real. Without one it is an em dash, with the - * reason on the tile: `leaderboard` owes the rating, `match-records` owes the - * kill attribution behind K/D, and `forces` owes the count of saved lists. - */ -type Figure = { - readonly label: string; - readonly read?: (actor: Actor) => Promise; - /** Why the figure is missing. Placeholders only. */ - readonly pending?: string; - /** Readable about yourself and about nobody else, with the reason. */ - readonly selfOnly?: string; -}; - -const SCORES: readonly Figure[] = [ - { label: "Rating", pending: "No rating is computed yet." }, - { label: "K/D", pending: "A match result does not record kills yet." }, -]; - -const COUNTS: readonly Figure[] = [ - { - label: "Matches", - // Ours rather than the network's, and only about you: the list is - // session-scoped until a finished match is a record in the player's own - // repository. See match-records. - read: async () => (await listMatches()).length, - selfOnly: "Only the player can see their matches for now.", - }, - { label: "Forces", pending: "Nothing saves a force yet." }, - { - // What the rest of the site calls it: the masthead's link, the account - // menu, the editor. - label: "Camo", - // Read straight from the player's own repository, so this costs - // headquarters nothing and answers for anybody on the network. - read: async (actor) => { - const repo = await import("./camo/repo"); - return (await repo.list(actor.did)).length; - }, - }, -]; - -/** The picture, or the letter it stands in for. */ -function face(actor: Actor): HTMLElement { - const box = el("span", { className: "profile-face" }, [ - el("span", { - className: "profile-monogram", - textContent: monogram(actor), - }), - ]); - - void avatarUrl(actor.did) - .then((src) => { - if (!src) return; - const image = el("img", { - className: "profile-portrait", - src, - // Decorative: the handle is beside it. - alt: "", - decoding: "async", - }); - image.addEventListener("load", () => box.replaceChildren(image)); - }) - .catch((error: unknown) => { - console.warn("players: the picture could not be found", error); - }); - - return box; -} - -/** - * One figure, filled in when its own read lands. - * - * Every one starts as an em dash, so the page is complete and readable from - * the first frame and nothing on it moves as the numbers arrive. - */ -function figure(spec: Figure, actor: Actor, self: boolean): HTMLElement { - const value = el("span", { className: "figure-value", textContent: "—" }); - const label = el("span", { - className: "figure-label", - textContent: spec.label, - }); - const row = el("div", { className: "figure" }, [label, value]); - - const withheld = spec.selfOnly && !self ? spec.selfOnly : null; - if (!spec.read || withheld) { - row.classList.add("figure-empty"); - row.title = withheld ?? spec.pending ?? "Not recorded yet."; - label.append( - el("span", { - className: "visually-hidden", - textContent: withheld ? " (not public)" : " (not recorded yet)", - }), - ); - return row; - } - - spec - .read(actor) - .then((count) => { - value.textContent = String(count); - }) - .catch((error: unknown) => { - // A figure that cannot be read stays an em dash. Nothing on this page is - // worth an error screen, and the rest of it may have arrived. - console.warn(`players: ${spec.label} could not be counted`, error); - row.title = "This could not be read just now."; - }); - - return row; -} - -/** - * The one thing to do with somebody else's page. - * - * A press stashes who the page is about and opens a duel lobby with them - * seated opposite (challenge-stash.ts, and router.ts's "matches" branch). It - * is not a link: what it starts is a lobby that does not exist yet, and there - * is no address to give it. - * - * Signed out it is still a press. It puts up the site's one sign-in dialog - * and stashes the challenge on the way out, so the round trip to the - * player's own provider comes back into the fight they asked for rather than - * onto the front page - the same shape the daily card's Play has, through - * the same modal. A disabled button would have been a dead end for the one - * reader this page is most for: a stranger who arrived from a post. - * - * Absent on your own page, where the answer to "challenge" is nobody. - */ -function challengeButton( - actor: Actor, - session: Session | null, - trouble: HTMLElement, -): HTMLElement | null { - if (session?.did === actor.did) return null; - - const button = el("button", { - type: "button", - className: "profile-challenge plate", - textContent: "Challenge", - }); - - const who = actor.handle ? `@${actor.handle}` : actor.did; - - button.addEventListener("click", () => { - // Read now rather than trusted from render time: this page is a document - // somebody can leave open, and a session can end while it sits there. - button.disabled = true; - trouble.textContent = ""; - void currentSession() - .then(async (live) => { - button.disabled = false; - if (!live) { - const { openSignIn } = await import("./signin-modal"); - openSignIn({ onBeforeConnect: () => stashChallenge(who) }); - return; - } - stashChallenge(who); - window.location.assign("/#matches"); - }) - .catch((error: unknown) => { - // The control plane did not answer. Not an error screen - the rest of - // this page is read from the network the account lives on and is - // still true - but not silence either: a press that appears to do - // nothing is the worst of the three answers. - console.warn("players: the challenge could not be started", error); - button.disabled = false; - trouble.textContent = "A challenge could not be started just now."; - }); - }); - - return button; -} - -/** - * Where else this account is, as a mark rather than as a sentence. - * - * On the eyebrow's line, hard right, above the picture: an account is not a - * thing this page has to explain, and a row of "On Bluesky" buttons under - * somebody's handle reads as a link farm. One today, and the row it sits in - * is a list because it is going to be more - the same account is reachable - * through whatever else speaks ATProto. The mark carries its own accessible - * name, since a badge with no words is nothing to a screen reader. - */ -function accountMark(actor: Actor, session: Session | null): HTMLElement { - const where = actor.handle ?? actor.did; - const mark = el( - "a", - { - className: "profile-badge", - href: `https://bsky.app/profile/${where}`, - rel: "noopener", - title: "On Bluesky", - "aria-label": "On Bluesky", - }, - [blueskyMark()], - ); - - // Whether you follow them, when there is a you to ask about. Read from the - // appview after the mark is on the page rather than waited for: it is a - // remark about the reader, and nothing on the page should be held up for - // one. Never on your own page - "you follow yourself" is not a fact. - if (session && session.did !== actor.did) { - void relationship(session.did, actor.did).then((how) => { - if (!how.following) return; - mark.append(followTick()); - const both = how.followedBy ? " They follow you too." : ""; - const said = `On Bluesky. You follow them.${both}`; - mark.title = said; - mark.setAttribute("aria-label", said); - }); - } - - return mark; -} - -/** - * The tick in the mark's corner: you follow this account. - * - * Drawn rather than written, and small, because it is a footnote on a badge - * - what it means is in the badge's own title and accessible name, which is - * where somebody who cannot see a 10px glyph is going to find it. - */ -function followTick(): SVGSVGElement { - const svg = document.createElementNS(SVG_NS, "svg"); - svg.setAttribute("class", "badge-tick"); - svg.setAttribute("viewBox", "0 0 10 10"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - - const ground = document.createElementNS(SVG_NS, "circle"); - ground.setAttribute("cx", "5"); - ground.setAttribute("cy", "5"); - ground.setAttribute("r", "5"); - ground.setAttribute("fill", "var(--bg)"); - - const tick = document.createElementNS(SVG_NS, "path"); - tick.setAttribute("d", "M2.6 5.2 4.3 6.9 7.5 3.4"); - tick.setAttribute("fill", "none"); - tick.setAttribute("stroke", "currentColor"); - tick.setAttribute("stroke-width", "1.6"); - tick.setAttribute("stroke-linecap", "round"); - tick.setAttribute("stroke-linejoin", "round"); - - svg.append(ground, tick); - return svg; -} - -const SVG_NS = "http://www.w3.org/2000/svg"; - -/** - * A placeholder mark, not Bluesky's own. - * - * Drawing somebody else's logo from memory gets it subtly wrong, and their - * real one is an asset with terms attached rather than a path to invent. This - * is a butterfly-ish glyph in the site's own line weight, easy to find and - * meant to be replaced - grep `blueskyMark` when the real mark is settled. - */ -function blueskyMark(): SVGSVGElement { - const svg = document.createElementNS(SVG_NS, "svg"); - svg.setAttribute("class", "badge-mark"); - svg.setAttribute("viewBox", "0 0 16 16"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - - const wings = document.createElementNS(SVG_NS, "path"); - wings.setAttribute( - "d", - "M8 6.4C6.9 4.3 4.6 2.2 3.1 2.6 1.9 2.9 1.7 4.6 2 6.2c.3 1.6 1.4 2.6 3 2.9-1.4.4-2 1.3-1.6 2.2.5 1 2 1.1 3 .4.9-.6 1.4-1.6 1.6-2.6.2 1 .7 2 1.6 2.6 1 .7 2.5.6 3-.4.4-.9-.2-1.8-1.6-2.2 1.6-.3 2.7-1.3 3-2.9.3-1.6.1-3.3-1.1-3.6-1.5-.4-3.8 1.7-4.9 3.8Z", - ); - wings.setAttribute("fill", "none"); - wings.setAttribute("stroke", "currentColor"); - wings.setAttribute("stroke-width", "1.2"); - wings.setAttribute("stroke-linejoin", "round"); - - svg.append(wings); - return svg; -} - -/** The page, once the account behind the address is known. */ -function profile(actor: Actor, session: Session | null): Node[] { - const self = session?.did === actor.did; - // The row reads label, rule, then what there is to do about this player: - // the action first and where else they are after it, since one of those is - // a decision and the other is a footnote. Friending will land here too, - // between them. - // Where a press that could not be started says so. Empty and on the page - // from the first frame, because a live region added and written in the same - // breath announces nothing. - const trouble = el("p", { className: "error", role: "alert" }); - const actions = [ - challengeButton(actor, session, trouble), - accountMark(actor, session), - ] - .filter((node) => node !== null) - .map((node) => el("li", {}, [node])); - - return [ - el("section", { className: "profile" }, [ - el("div", { className: "profile-eyebrow" }, [ - el("span", { className: "profile-label", textContent: "Player" }), - el("ul", { className: "profile-actions" }, actions), - ]), - trouble, - el("div", { className: "profile-head" }, [ - el("div", { className: "profile-who" }, [ - el("h1", { - className: "profile-name", - textContent: actor.handle ? `@${actor.handle}` : actor.did, - }), - ]), - el("div", { className: "profile-plate" }, [face(actor)]), - ]), - el( - "div", - { className: "profile-scores" }, - SCORES.map((spec) => figure(spec, actor, self)), - ), - el( - "div", - { className: "profile-counts" }, - COUNTS.map((spec) => figure(spec, actor, self)), - ), - ]), - footer(), - ]; -} - -/** - * The one address this page is published at, for anything that has to name it - * once: the handle's, or the DID's for an account with no handle to use. - * - * The same form the site writes everywhere else - two addresses answering one - * page is a feature for whoever is typing and a problem for anything counting - * them. 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: the distribution sends it to the unfurl route instead. - */ -function markCanonical(actor: Actor): void { - const href = new URL(playerHref(actor), 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)); - 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); - document.title = `${actor.handle ? `@${actor.handle}` : actor.did} · lance.blue`; - render(...profile(actor, session)); - app?.removeAttribute("aria-busy"); -} - -void start(); diff --git a/web/src/profile-page.ts b/web/src/profile-page.ts new file mode 100644 --- /dev/null +++ b/web/src/profile-page.ts @@ -0,0 +1,472 @@ +/** + * A player's page: /profile/ and /profile/, 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, listMatches, type Session } from "./api"; +import { stashChallenge } from "./challenge-stash"; +import { footer, pageHead } from "./chrome"; +import { el, render } from "./dom"; +import { actorFromPath, profileHref } from "./destinations"; +import { resolveActor, type Actor } from "./identity"; +import { relationship } from "./relationships"; +import { avatarUrl, monogram } from "./profile"; + +/** + * 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(), + ]; +} + +/** + * What the page counts, and which kind of figure each one is. + * + * The same split the card draws, because they are the same claim made twice: + * a rating and a kill ratio are read against other players, so they are + * instruments with a leader running out to them; matches, forces and camo + * are counts of what somebody has done, read one at a time, so they are a + * ruled row. The page is not the card - it is wider, it is responsive, it + * links onwards and it will hold a match history - but a reader arriving from + * a post should recognise it as the same thing. + * + * `read` is what makes a figure real. Without one it is an em dash, with the + * reason on the tile: `leaderboard` owes the rating, `match-records` owes the + * kill attribution behind K/D, and `forces` owes the count of saved lists. + */ +type Figure = { + readonly label: string; + readonly read?: (actor: Actor) => Promise; + /** Why the figure is missing. Placeholders only. */ + readonly pending?: string; + /** Readable about yourself and about nobody else, with the reason. */ + readonly selfOnly?: string; +}; + +const SCORES: readonly Figure[] = [ + { label: "Rating", pending: "No rating is computed yet." }, + { label: "K/D", pending: "A match result does not record kills yet." }, +]; + +const COUNTS: readonly Figure[] = [ + { + label: "Matches", + // Ours rather than the network's, and only about you: the list is + // session-scoped until a finished match is a record in the player's own + // repository. See match-records. + read: async () => (await listMatches()).length, + selfOnly: "Only the player can see their matches for now.", + }, + { label: "Forces", pending: "Nothing saves a force yet." }, + { + // What the rest of the site calls it: the masthead's link, the account + // menu, the editor. + label: "Camo", + // Read straight from the player's own repository, so this costs + // headquarters nothing and answers for anybody on the network. + read: async (actor) => { + const repo = await import("./camo/repo"); + return (await repo.list(actor.did)).length; + }, + }, +]; + +/** The picture, or the letter it stands in for. */ +function face(actor: Actor): HTMLElement { + const box = el("span", { className: "profile-face" }, [ + el("span", { + className: "profile-monogram", + textContent: monogram(actor), + }), + ]); + + void avatarUrl(actor.did) + .then((src) => { + if (!src) return; + const image = el("img", { + className: "profile-portrait", + src, + // Decorative: the handle is beside it. + alt: "", + decoding: "async", + }); + image.addEventListener("load", () => box.replaceChildren(image)); + }) + .catch((error: unknown) => { + console.warn("profile: the picture could not be found", error); + }); + + return box; +} + +/** + * One figure, filled in when its own read lands. + * + * Every one starts as an em dash, so the page is complete and readable from + * the first frame and nothing on it moves as the numbers arrive. + */ +function figure(spec: Figure, actor: Actor, self: boolean): HTMLElement { + const value = el("span", { className: "figure-value", textContent: "—" }); + const label = el("span", { + className: "figure-label", + textContent: spec.label, + }); + const row = el("div", { className: "figure" }, [label, value]); + + const withheld = spec.selfOnly && !self ? spec.selfOnly : null; + if (!spec.read || withheld) { + row.classList.add("figure-empty"); + row.title = withheld ?? spec.pending ?? "Not recorded yet."; + label.append( + el("span", { + className: "visually-hidden", + textContent: withheld ? " (not public)" : " (not recorded yet)", + }), + ); + return row; + } + + spec + .read(actor) + .then((count) => { + value.textContent = String(count); + }) + .catch((error: unknown) => { + // A figure that cannot be read stays an em dash. Nothing on this page is + // worth an error screen, and the rest of it may have arrived. + console.warn(`profile: ${spec.label} could not be counted`, error); + row.title = "This could not be read just now."; + }); + + return row; +} + +/** + * The one thing to do with somebody else's page. + * + * A press stashes who the page is about and opens a duel lobby with them + * seated opposite (challenge-stash.ts, and router.ts's "matches" branch). It + * is not a link: what it starts is a lobby that does not exist yet, and there + * is no address to give it. + * + * Signed out it is still a press. It puts up the site's one sign-in dialog + * and stashes the challenge on the way out, so the round trip to the + * player's own provider comes back into the fight they asked for rather than + * onto the front page - the same shape the daily card's Play has, through + * the same modal. A disabled button would have been a dead end for the one + * reader this page is most for: a stranger who arrived from a post. + * + * Absent on your own page, where the answer to "challenge" is nobody. + */ +function challengeButton( + actor: Actor, + session: Session | null, + trouble: HTMLElement, +): HTMLElement | null { + if (session?.did === actor.did) return null; + + const button = el("button", { + type: "button", + className: "profile-challenge plate", + textContent: "Challenge", + }); + + const who = actor.handle ? `@${actor.handle}` : actor.did; + + button.addEventListener("click", () => { + // Read now rather than trusted from render time: this page is a document + // somebody can leave open, and a session can end while it sits there. + button.disabled = true; + trouble.textContent = ""; + void currentSession() + .then(async (live) => { + button.disabled = false; + if (!live) { + const { openSignIn } = await import("./signin-modal"); + openSignIn({ onBeforeConnect: () => stashChallenge(who) }); + return; + } + stashChallenge(who); + window.location.assign("/#matches"); + }) + .catch((error: unknown) => { + // The control plane did not answer. Not an error screen - the rest of + // this page is read from the network the account lives on and is + // still true - but not silence either: a press that appears to do + // nothing is the worst of the three answers. + console.warn("profile: the challenge could not be started", error); + button.disabled = false; + trouble.textContent = "A challenge could not be started just now."; + }); + }); + + return button; +} + +/** + * Where else this account is, as a mark rather than as a sentence. + * + * On the eyebrow's line, hard right, above the picture: an account is not a + * thing this page has to explain, and a row of "On Bluesky" buttons under + * somebody's handle reads as a link farm. One today, and the row it sits in + * is a list because it is going to be more - the same account is reachable + * through whatever else speaks ATProto. The mark carries its own accessible + * name, since a badge with no words is nothing to a screen reader. + */ +function accountMark(actor: Actor, session: Session | null): HTMLElement { + const where = actor.handle ?? actor.did; + const mark = el( + "a", + { + className: "profile-badge", + href: `https://bsky.app/profile/${where}`, + rel: "noopener", + title: "On Bluesky", + "aria-label": "On Bluesky", + }, + [blueskyMark()], + ); + + // Whether you follow them, when there is a you to ask about. Read from the + // appview after the mark is on the page rather than waited for: it is a + // remark about the reader, and nothing on the page should be held up for + // one. Never on your own page - "you follow yourself" is not a fact. + if (session && session.did !== actor.did) { + void relationship(session.did, actor.did).then((how) => { + if (!how.following) return; + mark.append(followTick()); + const both = how.followedBy ? " They follow you too." : ""; + const said = `On Bluesky. You follow them.${both}`; + mark.title = said; + mark.setAttribute("aria-label", said); + }); + } + + return mark; +} + +/** + * The tick in the mark's corner: you follow this account. + * + * Drawn rather than written, and small, because it is a footnote on a badge + * - what it means is in the badge's own title and accessible name, which is + * where somebody who cannot see a 10px glyph is going to find it. + */ +function followTick(): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "badge-tick"); + svg.setAttribute("viewBox", "0 0 10 10"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + + const ground = document.createElementNS(SVG_NS, "circle"); + ground.setAttribute("cx", "5"); + ground.setAttribute("cy", "5"); + ground.setAttribute("r", "5"); + ground.setAttribute("fill", "var(--bg)"); + + const tick = document.createElementNS(SVG_NS, "path"); + tick.setAttribute("d", "M2.6 5.2 4.3 6.9 7.5 3.4"); + tick.setAttribute("fill", "none"); + tick.setAttribute("stroke", "currentColor"); + tick.setAttribute("stroke-width", "1.6"); + tick.setAttribute("stroke-linecap", "round"); + tick.setAttribute("stroke-linejoin", "round"); + + svg.append(ground, tick); + return svg; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * A placeholder mark, not Bluesky's own. + * + * Drawing somebody else's logo from memory gets it subtly wrong, and their + * real one is an asset with terms attached rather than a path to invent. This + * is a butterfly-ish glyph in the site's own line weight, easy to find and + * meant to be replaced - grep `blueskyMark` when the real mark is settled. + */ +function blueskyMark(): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "badge-mark"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + + const wings = document.createElementNS(SVG_NS, "path"); + wings.setAttribute( + "d", + "M8 6.4C6.9 4.3 4.6 2.2 3.1 2.6 1.9 2.9 1.7 4.6 2 6.2c.3 1.6 1.4 2.6 3 2.9-1.4.4-2 1.3-1.6 2.2.5 1 2 1.1 3 .4.9-.6 1.4-1.6 1.6-2.6.2 1 .7 2 1.6 2.6 1 .7 2.5.6 3-.4.4-.9-.2-1.8-1.6-2.2 1.6-.3 2.7-1.3 3-2.9.3-1.6.1-3.3-1.1-3.6-1.5-.4-3.8 1.7-4.9 3.8Z", + ); + wings.setAttribute("fill", "none"); + wings.setAttribute("stroke", "currentColor"); + wings.setAttribute("stroke-width", "1.2"); + wings.setAttribute("stroke-linejoin", "round"); + + svg.append(wings); + return svg; +} + +/** The page, once the account behind the address is known. */ +function profile(actor: Actor, session: Session | null): Node[] { + const self = session?.did === actor.did; + // The row reads label, rule, then what there is to do about this player: + // the action first and where else they are after it, since one of those is + // a decision and the other is a footnote. Friending will land here too, + // between them. + // Where a press that could not be started says so. Empty and on the page + // from the first frame, because a live region added and written in the same + // breath announces nothing. + const trouble = el("p", { className: "error", role: "alert" }); + const actions = [ + challengeButton(actor, session, trouble), + accountMark(actor, session), + ] + .filter((node) => node !== null) + .map((node) => el("li", {}, [node])); + + return [ + el("section", { className: "profile" }, [ + el("div", { className: "profile-eyebrow" }, [ + el("span", { className: "profile-label", textContent: "Player" }), + el("ul", { className: "profile-actions" }, actions), + ]), + trouble, + el("div", { className: "profile-head" }, [ + el("div", { className: "profile-who" }, [ + el("h1", { + className: "profile-name", + textContent: actor.handle ? `@${actor.handle}` : actor.did, + }), + ]), + el("div", { className: "profile-plate" }, [face(actor)]), + ]), + el( + "div", + { className: "profile-scores" }, + SCORES.map((spec) => figure(spec, actor, self)), + ), + el( + "div", + { className: "profile-counts" }, + COUNTS.map((spec) => figure(spec, actor, self)), + ), + ]), + footer(), + ]; +} + +/** + * The one address this page is published at, for anything that has to name it + * once: the handle's, or the DID's for an account with no handle to use. + * + * The same form the site writes everywhere else - two addresses answering one + * page is a feature for whoever is typing and a problem for anything counting + * them. 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: the distribution sends it to the unfurl route instead. + */ +function markCanonical(actor: Actor): void { + const href = new URL(profileHref(actor), 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) { + // /profile 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(profileHref(session)); + 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); + document.title = `${actor.handle ? `@${actor.handle}` : actor.did} · lance.blue`; + render(...profile(actor, session)); + app?.removeAttribute("aria-busy"); +} + +void start(); diff --git a/services/api/src/routes.rs b/services/api/src/routes.rs --- a/services/api/src/routes.rs +++ b/services/api/src/routes.rs @@ -123,12 +123,12 @@ .route("/reports/{id}/card.png", get(crate::share::card)) .route("/reports/fonts/{name}", get(crate::share::font)) // What a link to a player unfurls as. A second prefix rather than - // /players/, because that address belongs to the site and is served + // /profile/, because that address belongs to the site and is served // from the bucket: the distribution sends a card fetcher here and // everybody else there. See unfurl.rs. - .route("/unfurl/players/{actor}", get(crate::unfurl::player)) + .route("/unfurl/profile/{actor}", get(crate::unfurl::player)) .route( - "/unfurl/players/{actor}/card.png", + "/unfurl/profile/{actor}/card.png", get(crate::unfurl::player_card), ) .route("/match/{id}", get(crate::proxy::entry)) diff --git a/services/api/src/unfurl.rs b/services/api/src/unfurl.rs --- a/services/api/src/unfurl.rs +++ b/services/api/src/unfurl.rs @@ -1,6 +1,6 @@ //! What a link to a player unfurls as. //! -//! `/players/` is a page the site builds once and the browser +//! `/profile/` is a page the site builds once and the browser //! fills in, which is right for a reader and useless to a card service: an //! unfurler runs no JavaScript, so one shell would preview as the same //! generic card for every player on the site. @@ -44,7 +44,7 @@ /// card prints and what the tags say. name: String, /// The same handle without its @, or None. What the address is written - /// with: `/players/@alice.example` carries one @ and not two. + /// with: `/profile/@alice.example` carries one @ and not two. handle: Option, did: String, /// The account's own picture, as it uploaded it. None where it has none. @@ -134,12 +134,12 @@ /// The address of the page this is a preview of. fn here(&self, origin: &str) -> String { - format!("{origin}/players/{}", self.address()) + format!("{origin}/profile/{}", self.address()) } /// The picture's own address. /// - /// Under this prefix and not under the page's: `/players/...` is the + /// Under this prefix and not under the page's: `/profile/...` is the /// site's, and the distribution answers all of it from the bucket - a /// card asked for there would come back as the shell's HTML. /// @@ -148,7 +148,7 @@ /// than on the name it currently goes by means a rename does not strand /// the card a post is already carrying. fn card(&self, origin: &str) -> String { - format!("{origin}/unfurl/players/{}/card.png", self.did) + format!("{origin}/unfurl/profile/{}/card.png", self.did) } } @@ -308,10 +308,10 @@ fn the_page_names_the_site_and_the_card_names_this_service() { let player = player(); let origin = "https://lance.blue"; - assert_eq!(player.here(origin), "https://lance.blue/players/a.example"); + assert_eq!(player.here(origin), "https://lance.blue/profile/a.example"); assert_eq!( player.card(origin), - "https://lance.blue/unfurl/players/did:plc:abc123/card.png" + "https://lance.blue/unfurl/profile/did:plc:abc123/card.png" ); } @@ -323,16 +323,16 @@ let html = document(&player, &player.here(origin), &player.card(origin)); assert!(html.contains("")); assert!(html.contains( - "" + "" )); assert!(html.contains( - "" + "" )); assert!(html.contains("")); // The canonical address a crawler routed here should index is the // page's, never this document's own. assert!( - html.contains("") + html.contains("") ); } @@ -344,7 +344,7 @@ let player = player(); let html = document( &player, - "https://lance.blue/players/a.example", + "https://lance.blue/profile/a.example", "https://x/c.png", ); assert!(html.contains(&format!( @@ -369,7 +369,7 @@ }; let html = document( &hostile, - "https://lance.blue/players/x", + "https://lance.blue/profile/x", "https://lance.blue/c.png", ); assert!(!html.contains(" - diff --git a/web/src/pages/profile/index.astro b/web/src/pages/profile/index.astro new file mode 100644 --- /dev/null +++ b/web/src/pages/profile/index.astro @@ -0,0 +1,30 @@ +--- +/** + * The shell every player's page is served from. + * + * One file, for /profile/ and /profile/ 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 profile-page.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"; +--- + + +
+ +
+ +