From bc2d1cb1e532f68f1f244154ee14c08eddf49ecf Mon Sep 17 00:00:00 2001 From: iamlewis Date: Sat, 3 Oct 2026 13:35:00 +0100 Subject: [PATCH 01/10] Open the player profile from /player/ links The canonical profile link is now https:///player/, the link the site unfurls into a profile card. Opening it, with or without a trailing slash, behind a /w worker prefix, and with an optional ?tab=stats|games|clans|progression, opens that profile on load. The hash form (#modal=profile&publicID=...) keeps working. The modal router learns path routes: while a profile opened from its path stays open the URL keeps the path, with tab changes written to ?tab=; closing it (Back, Escape) returns to the home page and replaces the URL with / so a reload doesn't reopen it. Opening another modal hands the URL back to the hash. On the profile: - Signed-out viewers get a "New to OpenFront?" banner above the card, whose Play button closes the profile onto the home page. - Share buttons under the card: Copy link, Share on X, and Copy for Discord (a short line and the link, which Discord unfurls). Touch devices with a native share sheet get one Share button instead. Copies toast as elsewhere and are announced to screen readers. - Not found now explains itself, shows the link that was opened, and offers Play OpenFront and View leaderboard. The friends box accepts a pasted /player/ link. Game server workers serve the app shell at /player/ (the master's SPA fallback already did for the unprefixed form), and the dev server does the same for /w/player/. Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 14 +- src/client/Main.ts | 7 + src/client/ModalRouter.ts | 83 +++++ src/client/PlayerProfileModal.ts | 136 +++++++- src/client/components/FriendsList.ts | 9 +- src/client/components/ProfileShare.ts | 204 ++++++++++++ src/client/utilities/PlayerProfileUrl.ts | 53 +++- src/server/PlayerProfileRoute.ts | 36 +++ src/server/Worker.ts | 3 + .../client/AccountGameStatsNavigation.test.ts | 1 + tests/client/NavAccountMenu.test.ts | 4 +- tests/client/PlayerProfileClansTab.test.ts | 8 +- tests/client/PlayerProfileModal.test.ts | 9 +- tests/client/PlayerProfilePage.test.ts | 296 ++++++++++++++++++ .../PlayerProfileProgressionTab.test.ts | 6 + tests/client/PlayerProfileUrl.test.ts | 79 +++++ .../client/ProfileGameStatsNavigation.test.ts | 6 + tests/client/ProfileShare.test.ts | 168 ++++++++++ tests/client/ShareBase.test.ts | 26 +- .../FriendsListPublicIdInput.test.ts | 12 + tests/server/PlayerProfileRoute.test.ts | 77 +++++ vite.config.ts | 5 +- 22 files changed, 1212 insertions(+), 30 deletions(-) create mode 100644 src/client/components/ProfileShare.ts create mode 100644 src/server/PlayerProfileRoute.ts create mode 100644 tests/client/PlayerProfilePage.test.ts create mode 100644 tests/client/PlayerProfileUrl.test.ts create mode 100644 tests/client/ProfileShare.test.ts create mode 100644 tests/server/PlayerProfileRoute.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 5926f994fe4..126248cde62 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1671,11 +1671,21 @@ "milestones": "Milestones", "no_clans": "This player isn't in any clans.", "no_prestige": "Hasn't prestiged yet", - "not_found": "Player profile not found", + "not_found_body": "We couldn't find a player at this link. It may be mistyped, or the account may have been deleted.", + "not_found_title": "Player not found", + "play": "Play OpenFront", "prestige_history": "Prestige history", "share": "Share Profile", + "share_copy_link": "Copy link", + "share_discord": "Copy for Discord", + "share_native": "Share", + "share_text": "Check out {name}'s profile on OpenFront", + "share_x": "Share on X", "title": "Player Profile", - "view": "View Profile" + "view": "View Profile", + "view_leaderboard": "View leaderboard", + "visitor_body": "Conquer the map in a free multiplayer strategy game, right in your browser.", + "visitor_title": "New to OpenFront?" }, "player_stats_table": { "arrived": "Arrived", diff --git a/src/client/Main.ts b/src/client/Main.ts index 4449ed73ee5..3aeb917e421 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -168,6 +168,7 @@ import { installDoubleTapZoomBlocker, installSafariPinchZoomBlocker, } from "./utilities/DisableSafariPinchZoom"; +import { playerProfileRouteArgs } from "./utilities/PlayerProfileUrl"; import "./components/DesktopNavBar"; import "./components/DetailedGameViewModal"; @@ -430,6 +431,8 @@ class Client { tag: "player-profile-modal", pageId: "page-profile", }); + // The canonical profile link, `/player/` (see playerProfileUrl). + modalRouter.registerPath("profile", playerProfileRouteArgs); modalRouter.register("help", { tag: "help-modal", pageId: "page-help" }); modalRouter.register("news", { tag: "news-modal", pageId: "page-news" }); modalRouter.register("language", { @@ -1334,6 +1337,10 @@ class Client { console.log(`${spectate ? "spectating" : "joining"} lobby ${lobbyId}`); return; } + // A shared profile link (`/player/`) opens that profile. + if (modalRouter.routeFromPath()) { + return; + } if (modalRouter.routeFromHash()) { return; } diff --git a/src/client/ModalRouter.ts b/src/client/ModalRouter.ts index 01622405727..8bf752ac227 100644 --- a/src/client/ModalRouter.ts +++ b/src/client/ModalRouter.ts @@ -7,8 +7,21 @@ * * Lobby modals (join/host) and matchmaking are intentionally not registered: * they have their own URL state (path-based) or none at all. + * + * A modal can also be opened by a path link (`/player/`, see + * registerPath). While it stays open the URL keeps that path, with tab + * changes written to `?tab=`; closing it returns the URL to the home page, so + * a reload doesn't reopen it. */ +import { pagePin } from "./PagePin"; + +/** Parses a page location into a modal's open() args, or null if it isn't one. */ +export type PathRouteParser = (location: { + pathname: string; + search: string; +}) => Record | null; + interface RegistryEntry { /** Custom element tag, e.g. "store-modal". */ tag: string; @@ -32,11 +45,55 @@ class ModalRouter { private currentName: string | null = null; /** True while we're routing from the URL (suppress modal→URL sync). */ private routingFromUrl = false; + private pathRoutes = new Map(); + /** Name of the modal the URL's path (not its hash) reflects, if any. */ + private pathRoutedName: string | null = null; register(name: string, entry: RegistryEntry): void { this.registry.set(name, entry); } + /** Let a registered modal be opened by a path link as well as the hash. */ + registerPath(name: string, parse: PathRouteParser): void { + this.pathRoutes.set(name, parse); + } + + /** + * Open the modal the current path names, if one does. Returns true when it + * did (the open itself is asynchronous, as for routeFromHash). + */ + routeFromPath(): boolean { + for (const [name, parse] of this.pathRoutes) { + const entry = this.registry.get(name); + if (entry === undefined) continue; + const args = parse(window.location); + if (args === null) continue; + this.pathRoutedName = name; + void this.openRegistered(name, entry, args); + return true; + } + return false; + } + + /** True while the named modal is open from a path link. */ + isPathRouted(name: string): boolean { + return this.pathRoutedName === name; + } + + // The path link has had its say: put the URL back to the home page. The + // home page keeps the page's version pin when it has one (see + // currentPagePath in Utils, which this mirrors without its imports). + private leavePath(): void { + if (this.pathRoutedName === null) return; + this.pathRoutedName = null; + const commit = pagePin(); + history.replaceState( + history.state, + "", + commit === null ? "/" : `/v/${commit}/`, + ); + } + /** * Parse `window.location.hash` for `#modal=&...`. If present and * registered, open the modal with the remaining keys as args. Returns true @@ -97,6 +154,8 @@ class ModalRouter { syncOpened(name: string, args?: Record): void { if (this.routingFromUrl) return; // we're driving the modal from the URL; don't loop if (!this.registry.has(name)) return; + // Another modal (or this one again) opened in-app: the hash takes over. + this.leavePath(); this.currentName = name; this.writeHash(name, args); } @@ -106,6 +165,10 @@ class ModalRouter { if (this.routingFromUrl) return; if (this.currentName !== name) return; // not the active routed modal this.currentName = null; + if (this.pathRoutedName === name) { + this.leavePath(); + return; + } this.replaceHash(""); } @@ -113,6 +176,24 @@ class ModalRouter { syncTab(name: string, tab: string): void { if (this.routingFromUrl) return; if (this.currentName !== name) return; + if (this.pathRoutedName === name) { + // Opened from a path: the path stays, and the tab rides as `?tab=`. + const search = new URLSearchParams(window.location.search); + if (tab) { + search.set("tab", tab); + } else { + search.delete("tab"); + } + const query = search.toString(); + history.replaceState( + history.state, + "", + window.location.pathname + + (query ? `?${query}` : "") + + window.location.hash, + ); + return; + } const params = this.currentHashParams(); params.set("modal", name); if (tab) { @@ -127,6 +208,8 @@ class ModalRouter { syncArgs(name: string, args: Record): void { if (this.routingFromUrl) return; if (this.currentName !== name) return; + // The path is the state of a path-routed modal; don't add a hash to it. + if (this.pathRoutedName === name) return; const params = this.currentHashParams(); params.set("modal", name); for (const [key, value] of Object.entries(args)) { diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index a495de47ac8..01c5b19b2ea 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -8,6 +8,7 @@ import { type PublicProgress, } from "../core/ApiSchemas"; import { fetchPublicPlayerProfile } from "./Api"; +import "./components/baseComponents/Button"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTree"; @@ -16,9 +17,12 @@ import "./components/clan/ClanCard"; import "./components/PlayerName"; import "./components/ProfileCard"; import "./components/ProfileProgression"; +import "./components/ProfileShare"; import { modalHeader } from "./components/ui/ModalHeader"; import { usernameText } from "./components/ui/UsernameText"; import { verifiedBadge } from "./components/ui/VerifiedBadge"; +import { modalRouter } from "./ModalRouter"; +import { resolveXpAccount } from "./ProgressionAccount"; import { fetchPublicPlayerProgress } from "./ProgressionApi"; import { playerProfileUrl } from "./utilities/PlayerProfileUrl"; import { currentPagePath, translateText } from "./Utils"; @@ -39,6 +43,12 @@ export class PlayerProfileModal extends BaseModal { // Level / XP, when progression is on and the player has any. @state() private progress: PublicProgress | null = null; @state() private loading = false; + // Only a signed-out viewer is offered the game itself (the visitor + // banner). False until known, so a signed-in player never sees it flash. + @state() private viewerSignedOut = false; + // The link this profile was opened from (`openfront.io/player/`), when + // it was opened from one: the not-found state shows it. + @state() private openedLink: string | null = null; private openedFrom: ProfileOrigin | null = null; // Mirrors the account modal's Games tab: keep the accumulated history list + // cursor across tab switches (and the game-stats detour) so re-entering Games @@ -230,11 +240,19 @@ export class PlayerProfileModal extends BaseModal { if (this.loading) { return this.renderLoadingSpinner(translateText("player_profile.loading")); } - if (!this.profileLoaded()) { + const publicId = this.publicId; + if (!this.profileLoaded() || publicId === null) { return this.renderNotFound(); } // The card heads the stats; games and wins stay in the stats below it. + // A visitor who doesn't play yet is offered the game above it, and the + // ways to share the profile follow it. + // + // A "Show my profile in search engines" setting will join the account + // settings' Privacy card once the API carries the flag and the site + // Worker honours it with noindex; profile links are indexable until then. return html` + ${this.viewerSignedOut ? this.renderVisitorBanner() : nothing} ${this.progress === null ? nothing : html``} + `; } + private renderVisitorBanner() { + return html` +
+
+
+ ${translateText("player_profile.visitor_title")} +
+
+ ${translateText("player_profile.visitor_body")} +
+
+ this.goHome()} + > +
+ `; + } + private renderProgression() { // The tab is hidden without progress; should it still be selected (a new // player opened on top), show the Stats content rather than nothing. @@ -277,15 +325,89 @@ export class PlayerProfileModal extends BaseModal { private renderNotFound() { return html` -
- 📊 -

- ${translateText("player_profile.not_found")} +

+
+ +
+

+ ${translateText("player_profile.not_found_title")} +

+

+ ${translateText("player_profile.not_found_body")}

+ ${this.openedLink === null + ? nothing + : html`

+ ${this.openedLink} +

`} +
+ this.goHome()} + > + this.openLeaderboard()} + > +
`; } + // Close the profile onto the home page (the URL follows: see ModalRouter). + private goHome(): void { + this.close(); + } + + private openLeaderboard(): void { + this.close(); + document + .querySelector("leaderboard-modal") + ?.open(); + } + + // Signed-out viewers get the visitor banner. The same "signed in" rule as + // the XP surfaces (a guest session is not a sign-in); an account whose + // details didn't load is treated as signed in, so it isn't pitched to. + private async loadViewer(gen: number): Promise { + let signedOut: boolean; + try { + signedOut = (await resolveXpAccount()).kind === "signed_out"; + } catch { + signedOut = false; + } + if (gen === this.loadGeneration) this.viewerSignedOut = signedOut; + } + protected onOpen(args?: Record): void { const publicId = typeof args?.publicID === "string" && args.publicID.length > 0 @@ -327,6 +449,9 @@ export class PlayerProfileModal extends BaseModal { this.gameHistoryCache = null; this.gamesScrollTop = 0; this.restoreGamesScrollAfterOpen = false; + this.openedLink = modalRouter.isPathRouted("profile") + ? `${window.location.host}${window.location.pathname}` + : null; this.loading = publicId !== null; if (publicId !== null) { void this.loadProfile(publicId); @@ -340,6 +465,7 @@ export class PlayerProfileModal extends BaseModal { // can't reject a stale same-player load started before an earlier close. const current = () => gen === this.loadGeneration && this.publicId === publicId; + void this.loadViewer(gen); // The level is a nice-to-have: it lands whenever it arrives and never // holds up the profile (its request can take up to its own timeout). // Only shown alongside a loaded profile (see renderProfile). diff --git a/src/client/components/FriendsList.ts b/src/client/components/FriendsList.ts index 19b0019ceeb..11440d18177 100644 --- a/src/client/components/FriendsList.ts +++ b/src/client/components/FriendsList.ts @@ -10,14 +10,16 @@ import { sendFriendRequest, } from "../FriendsApi"; import { showInGameConfirm } from "../InGameModal"; +import { parsePlayerProfilePath } from "../utilities/PlayerProfileUrl"; import { showToast, translateText } from "../Utils"; import { playerNameLink } from "./ui/PlayerNameLink"; const PAGE_LIMIT = 20; /** - * Accept a pasted profile share link (`…#modal=profile&publicID=abc12345`) and - * keep only the id, mirroring the private-lobby join box. Only strings that are + * Accept a pasted profile share link (`…/player/abc12345`, or the older + * `…#modal=profile&publicID=abc12345`) and keep only the id, mirroring the + * private-lobby join box. Only strings that are * actually URLs are touched: usernames render as `wonder #5005`, so a bare `#` * must be left alone. */ @@ -32,6 +34,9 @@ export function extractPublicIdFromUrl(input: string): string { // so don't log; just hand the raw text back for the server to reject. return input; } + // The canonical link names the player in its path. + const fromPath = parsePlayerProfilePath(url.pathname); + if (fromPath !== null) return fromPath; // Modals are hash-routed (`…/#modal=profile&publicID=abc12345`), so the id is // in the fragment, not the query string — `url.searchParams` is always empty // here. Parse the fragment's own `key=value&…` pairs instead. diff --git a/src/client/components/ProfileShare.ts b/src/client/components/ProfileShare.ts new file mode 100644 index 00000000000..91ee1fb4cec --- /dev/null +++ b/src/client/components/ProfileShare.ts @@ -0,0 +1,204 @@ +import { html, LitElement, nothing, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { Platform } from "../Platform"; +import { copyToClipboard, showToast, translateText } from "../Utils"; + +// Ways to pass a profile on: copy the link, post it to X, or copy a ready +// message for Discord (which unfurls the link into the profile card). On a +// phone or tablet with a native share sheet, one Share button opens that +// instead, since it already offers every app the player has. + +/** The X (Twitter) compose URL for a link and a line of text. */ +export function xShareUrl(url: string, text: string): string { + return `https://x.com/intent/post?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`; +} + +/** What "Copy for Discord" puts on the clipboard: the line, then the link. */ +export function discordShareText(url: string, text: string): string { + return `${text}\n${url}`; +} + +// The native share sheet, where there is one to use. Desktop Chrome on +// Windows has navigator.share too, but there the three buttons say more than +// a generic Share, so the sheet is kept to touch devices. +export function useNativeShare(): boolean { + return ( + typeof navigator !== "undefined" && + typeof navigator.share === "function" && + Platform.isTouch + ); +} + +const BUTTON = + "inline-flex min-h-[38px] items-center gap-2 rounded-[10px] border border-white/15 bg-white/5 px-3.5 text-[13px] font-semibold text-white transition-colors hover:bg-white/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-white/30"; + +const linkIcon = html``; + +const xIcon = html``; + +const chatIcon = html``; + +const shareIcon = html``; + +@customElement("profile-share") +export class ProfileShare extends LitElement { + // The canonical profile link (playerProfileUrl). + @property({ type: String }) url = ""; + // The player's display name, for the line that goes with the link. + @property({ type: String }) name = ""; + + // Read out by screen readers after a copy: the toast that shows it is not + // a live region. + @state() private announcement = ""; + + createRenderRoot() { + return this; + } + + private shareText(): string { + return translateText("player_profile.share_text", { name: this.name }); + } + + private async copy(text: string): Promise { + // Cleared first so copying twice announces twice. + this.announcement = ""; + try { + await copyToClipboard(text); + const copied = translateText("common.copied"); + showToast(copied, "green"); + this.announcement = copied; + } catch { + const failed = translateText("common.failed_copy"); + showToast(failed, "red"); + this.announcement = failed; + } + } + + // Opens in a new tab on the web. In the desktop shell, the shell's + // window-open policy sends every https link to the system browser (the + // same route the shell's other external links take, e.g. linkGoogle). + private shareOnX(): void { + window.open( + xShareUrl(this.url, this.shareText()), + "_blank", + "noopener,noreferrer", + ); + } + + private async nativeShare(): Promise { + try { + await navigator.share({ + title: translateText("player_profile.title"), + text: this.shareText(), + url: this.url, + }); + } catch (err) { + // Dismissing the sheet rejects with AbortError: nothing to do. Any + // other failure falls back to copying the link. + if (err instanceof DOMException && err.name === "AbortError") return; + await this.copy(this.url); + } + } + + private button( + action: string, + icon: TemplateResult, + label: string, + onClick: () => void, + ): TemplateResult { + return html``; + } + + render() { + if (this.url === "") return nothing; + return html` +
+ ${useNativeShare() + ? this.button( + "native", + shareIcon, + translateText("player_profile.share_native"), + () => void this.nativeShare(), + ) + : html` + ${this.button( + "copy", + linkIcon, + translateText("player_profile.share_copy_link"), + () => void this.copy(this.url), + )} + ${this.button( + "x", + xIcon, + translateText("player_profile.share_x"), + () => this.shareOnX(), + )} + ${this.button( + "discord", + chatIcon, + translateText("player_profile.share_discord"), + () => + void this.copy(discordShareText(this.url, this.shareText())), + )} + `} +
+ ${this.announcement} + `; + } +} diff --git a/src/client/utilities/PlayerProfileUrl.ts b/src/client/utilities/PlayerProfileUrl.ts index 9663aca214b..1c8214dfe95 100644 --- a/src/client/utilities/PlayerProfileUrl.ts +++ b/src/client/utilities/PlayerProfileUrl.ts @@ -1,17 +1,58 @@ import { ClientEnv } from "../ClientEnv"; +// The profile's tabs, as `?tab=` on a profile link names them. +export const PLAYER_PROFILE_TABS: readonly string[] = [ + "stats", + "games", + "clans", + "progression", +]; + +// Public ids are short url-safe tokens (the API stores at most 22 +// characters). Anything else in a profile path is not a link to a player. +const PUBLIC_ID = /^[A-Za-z0-9_-]{1,22}$/; + +// `/player/`, with an optional trailing slash, behind the optional +// prefixes any page path can carry: `/v/` (a version's own page) and +// `/w` (a game server's worker). +const PROFILE_PATH = /^(?:\/v\/[^/]+)?(?:\/w\d+)?\/player\/([^/]+)\/?$/; + /** - * Build a shareable profile URL for a publicId. + * Build a shareable profile URL for a publicId: `https:///player/`, + * the canonical link the site unfurls into a profile card. * * Its own module so callers that only need the link — the nav profile menu's * copy action, say — don't pull in the whole profile modal. * - * The base comes from ClientEnv.shareBase(), not window.location, because this - * link is copied to the clipboard to be sent to someone else: under the desktop - * shell the document lives on `app://openfront/index.html`, which is a URL only - * that Electron app can resolve (OPE bug: the Steam build copied + * The origin comes from ClientEnv.shareOrigin(), not window.location, because + * this link is copied to the clipboard to be sent to someone else: under the + * desktop shell the document lives on `app://openfront/index.html`, which is + * a URL only that Electron app can resolve (OPE bug: the Steam build copied * `app://openfront/index.html#modal=profile&publicID=…`). */ export function playerProfileUrl(publicId: string): string { - return `${ClientEnv.shareBase()}#modal=profile&publicID=${encodeURIComponent(publicId)}`; + return `${ClientEnv.shareOrigin()}/player/${encodeURIComponent(publicId)}`; +} + +/** The publicId a profile path names, or null for any other path. */ +export function parsePlayerProfilePath(pathname: string): string | null { + const match = PROFILE_PATH.exec(pathname); + if (match === null || !PUBLIC_ID.test(match[1])) return null; + return match[1]; +} + +/** + * The profile a page URL opens, as the profile modal's open() args: the + * publicId from the path, and the tab from `?tab=` when it names one. + */ +export function playerProfileRouteArgs(location: { + pathname: string; + search: string; +}): { publicID: string; tab?: string } | null { + const publicID = parsePlayerProfilePath(location.pathname); + if (publicID === null) return null; + const tab = new URLSearchParams(location.search).get("tab"); + return tab !== null && PLAYER_PROFILE_TABS.includes(tab) + ? { publicID, tab } + : { publicID }; } diff --git a/src/server/PlayerProfileRoute.ts b/src/server/PlayerProfileRoute.ts new file mode 100644 index 00000000000..f9b1ed73aad --- /dev/null +++ b/src/server/PlayerProfileRoute.ts @@ -0,0 +1,36 @@ +import type { Express } from "express"; +import path from "path"; +import type { Logger } from "winston"; +import { renderAppShell } from "./RenderHtml"; + +/** + * Serve the app shell at `/player/`, the canonical profile link: the + * client opens that player's profile on load (see playerProfileUrl). + * + * In production the site Worker answers these paths, and the master's SPA + * fallback already answers a bare `/player/` here. This is for the + * worker-prefixed form (`/w/player/`, routed to a worker by nginx and + * stripped of its prefix before routing), so a standalone or dev deployment + * opens a profile link the same way production does. The id isn't checked: + * the client shows its own not-found state for a player that doesn't exist. + */ +export function registerPlayerProfileRoute(opts: { + app: Express; + log: Logger; + baseDir: string; +}) { + const { app, log, baseDir } = opts; + const htmlPath = path.join(baseDir, "../../static/index.html"); + + // Express matches a trailing slash too (non-strict routing). + app.get("/player/:publicId", async (_req, res) => { + try { + await renderAppShell(res, htmlPath); + } catch (error) { + log.error("failed to render the app shell for a profile link", { + error, + }); + res.status(500).send("Internal Server Error"); + } + }); +} diff --git a/src/server/Worker.ts b/src/server/Worker.ts index ab63aa4b5dd..bd178d86c44 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -36,6 +36,7 @@ import { isSteamAuthenticated, planJoinVerify, verifyJoin } from "./JoinVerify"; import { getUserMe, userMeFailureClose, verifyClientToken } from "./jwt"; import { payForLobbyQueue, queueListedLobby } from "./LobbyQueuePayment"; import { logger } from "./Logger"; +import { registerPlayerProfileRoute } from "./PlayerProfileRoute"; import { resolveVerifiedJoin } from "./Privilege"; import { MapPlaylist } from "./MapPlaylist"; @@ -470,6 +471,8 @@ export async function startWorker() { baseDir: __dirname, }); + registerPlayerProfileRoute({ app, log, baseDir: __dirname }); + registerAdminBotRoutes({ app, gm, workerId, log }); // WebSocket handling diff --git a/tests/client/AccountGameStatsNavigation.test.ts b/tests/client/AccountGameStatsNavigation.test.ts index c13dd512076..343fd2c4ecb 100644 --- a/tests/client/AccountGameStatsNavigation.test.ts +++ b/tests/client/AccountGameStatsNavigation.test.ts @@ -69,6 +69,7 @@ vi.mock("src/client/ClientEnv", () => ({ workerPath: vi.fn(() => "w0"), // The profile header's copy-link button builds its URL from this; the web // answer is the document itself (see deriveShareBase). + shareOrigin: vi.fn(() => window.location.origin), shareBase: vi.fn( () => `${window.location.origin}${window.location.pathname}`, ), diff --git a/tests/client/NavAccountMenu.test.ts b/tests/client/NavAccountMenu.test.ts index 88a9c8af27d..c24e5e79263 100644 --- a/tests/client/NavAccountMenu.test.ts +++ b/tests/client/NavAccountMenu.test.ts @@ -159,9 +159,7 @@ describe("nav-account-menu", () => { await new Promise((r) => setTimeout(r, 0)); expect(copyToClipboard).toHaveBeenCalledTimes(1); - expect(vi.mocked(copyToClipboard).mock.calls[0][0]).toContain( - "modal=profile&publicID=p", - ); + expect(vi.mocked(copyToClipboard).mock.calls[0][0]).toContain("/player/p"); expect(showToast).toHaveBeenCalledWith("common.copied", "green"); // No publicId (older backend / unresolved player) — nothing to share. diff --git a/tests/client/PlayerProfileClansTab.test.ts b/tests/client/PlayerProfileClansTab.test.ts index e499c5d1e47..e89e46b255a 100644 --- a/tests/client/PlayerProfileClansTab.test.ts +++ b/tests/client/PlayerProfileClansTab.test.ts @@ -29,11 +29,17 @@ vi.mock("../../src/client/Api", () => ({ })), })); +// The visitor banner asks who is viewing; these tests don't sign anyone in. +vi.mock("../../src/client/ProgressionAccount", () => ({ + resolveXpAccount: vi.fn(async () => ({ kind: "unknown" })), +})); + vi.mock("src/client/ClientEnv", () => ({ ClientEnv: { workerPath: vi.fn(() => "w0"), // The profile header's copy-link button builds its URL from this; the web // answer is the document itself (see deriveShareBase). + shareOrigin: vi.fn(() => window.location.origin), shareBase: vi.fn( () => `${window.location.origin}${window.location.pathname}`, ), @@ -383,7 +389,7 @@ describe("Player profile Clans tab", () => { await openClansTab(); await waitForModal(modal, () => { - expect(modal.textContent).toContain("player_profile.not_found"); + expect(modal.textContent).toContain("player_profile.not_found_title"); }); // Must not read as "in no clans". expect(modal.textContent).not.toContain("player_profile.no_clans"); diff --git a/tests/client/PlayerProfileModal.test.ts b/tests/client/PlayerProfileModal.test.ts index b6c3685c8c0..55f97ddd0fb 100644 --- a/tests/client/PlayerProfileModal.test.ts +++ b/tests/client/PlayerProfileModal.test.ts @@ -20,6 +20,11 @@ vi.mock("../../src/client/Utils", () => ({ copyToClipboard: copyToClipboardMock, })); +// The visitor banner asks who is viewing; these tests don't sign anyone in. +vi.mock("../../src/client/ProgressionAccount", () => ({ + resolveXpAccount: vi.fn(async () => ({ kind: "unknown" })), +})); + vi.mock("../../src/client/Api", () => ({ fetchPublicPlayerProfile: fetchPublicPlayerProfileMock, })); @@ -141,7 +146,7 @@ describe("public player profile route", () => { await modal.updateComplete; expect(modal.isOpen()).toBe(true); expect(modal.querySelector("player-stats-tree-view")).toBeNull(); - expect(modal.textContent).toContain("player_profile.not_found"); + expect(modal.textContent).toContain("player_profile.not_found_title"); }); expect(fetchPublicPlayerProfileMock).toHaveBeenCalledWith("missing"); }); @@ -154,7 +159,7 @@ describe("public player profile route", () => { await vi.waitFor(async () => { await modal.updateComplete; expect(modal.isOpen()).toBe(true); - expect(modal.textContent).toContain("player_profile.not_found"); + expect(modal.textContent).toContain("player_profile.not_found_title"); }); expect(fetchPublicPlayerProfileMock).not.toHaveBeenCalled(); }); diff --git a/tests/client/PlayerProfilePage.test.ts b/tests/client/PlayerProfilePage.test.ts new file mode 100644 index 00000000000..02dec66ea39 --- /dev/null +++ b/tests/client/PlayerProfilePage.test.ts @@ -0,0 +1,296 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const fetchPublicPlayerProfileMock = vi.hoisted(() => vi.fn()); +const viewer = vi.hoisted(() => ({ kind: "unknown" as string })); + +vi.mock("../../src/client/Utils", () => ({ + translateText: vi.fn((key: string) => key), + copyToClipboard: vi.fn(async () => undefined), + showToast: vi.fn(), +})); + +vi.mock("../../src/client/Api", () => ({ + fetchPublicPlayerProfile: fetchPublicPlayerProfileMock, + fetchPublicPlayerGames: vi.fn(async () => ({ + results: [], + nextCursor: null, + })), +})); + +vi.mock("../../src/client/ProgressionApi", () => ({ + fetchPublicPlayerProgress: vi.fn(async () => false), +})); + +vi.mock("../../src/client/ProgressionAccount", () => ({ + resolveXpAccount: vi.fn(async () => ({ kind: viewer.kind })), +})); + +vi.mock( + "../../src/client/components/baseComponents/stats/PlayerStatsTree", + () => ({}), +); +vi.mock( + "../../src/client/components/baseComponents/stats/PlayerGameHistoryView", + () => ({}), +); + +import { modalRouter } from "../../src/client/ModalRouter"; +import { initNavigation } from "../../src/client/Navigation"; +import { PlayerProfileModal } from "../../src/client/PlayerProfileModal"; +import { playerProfileRouteArgs } from "../../src/client/utilities/PlayerProfileUrl"; + +type ModalShell = HTMLElement & { updateComplete: Promise }; + +const profile = { + createdAt: "2026-01-01T00:00:00.000Z", + username: "Wonder", + stats: { Public: {} }, + clans: [], +}; + +describe("public profile page", () => { + let modal: PlayerProfileModal; + let playPage: HTMLElement; + let leaderboard: HTMLElement & { open: () => void }; + let leaderboardOpens: number; + + async function settle(assertion: () => void): Promise { + await vi.waitFor(async () => { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; + assertion(); + }); + } + + const url = () => + window.location.pathname + window.location.search + window.location.hash; + + beforeAll(() => { + playPage = document.createElement("div"); + playPage.id = "page-play"; + document.body.appendChild(playPage); + initNavigation(); + modalRouter.register("profile", { + tag: "player-profile-modal", + pageId: "page-profile", + }); + modalRouter.registerPath("profile", playerProfileRouteArgs); + if (!customElements.get("player-profile-modal")) { + customElements.define("player-profile-modal", PlayerProfileModal); + } + }); + + afterAll(() => { + playPage.remove(); + }); + + beforeEach(async () => { + fetchPublicPlayerProfileMock.mockReset(); + fetchPublicPlayerProfileMock.mockResolvedValue(profile); + viewer.kind = "unknown"; + history.replaceState(null, "", "/"); + + leaderboardOpens = 0; + leaderboard = document.createElement("leaderboard-modal") as HTMLElement & { + open: () => void; + }; + leaderboard.open = () => { + leaderboardOpens++; + }; + document.body.appendChild(leaderboard); + + modal = document.createElement( + "player-profile-modal", + ) as PlayerProfileModal; + modal.id = "page-profile"; + modal.setAttribute("inline", ""); + modal.className = "hidden page-content"; + document.body.appendChild(modal); + await modal.updateComplete; + }); + + afterEach(() => { + window.showPage?.("page-play"); + modal.remove(); + leaderboard.remove(); + history.replaceState(null, "", "/"); + }); + + async function openFromPath(path: string): Promise { + history.replaceState(null, "", path); + expect(modalRouter.routeFromPath()).toBe(true); + await settle(() => expect(modal.isOpen()).toBe(true)); + } + + describe("opening from a /player/ link", () => { + it("opens that player's profile and keeps the link in the address bar", async () => { + await openFromPath("/player/aB3dE5fX"); + await settle(() => + expect(modal.querySelector("player-stats-tree-view")).not.toBeNull(), + ); + expect(fetchPublicPlayerProfileMock).toHaveBeenCalledWith("aB3dE5fX"); + expect(url()).toBe("/player/aB3dE5fX"); + }); + + it("accepts the trailing-slash and worker-prefixed forms", async () => { + await openFromPath("/w0/player/aB3dE5fX/"); + expect(fetchPublicPlayerProfileMock).toHaveBeenCalledWith("aB3dE5fX"); + }); + + it("ignores a path that isn't a profile link", () => { + history.replaceState(null, "", "/player/not!an!id"); + expect(modalRouter.routeFromPath()).toBe(false); + history.replaceState(null, "", "/news"); + expect(modalRouter.routeFromPath()).toBe(false); + expect(modal.isOpen()).toBe(false); + }); + + it("opens on the tab ?tab= names, and writes tab changes back to it", async () => { + await openFromPath("/player/aB3dE5fX?tab=clans"); + await settle(() => + expect(modal.textContent).toContain("player_profile.no_clans"), + ); + + modal.setActiveTab("games"); + expect(url()).toBe("/player/aB3dE5fX?tab=games"); + expect(window.location.hash).toBe(""); + }); + + it("goes home on Back and resets the URL so a reload doesn't reopen it", async () => { + await openFromPath("/player/aB3dE5fX"); + (modal.querySelector('[slot="header"] button') as HTMLElement).click(); + expect(modal.isOpen()).toBe(false); + expect(url()).toBe("/"); + expect(playPage.classList.contains("hidden")).toBe(false); + expect(modalRouter.isPathRouted("profile")).toBe(false); + }); + + it("goes home on Escape too", async () => { + await openFromPath("/player/aB3dE5fX?tab=games"); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await vi.waitFor(() => expect(modal.isOpen()).toBe(false)); + expect(url()).toBe("/"); + }); + + it("hands the URL back to the hash once the profile is reopened in-app", async () => { + await openFromPath("/player/aB3dE5fX"); + modal.close(); + modal.openFromLeaderboard("other123"); + expect(url()).toBe("/#modal=profile&publicID=other123"); + }); + + it("still opens from the hash form", async () => { + history.replaceState(null, "", "/#modal=profile&publicID=aB3dE5fX"); + expect(modalRouter.routeFromHash()).toBe(true); + await settle(() => expect(modal.isOpen()).toBe(true)); + expect(fetchPublicPlayerProfileMock).toHaveBeenCalledWith("aB3dE5fX"); + expect(modalRouter.isPathRouted("profile")).toBe(false); + }); + }); + + describe("visitor banner", () => { + it("invites a signed-out viewer to play, and Play goes home", async () => { + viewer.kind = "signed_out"; + await openFromPath("/player/aB3dE5fX"); + await settle(() => + expect(modal.querySelector("[data-visitor-banner]")).not.toBeNull(), + ); + const banner = modal.querySelector("[data-visitor-banner]")!; + expect(banner.textContent).toContain("player_profile.visitor_title"); + expect(banner.textContent).toContain("player_profile.visitor_body"); + + (banner.querySelector("o-button button") as HTMLElement).click(); + expect(modal.isOpen()).toBe(false); + expect(url()).toBe("/"); + }); + + it("is hidden from a signed-in player", async () => { + viewer.kind = "signed_in"; + await openFromPath("/player/aB3dE5fX"); + await settle(() => + expect(modal.querySelector("profile-share")).not.toBeNull(), + ); + await new Promise((r) => setTimeout(r, 0)); + await modal.updateComplete; + expect(modal.querySelector("[data-visitor-banner]")).toBeNull(); + }); + }); + + it("offers the canonical link to share", async () => { + await openFromPath("/player/aB3dE5fX"); + await settle(() => + expect(modal.querySelector("profile-share")).not.toBeNull(), + ); + const share = modal.querySelector("profile-share") as HTMLElement & { + url: string; + name: string; + }; + expect(share.url).toBe(`${window.location.origin}/player/aB3dE5fX`); + expect(share.name).toBe("Wonder"); + }); + + describe("not found", () => { + it("explains, shows the link that was opened, and offers a way on", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue(false); + await openFromPath("/player/missing1"); + await settle(() => + expect(modal.querySelector("[data-not-found]")).not.toBeNull(), + ); + const panel = modal.querySelector("[data-not-found]")!; + expect(panel.textContent).toContain("player_profile.not_found_title"); + expect(panel.textContent).toContain("player_profile.not_found_body"); + expect( + panel.querySelector("[data-opened-link]")?.textContent?.trim(), + ).toBe(`${window.location.host}/player/missing1`); + expect(panel.querySelector("svg")).not.toBeNull(); + }); + + it("leaves the link out when the profile wasn't opened from one", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue(false); + modal.openFromLeaderboard("missing1"); + await settle(() => + expect(modal.querySelector("[data-not-found]")).not.toBeNull(), + ); + expect(modal.querySelector("[data-opened-link]")).toBeNull(); + }); + + it("Play OpenFront goes home", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue(false); + await openFromPath("/player/missing1"); + await settle(() => + expect(modal.querySelector('[data-action="play"]')).not.toBeNull(), + ); + ( + modal.querySelector('[data-action="play"] button') as HTMLElement + ).click(); + expect(modal.isOpen()).toBe(false); + expect(url()).toBe("/"); + }); + + it("View leaderboard opens the leaderboard", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue(false); + await openFromPath("/player/missing1"); + await settle(() => + expect( + modal.querySelector('[data-action="leaderboard"]'), + ).not.toBeNull(), + ); + ( + modal.querySelector('[data-action="leaderboard"] button') as HTMLElement + ).click(); + expect(modal.isOpen()).toBe(false); + expect(leaderboardOpens).toBe(1); + expect(url()).toBe("/"); + }); + }); +}); diff --git a/tests/client/PlayerProfileProgressionTab.test.ts b/tests/client/PlayerProfileProgressionTab.test.ts index 70f107c5f33..de6d474ca17 100644 --- a/tests/client/PlayerProfileProgressionTab.test.ts +++ b/tests/client/PlayerProfileProgressionTab.test.ts @@ -19,9 +19,15 @@ vi.mock("../../src/client/ProgressionApi", () => ({ fetchPublicPlayerProgress: fetchPublicPlayerProgressMock, })); +// The visitor banner asks who is viewing; these tests don't sign anyone in. +vi.mock("../../src/client/ProgressionAccount", () => ({ + resolveXpAccount: vi.fn(async () => ({ kind: "unknown" })), +})); + vi.mock("src/client/ClientEnv", () => ({ ClientEnv: { workerPath: vi.fn(() => "w0"), + shareOrigin: vi.fn(() => window.location.origin), shareBase: vi.fn( () => `${window.location.origin}${window.location.pathname}`, ), diff --git a/tests/client/PlayerProfileUrl.test.ts b/tests/client/PlayerProfileUrl.test.ts new file mode 100644 index 00000000000..2a8958da1bd --- /dev/null +++ b/tests/client/PlayerProfileUrl.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + parsePlayerProfilePath, + playerProfileRouteArgs, +} from "../../src/client/utilities/PlayerProfileUrl"; + +describe("parsePlayerProfilePath", () => { + it("reads the id from every form of the profile path", () => { + for (const path of [ + "/player/aB3dE5fX", + "/player/aB3dE5fX/", + "/w0/player/aB3dE5fX", + "/w12/player/aB3dE5fX/", + "/v/abc1234/player/aB3dE5fX", + "/v/abc1234/w1/player/aB3dE5fX", + ]) { + expect(parsePlayerProfilePath(path), path).toBe("aB3dE5fX"); + } + }); + + it("accepts the url-safe characters ids are made of", () => { + expect(parsePlayerProfilePath("/player/a_b-C9")).toBe("a_b-C9"); + }); + + it("ignores other paths and ids that can't be one", () => { + for (const path of [ + "/", + "/player", + "/player/", + "/player/aB3dE5fX/games", + "/players/aB3dE5fX", + "/x/player/aB3dE5fX", + "/wx/player/aB3dE5fX", + "/game/aB3dE5fX", + "/player/a%2Bb", + "/player/