From 758cf27c8380fde6bb543f249a8e53bf15ed99fb Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 15:16:57 -0400 Subject: [PATCH] feat(player-profile): challenge a player from their own page Challenge opens a duel with them already seated opposite, and the account mark says whether you follow them. --- plan/player-profile.md | 22 ++++- web/scripts/players.test.mjs | 63 +++++++++++++++ web/src/challenge-stash.ts | 61 ++++++++++++++ web/src/players.ts | 151 +++++++++++++++++++++++++++++------ web/src/relationships.ts | 80 +++++++++++++++++++ web/src/router.ts | 22 +++++ web/src/screens/lobby.ts | 64 ++++++++++++++- web/src/styles.css | 51 +++++++++++- 8 files changed, 483 insertions(+), 31 deletions(-) create mode 100644 web/src/challenge-stash.ts create mode 100644 web/src/relationships.ts diff --git a/plan/player-profile.md b/plan/player-profile.md index 9fd70a4..172d7d9 100644 --- a/plan/player-profile.md +++ b/plan/player-profile.md @@ -52,7 +52,13 @@ dash. Those two are the only part of this still waiting. space never reads as a hole. - [ ] **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. + none of them leads anywhere. That is the next thing worth doing here, + and it is what makes Challenge reachable from inside a match rather + than only from an address somebody was given. +- [ ] **Friending, beside Challenge.** The action row is a list for this + reason. What it would mean here is undecided - a Bluesky follow written + to the player's own repo is one answer and a thing of ours is another - + so it waits on somebody choosing. - [ ] **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, @@ -93,6 +99,20 @@ now, mounted beside the account's and waiting on nothing. ## Done +- [x] **There is something to do on somebody else's page.** Challenge stashes + who it is about and opens a duel lobby with them seated opposite - the + press already said who, so the screen that asks is skipped. Signed out + it is inert and says why rather than vanishing: a stranger who arrived + from a post is exactly who it is for, and a control that is missing + tells them nothing. Seating happens on the first seat snapshot, which is + the first one after a scenario is chosen, and once: a seat this arranges + is one anybody in the lobby may then change. +- [x] **The mark says whether you follow them.** One unauthenticated call to + the public appview (`app.bsky.graph.getRelationships`), which is a + question about the reader rather than about the player, so nothing on + 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 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 diff --git a/web/scripts/players.test.mjs b/web/scripts/players.test.mjs index 0f5ce64..455c032 100644 --- a/web/scripts/players.test.mjs +++ b/web/scripts/players.test.mjs @@ -137,3 +137,66 @@ test("the page and the card count the same things, in the same two kinds", async "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/); +}); + +test("signing out disables Challenge rather than hiding it", 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], /button\.disabled = true;/); + // The reason goes in the accessible name: a disabled control is skipped by + // a screen reader's own controls list, so a tooltip alone says it to + // nobody who cannot see it. + assert.match(fn[0], /aria-label[\s\S]*sign in/i); + // And it is absent on your own page, where the answer is nobody. + assert.match(fn[0], /session\?\.did === actor\.did\) return null;/); +}); diff --git a/web/src/challenge-stash.ts b/web/src/challenge-stash.ts new file mode 100644 index 0000000..f407402 --- /dev/null +++ b/web/src/challenge-stash.ts @@ -0,0 +1,61 @@ +/** + * 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 + * 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 + * Challenge, signs in, and comes back from their provider with the address + * they started on gone (see router.ts's note on hasPendingCamo for why). + * Both are the same problem, and this is the mechanism lobby-invite-stash.ts + * already solved it with, pointed at a handle. + * + * Its own module, importing nothing, for that file's two reasons: router.ts + * peeks on every landing whichever screen ends up rendered, and a file with + * no imports of its own is one `node --test` can load without a build step. + */ + +const PENDING_KEY = "lance.blue:challenge:pending"; + +/** + * Whether somebody is on their way into a challenge, without consuming it. + * + * Peek and consume are separate for the reason the camo editor's are: the + * router decides which screen to put up from this, and it must not spend the + * answer to find out. + */ +export function hasPendingChallenge(): boolean { + return readStash(false) !== null; +} + +/** + * The player to challenge, taken. Null when there is none, or when storage + * refused — a browser in a private mode is not an error here, it is a + * challenge that has to be started the ordinary way. + */ +export function takePendingChallenge(): string | null { + return readStash(true); +} + +/** Set immediately before the navigation that leaves the player's page. */ +export function stashChallenge(actor: string): void { + try { + sessionStorage.setItem(PENDING_KEY, actor); + } catch { + // Storage refused. The press still navigates, and the player lands on + // Play with nobody seated — the flow they would have taken anyway, minus + // one step. + } +} + +function readStash(consume: boolean): string | null { + let actor: string | null = null; + try { + actor = sessionStorage.getItem(PENDING_KEY); + if (consume) sessionStorage.removeItem(PENDING_KEY); + } catch { + return null; + } + return actor === null || actor === "" ? null : actor; +} diff --git a/web/src/players.ts b/web/src/players.ts index d765a90..52a9fd9 100644 --- a/web/src/players.ts +++ b/web/src/players.ts @@ -20,11 +20,13 @@ * match count, and it is only ever your own. */ -import { currentSession, listMatches } from "./api"; +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"; /** @@ -194,32 +196,122 @@ function figure(spec: Figure, actor: Actor, self: boolean): HTMLElement { } /** - * Where else this account is, as marks rather than as sentences. + * The one thing to do with somebody else's page. + * + * A press stashes who it is about and leaves for Play, where the router + * opens a duel lobby with them seated opposite (challenge-stash.ts, and + * router.ts's "challenge" 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 inert and says why rather than disappearing - a stranger + * who came from a post is exactly who this is for, and a control that is + * missing tells them nothing. Absent only on your own page, where the answer + * to "challenge" is nobody. + */ +function challengeButton( + actor: Actor, + session: Session | null, +): HTMLElement | null { + if (session?.did === actor.did) return null; + + const button = el("button", { + type: "button", + className: "profile-challenge plate", + textContent: "Challenge", + }); + + if (!session) { + button.disabled = true; + button.title = "Sign in to challenge a player."; + // A disabled control is skipped by a screen reader's own controls list, + // so the reason goes in the accessible name rather than only in a + // tooltip a pointer has to find. + button.setAttribute( + "aria-label", + "Challenge — sign in to challenge a player", + ); + return button; + } + + button.addEventListener("click", () => { + stashChallenge(actor.handle ? `@${actor.handle}` : actor.did); + window.location.assign("/#matches"); + }); + 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. The shape is a list - * because it is going to be more than one - the same account is reachable - * through whatever else speaks ATProto - and each mark carries its own - * accessible name, since a badge with no words is nothing to a screen reader. + * 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 badges(actor: Actor): HTMLElement { +function accountMark(actor: Actor, session: Session | null): HTMLElement { const where = actor.handle ?? actor.did; - return el("ul", { className: "profile-badges" }, [ - el("li", {}, [ - el( - "a", - { - className: "profile-badge", - href: `https://bsky.app/profile/${where}`, - rel: "noopener", - title: "On Bluesky", - "aria-label": "On Bluesky", - }, - [blueskyMark()], - ), - ]), - ]); + 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"; @@ -254,12 +346,21 @@ function blueskyMark(): SVGSVGElement { } /** The page, once the account behind the address is known. */ -function profile(actor: Actor, self: boolean): Node[] { +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. + const actions = [challengeButton(actor, session), 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" }), - badges(actor), + el("ul", { className: "profile-actions" }, actions), ]), el("div", { className: "profile-head" }, [ el("div", { className: "profile-who" }, [ @@ -339,7 +440,7 @@ async function start(): Promise { markCanonical(actor); document.title = `${actor.handle ? `@${actor.handle}` : actor.did} · lance.blue`; - render(...profile(actor, session?.did === actor.did)); + render(...profile(actor, session)); app?.removeAttribute("aria-busy"); } diff --git a/web/src/relationships.ts b/web/src/relationships.ts new file mode 100644 index 0000000..2e2d12c --- /dev/null +++ b/web/src/relationships.ts @@ -0,0 +1,80 @@ +/** + * Whether the signed-in player follows somebody, on Bluesky. + * + * One request to the public appview, which answers this unauthenticated: + * `app.bsky.graph.getRelationships` takes an actor and a list of others and + * says, per other, which way the follows run. No route of ours is involved + * and nothing here carries a token — a follow is public, and this is the same + * appview avatars.ts already talks to. + * + * Deliberately not `/api/opponents/suggested`, which exists and answers a + * different question: that one is "who do I follow, who has played here, and + * who have I not faced yet", scoped to a list worth suggesting. This is one + * yes or no about one account. + * + * The answer is a nicety and never a gate. Anything that goes wrong — a slow + * appview, an account it has never indexed, a network that is not there — is + * "we do not know", and what a caller does with that is show nothing. + */ + +const APPVIEW = "https://public.api.bsky.app"; + +/** What the appview knows about how two accounts are connected. */ +export interface Relationship { + /** Whether the viewer follows the other account. */ + readonly following: boolean; + /** Whether the other account follows the viewer back. */ + readonly followedBy: boolean; +} + +const NEITHER: Relationship = { following: false, followedBy: false }; + +/** One answer per pair for the life of the page. */ +const cache = new Map>(); + +/** + * How `viewer` is connected to `other`, both named by DID. + * + * Both directions are read though only one is shown today: they arrive in the + * same answer, and "they follow you back" is the other half of the mutual the + * front page's own copy is about. + */ +export function relationship( + viewer: string, + other: string, +): Promise { + const key = `${viewer} ${other}`; + const known = cache.get(key); + if (known) return known; + + const pending = lookup(viewer, other).catch((error: unknown) => { + console.warn("relationships: the appview did not answer", error); + // Not remembered: one flaky lookup should not settle the question for the + // life of the page. + cache.delete(key); + return NEITHER; + }); + cache.set(key, pending); + return pending; +} + +async function lookup(viewer: string, other: string): Promise { + const url = + `${APPVIEW}/xrpc/app.bsky.graph.getRelationships` + + `?actor=${encodeURIComponent(viewer)}&others=${encodeURIComponent(other)}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`getRelationships answered ${response.status}`); + } + const body = (await response.json()) as { + relationships?: { following?: unknown; followedBy?: unknown }[]; + }; + // A follow is an at:// URI where there is one and absent where there is + // not. Everything here comes off the network, so nothing is assumed about + // its shape. + const found = body.relationships?.[0]; + return { + following: typeof found?.following === "string", + followedBy: typeof found?.followedBy === "string", + }; +} diff --git a/web/src/router.ts b/web/src/router.ts index dc02a0b..235177a 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -26,6 +26,7 @@ import { capturePageview } from "./analytics"; import { ApiError, currentSession } from "./api"; import { camoEditor, hasPendingCamo } from "./camo/editor"; import { footer, markCurrent, type Route } from "./chrome"; +import { takePendingChallenge } from "./challenge-stash"; import { hasPendingDailyPlay, takePendingDailyPlay } from "./daily-play-stash"; import { type AboutPageId, @@ -271,6 +272,27 @@ export async function start(): Promise { if (session) { if (route === "matches") { + // Always taken, the posture every stash here has: a visit to Play for + // any other reason clears one an abandoned sign-in left, so an old + // Challenge press cannot seat somebody into a lobby opened days + // later. + const challenged = takePendingChallenge(); + if (challenged) { + // A duel with them already opposite, rather than Play with a form + // to fill in: the press that got here already said who. Rendered + // directly and marked by hand for the reason matches.ts gives for + // its own Mode cards - a fresh lobby has no address until it has + // minted an id. + markEntered("lobby"); + render( + ...lobbyScreen(session, { + kind: "fresh", + mode: "duel", + invite: challenged, + }), + ); + return; + } render(...matchesScreen(session)); return; } diff --git a/web/src/screens/lobby.ts b/web/src/screens/lobby.ts index 40892f2..1d93319 100644 --- a/web/src/screens/lobby.ts +++ b/web/src/screens/lobby.ts @@ -92,7 +92,13 @@ function at(handle: string): string { * one into the other is a lookup in a file this screen already has. */ export type LobbyEntry = - | { kind: "fresh"; mode: ChallengeMode } + /** + * A lobby nobody has opened yet. `invite` is who to seat opposite once + * there are seats to seat them in - set when the player pressed Challenge + * on somebody's page, and unset for the Duel and Scenario cards, which are + * an invitation to choose. + */ + | { kind: "fresh"; mode: ChallengeMode; invite?: string } | { kind: "join"; matchId: string } | { kind: "daily"; slug: string }; @@ -208,6 +214,15 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { */ const daily: Challenge | null = entry.kind === "daily" ? challengeBySlug(entry.slug) : null; + /** + * Who the player pressed Challenge on, until they have been seated. + * + * Spent on the first seat snapshot that has room for them and never read + * again — a seat this arranges is an arrangement anybody in the lobby may + * then change, and a pending invite that fired twice would put them back + * after somebody kicked them. + */ + let toSeat = entry.kind === "fresh" ? (entry.invite ?? null) : null; const status = el("p", { className: "error", role: "alert" }); const scenarioNote = el("p", { className: "hint", @@ -1228,6 +1243,49 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { return team === null ? `slot:${slotName}` : `team:${team}`; } + /** + * Set up the fight the Challenge press asked for: this player in the first + * seat, the player they pressed it on in the next. + * + * Only on the first snapshot with seats in it, which is the first one after + * a scenario is chosen — a fresh lobby has no slots until then, so a + * challenge is arranged the moment the scenario is, not before. + * + * Both moves are sent and neither is assumed: the API answers with a + * snapshot of what it did, the same as it does for a seat claimed by hand, + * and this screen draws that. An invite whose handle does not resolve seats + * nobody, which is what the invite form does too — see `seatActions`. + */ + function seatTheChallenged(): void { + if (!toSeat || !seats.length) return; + const wanted = toSeat; + toSeat = null; + + let ours = seats.find((seat) => seat.did === session.did)?.slot ?? null; + if (!ours) { + const open = seats.find((seat) => seat.control !== "human"); + if (open) { + socket?.claimSeat(open.slot); + ours = open.slot; + } + } + + // Theirs is the next seat that is neither ours nor already somebody's. + // Compared by slot rather than by DID: the seat just claimed is still + // empty in *this* snapshot - the one that says otherwise has not arrived + // yet - so a DID test would offer them the seat we are sitting down in. + // + // A scenario with one slot has nowhere to put them, and nothing is sent + // rather than somebody being displaced. + const theirs = seats.find( + (seat) => + seat.slot !== ours && + seat.did !== session.did && + seat.control !== "human", + ); + if (theirs) socket?.inviteHandle(theirs.slot, wanted.replace(/^@/, "")); + } + function renderSeats(): void { seatList.replaceChildren(); repaint.clear(); @@ -1637,6 +1695,10 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { // Who is seated decides whether this player has a Ready button at all, // and who the rest of the room is still waiting for. renderReady(); + // Last, and after the render for the reason the roll above is: this + // sends two messages the API answers with a snapshot of its own, and + // that snapshot is what draws the arrangement. + seatTheChallenged(); }, onDeployed(newMatchId: string) { render(...waitingScreen(session, newMatchId)); diff --git a/web/src/styles.css b/web/src/styles.css index d319a15..d713d5b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2885,17 +2885,44 @@ footer .debug { background: var(--line); } -/* Where else this account is. Last in the row, so the rule runs out from the - label and stops at them. */ -.profile-badges { +/* What there is to do about this player, and where else they are. Last in the + row, so the rule runs out from the label and stops at them. */ +.profile-actions { order: 2; display: flex; - gap: 0.4rem; + align-items: center; + gap: 0.5rem; margin: 0; padding: 0; list-style: none; } +/* The one action, in the filled treatment the masthead's Play wears: this is + the thing to do on somebody else's page. */ +.profile-challenge { + padding: 0.3rem 0.9rem; + border: 1px solid transparent; + color: var(--on-accent); + background: var(--accent); + font-size: 0.72rem; + cursor: pointer; + --plate-ring: var(--on-accent); +} + +.profile-challenge:hover:not(:disabled) { + opacity: 0.85; +} + +/* Signed out: inert, and still legible. Not dimmed into the ground - the + reason it cannot be pressed is in its own accessible name, and a control + nobody can read is a control nobody can act on. */ +.profile-challenge:disabled { + color: var(--muted); + background: none; + border-color: var(--line); + cursor: not-allowed; +} + .profile-badge { display: grid; place-items: center; @@ -2924,6 +2951,22 @@ footer .debug { height: 1rem; } +/* You follow them: a tick in the mark's low-right corner, on the page's own + ground so it reads as a stamp on the badge rather than as part of the + glyph under it. The badge's title says what it means. */ +.profile-badge { + position: relative; +} + +.badge-tick { + position: absolute; + right: -0.18rem; + bottom: -0.18rem; + width: 0.72rem; + height: 0.72rem; + color: var(--accent); +} + .profile-head { display: flex; align-items: flex-start; -- 2.51.2