diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 7c2fadb084..ed6cf0ea0f 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -514,6 +514,12 @@ export const UserMeResponseSchema = z.object({ // never fail the parse and reject the join, and when the setting can't be // read the private answer is the safe one. levelHidden: z.boolean().optional().catch(true), + // "Show my profile in search engines" turned off: the public profile page + // (openfront.io/player/) is served noindex. It still opens for + // anyone with the link, and link previews are unchanged. Optional so an + // older API without the setting still parses — absent hides the row in + // account settings. A malformed value reads as hidden, like levelHidden. + searchHidden: z.boolean().optional().catch(true), clans: z .array( z.object({ @@ -618,6 +624,12 @@ export const UserMeResponseSchema = z.object({ }), }); export type UserMeResponse = z.infer; + +// PUT /users/@me/level_visibility and /users/@me/search_visibility { hidden } +// — the stored setting, echoed. +export const VisibilityResponseSchema = z.object({ + hidden: z.boolean(), +}); export type UserSubscription = NonNullable< NonNullable >; diff --git a/packages/shared/src/WireSchemas.ts b/packages/shared/src/WireSchemas.ts index 23acb6cbbe..a4df83acb7 100644 --- a/packages/shared/src/WireSchemas.ts +++ b/packages/shared/src/WireSchemas.ts @@ -223,16 +223,16 @@ export const ClientInfoSchema = z.object({ // prestige and legend packed into one varint by packLevelBadge // (LevelBadgeWire.ts); read it with unpackLevelBadge. Stamped by the game // server from its own /users/@me lookup, never taken from the client. - // Display-only. Absent for guests, for players who hide their level (on - // every copy of the roster, their own included), when the API has - // progression off or the lookup failed, and on anonymized entries (a badge - // beside an anonymous name would point out the veterans). Must stay the - // LAST field: its one presence bit is the eighth and last bit of this - // object's one-byte header, so an entry without it encodes byte-identically - // to one from before it existed, and a pre-badge frame still decodes. - // Another field here would need a second header byte. No range check here - // on purpose: one bad value must not fail the whole roster's parse, so - // unpackLevelBadge drops it instead. + // Display-only. Absent for guests, for players who hide their level (even + // from themselves: their own client draws its own badge from its local + // /users/@me instead), when the API has progression off or the lookup + // failed, and on anonymized entries (a badge beside an anonymous name would + // point out the veterans). Must stay the LAST field: its one presence + // bit is the eighth and last bit of this object's one-byte header, so an + // entry without it encodes byte-identically to one from before it existed, + // and a pre-badge frame still decodes. Another field here would need a + // second header byte. No range check here on purpose: one bad value must + // not fail the whole roster's parse, so unpackLevelBadge drops it instead. levelBadge: zb.uint().optional(), }); diff --git a/resources/lang/en.json b/resources/lang/en.json index c4e123736d..2c958e688c 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -48,6 +48,11 @@ "identity_token_rate_limited": "Too many requests. Please wait a minute and try again.", "identity_token_site": "Site", "identity_token_title": "Link to a third-party site", + "level_visibility_desc": "Your level badge appears next to your name in lobbies, in game, on leaderboards, in clans and on your profile. Turn this off to hide it; it can take a little while to update everywhere. You still earn XP and see your own level.", + "level_visibility_failed": "Couldn't update your level setting. Please try again.", + "level_visibility_hidden": "Level hidden", + "level_visibility_others_see": "Others see", + "level_visibility_title": "Show my level to other players", "link_discord": "Link Discord Account", "link_existing_account": "Link an existing account", "link_google": "Link Google Account", @@ -82,6 +87,7 @@ "no_games": "No games played yet.", "no_stats": "No stats available yet. Play some games to start tracking.", "or": "OR", + "privacy_title": "Privacy", "reactivate_subscription": "Reactivate", "recovery_email_sent": "Recovery email sent to {email}", "reward_daily": "Daily subscription reward", @@ -95,6 +101,9 @@ "reward_level_up": "Level-up reward", "reward_prestige": "Prestige reward", "reward_signup_bonus": "Subscription signup bonus", + "search_visibility_desc": "Your public profile page can appear in Google and other search results. Turn this off to keep it out of search; search engines can take a while to drop a page they have already indexed. Anyone with the link can still open it.", + "search_visibility_failed": "Couldn't update your search setting. Please try again.", + "search_visibility_title": "Show my profile in search engines", "sign_in_desc": "Sign in to save your stats and progress", "sub_granted_from_purchase": "Included with your Steam purchase, paid for once on Steam. It isn't billed and doesn't renew, so there's nothing to manage or cancel. When it ends you keep the full game at no charge.", "sub_granted_indefinite": "Free access granted to your account. It isn't billed, so there's nothing to manage or cancel.", diff --git a/src/client/Api.ts b/src/client/Api.ts index f17b1bb238..0b2705b79c 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -44,6 +44,7 @@ import { TribeStatsResponseSchema, UserMeResponse, UserMeResponseSchema, + VisibilityResponseSchema, } from "@openfront/shared/ApiSchemas"; import { AnalyticsRecord, @@ -410,6 +411,72 @@ export async function setMarketingConsent( } } +export type SetVisibilityResult = + // 200: the stored setting, as the server echoes it. + | { ok: true; hidden: boolean } + // 401: the session is gone; logOut() has already run. + | { ok: false; code: "logged_out" } + // Anything else (400, 429, 5xx, network, unreadable body): nothing to act on + // beyond "try again". + | { ok: false; code: "failed" }; + +// PUT /users/@me/ { hidden }: the one write path behind every privacy +// toggle, so each setter differs only in its endpoint. Idempotent: the body is +// the desired state. Invalidates the cached /users/@me on success so the next +// read reflects it. +async function putVisibility( + setting: "level_visibility" | "search_visibility", + hidden: boolean, +): Promise { + try { + const response = await fetch(`${getApiBase()}/users/@me/${setting}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ hidden }), + }); + if (response.status === 401) { + await logOut(); + return { ok: false, code: "logged_out" }; + } + if (!response.ok) { + console.error( + `PUT ${setting}: request failed`, + response.status, + response.statusText, + ); + return { ok: false, code: "failed" }; + } + const parsed = VisibilityResponseSchema.safeParse(await response.json()); + if (!parsed.success) { + console.error(`PUT ${setting}: Zod validation failed`, parsed.error); + return { ok: false, code: "failed" }; + } + invalidateUserMe(); + return { ok: true, hidden: parsed.data.hidden }; + } catch (e) { + console.error(`PUT ${setting}: request failed`, e); + return { ok: false, code: "failed" }; + } +} + +// "Hide my level". +export function setLevelVisibility( + hidden: boolean, +): Promise { + return putVisibility("level_visibility", hidden); +} + +// "Keep my profile out of search engines" (the public profile page is served +// noindex). +export function setSearchVisibility( + hidden: boolean, +): Promise { + return putVisibility("search_visibility", hidden); +} + // The sites a player can generate an identity token for. Fails closed: any // error lands on an empty list, which hides the account-settings card. export async function getIdentityTokenAudiences(): Promise { diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 1984941e0e..4383d4c97b 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -223,7 +223,11 @@ export function joinLobby( clientID = message.myClientID; eventBus.emit(new LobbyInfoEvent(message.lobby, message.myClientID)); // Kept past game start for the in-game level badges. - rememberLobbyRoster(message.lobby.gameID, message.lobby.clients); + rememberLobbyRoster( + message.lobby.gameID, + message.lobby.clients, + message.myClientID, + ); // Preload the map while still in the lobby so game start can reuse the // cached result instead of blocking on the download in the short // prestart->start window. The preload is debounced: it only fires once diff --git a/src/client/LobbyRosterLevels.ts b/src/client/LobbyRosterLevels.ts index bc16bb7bc0..1b38925f6a 100644 --- a/src/client/LobbyRosterLevels.ts +++ b/src/client/LobbyRosterLevels.ts @@ -1,6 +1,10 @@ import { ClientID, GameID } from "@openfront/engine-api/Schemas"; import { LevelBadge, unpackLevelBadge } from "@openfront/shared/LevelBadgeWire"; import { ClientInfo } from "@openfront/shared/WireSchemas"; +import { + ownHiddenLevelBadge, + refreshOwnHiddenLevelBadge, +} from "./OwnLevelBadge"; // Level badges from the last lobby roster this tab was sent, for in-game UI. // The start message carries no levels, so a tab that never saw the lobby (a @@ -9,26 +13,42 @@ import { ClientInfo } from "@openfront/shared/WireSchemas"; let rosterGameID: GameID | null = null; let badges = new Map(); +// The viewer's own clientID when they were on that roster. +let rosterSelf: ClientID | null = null; export function rememberLobbyRoster( gameID: GameID, clients: readonly ClientInfo[] | undefined, + myClientID?: ClientID, ): void { const next = new Map(); + let self: ClientID | null = null; for (const c of clients ?? []) { if (c.levelBadge !== undefined) next.set(c.clientID, c.levelBadge); + if (c.clientID === myClientID) self = myClientID; + } + if (gameID !== rosterGameID) { + // Once per game: the viewer's own badge if they hide their level (their + // roster entry carries none). From the memoised /users/@me. + void refreshOwnHiddenLevelBadge(); } rosterGameID = gameID; badges = next; + rosterSelf = self; } // The player's badge in game `gameID`, or undefined when they have none (a // guest, an anonymized name, progression off, their level hidden, a value out -// of range) or this tab never saw them in that game's lobby. +// of range) or this tab never saw them in that game's lobby. The viewer's own +// badge still shows while they hide their level: it comes from their own +// /users/@me then. export function lobbyLevelBadge( gameID: GameID, clientID: ClientID | null, ): LevelBadge | undefined { if (clientID === null || gameID !== rosterGameID) return undefined; - return unpackLevelBadge(badges.get(clientID)); + return ( + unpackLevelBadge(badges.get(clientID)) ?? + (clientID === rosterSelf ? ownHiddenLevelBadge() : undefined) + ); } diff --git a/src/client/OwnLevelBadge.ts b/src/client/OwnLevelBadge.ts new file mode 100644 index 0000000000..4b2097d484 --- /dev/null +++ b/src/client/OwnLevelBadge.ts @@ -0,0 +1,52 @@ +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; +import type { LevelBadge } from "@openfront/shared/LevelBadgeWire"; +import { getUserMe } from "./Api"; + +// The viewer's OWN level badge while they hide their level ("hide my level"). +// +// The game server leaves a hidden player's badge off the lobby roster for +// everyone, the player included, but they still see their own level. So their +// client draws it from its own /users/@me instead — local only, nothing is +// sent anywhere. Undefined while the level is shown (the roster carries the +// badge then), for guests, and when progression is off. + +export function ownHiddenLevelBadgeFrom( + me: UserMeResponse | false, +): LevelBadge | undefined { + if (me === false || me.player.levelHidden !== true) return undefined; + const progress = me.player.progress; + if (progress === undefined) return undefined; + return { + level: progress.level, + prestige: progress.prestige, + legend: progress.legend, + }; +} + +let cached: LevelBadge | undefined; +// Bumped by every refresh and when the session is cleared: only the latest +// refresh writes the cache. One still waiting on an older /users/@me (from +// before a visibility change, or the old account's after a clear) leaves it. +let generation = 0; + +// The last value refreshOwnHiddenLevelBadge() found, for synchronous readers +// (the in-game player panel). +export function ownHiddenLevelBadge(): LevelBadge | undefined { + return cached; +} + +// Re-reads it from /users/@me. getUserMe() is memoised, so this is normally +// answered from the profile already loaded at sign-in / join — no request. +export async function refreshOwnHiddenLevelBadge(): Promise< + LevelBadge | undefined +> { + const forGeneration = ++generation; + const badge = ownHiddenLevelBadgeFrom(await getUserMe()); + if (forGeneration === generation) cached = badge; + return cached; +} + +document.addEventListener("session-cleared", () => { + generation++; + cached = undefined; +}); diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 479805ad25..3787f4002f 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -7,7 +7,7 @@ import { import { html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; -import { fetchPublicPlayerProfile } from "./Api"; +import { fetchPublicPlayerProfile, getUserMe } from "./Api"; import "./ClanModal"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; @@ -239,13 +239,20 @@ export class PlayerProfileModal extends BaseModal { `; } - // Everyone's level, your own included, comes from the public endpoint: the - // /users/@me copy is cached from page load and would miss the games played - // since. Missing progress hides the summary. + // Everyone's level, your own included, comes from the public endpoint first: + // the /users/@me copy is cached from page load and would miss the games + // played since. But that endpoint 404s for a player who hides their level — + // for everyone, them included — and they still see their own level. So on + // your OWN profile only, no public progress falls back to your /users/@me + // copy (memoised: normally no request). Nobody else's profile ever does, so a + // hidden level stays hidden from others. Missing progress hides the card. private async loadProgress(publicId: string): Promise { try { const progress = await fetchPublicPlayerProgress(publicId); - return progress === false ? null : progress; + if (progress !== false) return progress; + const me = await getUserMe(); + if (me === false || me.player.publicId !== publicId) return null; + return me.player.progress ?? null; } catch { return null; } diff --git a/src/client/components/AccountSettingsPanel.ts b/src/client/components/AccountSettingsPanel.ts index af826d5c08..206f30783e 100644 --- a/src/client/components/AccountSettingsPanel.ts +++ b/src/client/components/AccountSettingsPanel.ts @@ -2,24 +2,50 @@ import { UserMeResponse } from "@openfront/shared/ApiSchemas"; import { html, LitElement, nothing, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { isSteamPrimaryUser } from "../AccountIdentity"; -import { deleteAccount, setMarketingConsent } from "../Api"; +import { + deleteAccount, + setLevelVisibility, + setMarketingConsent, + setSearchVisibility, + type SetVisibilityResult, +} from "../Api"; import { clearLocalSession, linkGoogle, sendMagicLink } from "../Auth"; import { crazyGamesSDK } from "../CrazyGamesSDK"; import { isDesktopShell } from "../DesktopShell"; import { showInGameAlert } from "../InGameModal"; +import { refreshOwnHiddenLevelBadge } from "../OwnLevelBadge"; import { steamSDK } from "../SteamSDK"; import { translateText } from "../Utils"; import "./baseComponents/Button"; import "./DeleteAccountDialog"; import "./IdentityTokenCard"; +import "./LevelBadge"; import { googleLinkButton } from "./ui/GoogleLinkButton"; type UserMePlayer = UserMeResponse["player"]; type UserMeUser = UserMeResponse["user"]; +// The /users/@me privacy flags the Privacy card toggles, each with the request +// that stores it and the alert shown when that request fails. +type PrivacyField = "levelHidden" | "searchHidden"; +const PRIVACY_SETTINGS: Record< + PrivacyField, + { save: (hidden: boolean) => Promise; failedKey: string } +> = { + levelHidden: { + save: (hidden) => setLevelVisibility(hidden), + failedKey: "account_modal.level_visibility_failed", + }, + searchHidden: { + save: (hidden) => setSearchVisibility(hidden), + failedKey: "account_modal.search_visibility_failed", + }, +}; + /** * Account settings: marketing-consent control (with the bind-an-email flow when - * the account has no verified email), third-party identity tokens and + * the account has no verified email), privacy ("hide my level", "keep my + * profile out of search engines"), third-party identity tokens and * self-service account deletion. * * Extracted from AccountModal so the standalone account-settings modal opened @@ -33,6 +59,11 @@ export class AccountSettingsPanel extends LitElement { @state() private email: string = ""; @state() private consentBusy: boolean = false; + // Per setting, so one switch's request in flight never locks the other. + @state() private privacyBusy: Record = { + levelHidden: false, + searchHidden: false, + }; @state() private deleteDialogOpen: boolean = false; @state() private deleteBusy: boolean = false; @@ -63,7 +94,7 @@ export class AccountSettingsPanel extends LitElement { render(): TemplateResult { return html`
- ${this.renderMarketingCard()} + ${this.renderMarketingCard()} ${this.renderPrivacyCard()} ${this.renderDeleteAccountCard()}
@@ -128,6 +159,124 @@ export class AccountSettingsPanel extends LitElement { `; } + // Privacy: "Show my level to other players" and "Show my profile in search + // engines". Each row only when /users/@me carries its setting at all, and + // the card only when it carries either — an older API without them gets no + // card. + private renderPrivacyCard(): TemplateResult | typeof nothing { + const levelHidden = this.player?.levelHidden; + const searchHidden = this.player?.searchHidden; + if (levelHidden === undefined && searchHidden === undefined) { + return nothing; + } + return html` +
+
+ ${translateText("account_modal.privacy_title")} +
+ ${levelHidden === undefined + ? nothing + : html`${this.renderPrivacySwitchRow( + translateText("account_modal.level_visibility_title"), + translateText("account_modal.level_visibility_desc"), + !levelHidden, + this.privacyBusy.levelHidden, + (shown) => this.setPrivacyShown("levelHidden", shown), + )} + ${this.renderLevelPreview(!levelHidden)}`} + ${searchHidden === undefined + ? nothing + : html`
+ ${this.renderPrivacySwitchRow( + translateText("account_modal.search_visibility_title"), + translateText("account_modal.search_visibility_desc"), + !searchHidden, + this.privacyBusy.searchHidden, + (shown) => this.setPrivacyShown("searchHidden", shown), + )} +
`} +
+ `; + } + + // One Privacy row: title, description and the same role="switch" toggle as + // the email-updates card. On = shown. + private renderPrivacySwitchRow( + title: string, + description: string, + shown: boolean, + busy: boolean, + setShown: (shown: boolean) => void, + ): TemplateResult { + return html` +
+
+
${title}
+
${description}
+
+ +
+ `; + } + + // "Others see": the player's name as other players get it — with their + // level badge, or without it and marked hidden. Decorative (the switch + // carries the state), so hidden from assistive tech. Needs the player's + // level, so it is left out when progression is off. + private renderLevelPreview(shown: boolean): TemplateResult | typeof nothing { + const progress = this.player?.progress; + if (progress === undefined) return nothing; + return html` + + `; + } + // No verified email on the account yet. Offer both ways to attach one: // a magic link to a plain email (the backend associates a not-yet-registered // email with the current session — the "new-association" path), or linking a @@ -290,6 +439,44 @@ export class AccountSettingsPanel extends LitElement { this.requestUpdate(); } + // One handler for every Privacy switch: the field, its busy flag, its + // request and its error text all come from PRIVACY_SETTINGS[field], so a + // toggle can only ever read and write its own setting. + private async setPrivacyShown( + field: PrivacyField, + shown: boolean, + ): Promise { + const player = this.player; + if (!player || player[field] === undefined) return; + if (this.privacyBusy[field]) return; + const previous = player[field]; + const hidden = !shown; + if (previous === hidden) return; + + // Optimistic, like the consent toggle: `player` is the cached /users/@me + // profile, so this also keeps every other reader of it consistent. The + // switch is disabled until the server answers; a failure puts it back. + const setting = PRIVACY_SETTINGS[field]; + this.privacyBusy = { ...this.privacyBusy, [field]: true }; + player[field] = hidden; + + const result = await setting.save(hidden); + player[field] = result.ok ? result.hidden : previous; + this.privacyBusy = { ...this.privacyBusy, [field]: false }; + // The viewer's own-badge fallback (lobby roster, in-game panel) reads the + // same cached profile: re-read it now so a level change shows without + // waiting for the next lobby. + if (result.ok && field === "levelHidden") { + void refreshOwnHiddenLevelBadge(); + } + + // 401: logOut() has already run and the signed-out state takes over — + // nothing to tell the player here. + if (!result.ok && result.code === "failed") { + await showInGameAlert(translateText(setting.failedKey)); + } + } + private handleEmailInput = (e: Event): void => { this.email = (e.target as HTMLInputElement).value; }; diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 5a1634a487..32dcc87add 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -21,6 +21,10 @@ import { ClientInfo } from "@openfront/shared/WireSchemas"; import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; +import { + ownHiddenLevelBadge, + refreshOwnHiddenLevelBadge, +} from "../OwnLevelBadge"; import { Theme, themeProvider } from "../theme/ThemeProvider"; import { UserSettings } from "../UserSettings"; import { @@ -30,6 +34,20 @@ import { } from "../Utils"; import "./LevelBadge"; +function sameBadge( + a: LevelBadge | undefined, + b: LevelBadge | undefined, +): boolean { + return ( + a === b || + (a !== undefined && + b !== undefined && + a.level === b.level && + a.prestige === b.prestige && + a.legend === b.legend) + ); +} + export interface TeamPreviewData { team: Team; players: ClientInfo[]; @@ -59,6 +77,10 @@ export class LobbyTeamView extends LitElement { private _clanUpdateTimeout: number | null = null; private _teamClanTags: Map = new Map(); private _viewerFriends: ReadonlySet = new Set(); + // The viewer's own badge while they hide their level: the server leaves it + // off their roster entry, but they still see their own level. + @state() private _ownHiddenBadge: LevelBadge | undefined = + ownHiddenLevelBadge(); // Spectators are in the lobby roster (flagged) but hold no seat and never // reach the simulation — so the count header, the team preview and both @@ -93,6 +115,22 @@ export class LobbyTeamView extends LitElement { : undefined; this._viewerFriends = new Set(self?.friends ?? []); } + // On joining and on every roster update, so a visibility change made in + // the account settings shows by the next lobby_info. From the memoised + // /users/@me, so normally no request at all. + if ( + (changedProperties.has("currentClientID") || + changedProperties.has("clients")) && + this.currentClientID + ) { + const forClient = this.currentClientID; + void refreshOwnHiddenLevelBadge().then((badge) => { + if (this.currentClientID !== forClient) return; + if (!sameBadge(badge, this._ownHiddenBadge)) { + this._ownHiddenBadge = badge; + } + }); + } // Recompute team preview when relevant properties change // clients is updated from WebSocket lobby_info events if ( @@ -612,10 +650,15 @@ export class LobbyTeamView extends LitElement { // server-anonymized entries (they arrive without one), and none for other // players while the viewer has anonymous names on — the badge belongs to // the real name, like the verified check above. + // The viewer's own entry has no badge while they hide their level, so it + // falls back to the one from their own /users/@me. private visibleLevelBadge(client: ClientInfo): LevelBadge | undefined { - const anonymized = - this.userSettings.anonymousNames() && !this.isCurrentPlayer(client); - return anonymized ? undefined : unpackLevelBadge(client.levelBadge); + if (this.isCurrentPlayer(client)) { + return unpackLevelBadge(client.levelBadge) ?? this._ownHiddenBadge; + } + return this.userSettings.anonymousNames() + ? undefined + : unpackLevelBadge(client.levelBadge); } private anyVisibleLevelBadge(clients: readonly ClientInfo[]): boolean { diff --git a/tests/client/LevelVisibilityApi.test.ts b/tests/client/LevelVisibilityApi.test.ts new file mode 100644 index 0000000000..8fd75e8394 --- /dev/null +++ b/tests/client/LevelVisibilityApi.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Auth", () => ({ + getAuthHeader: async () => "Bearer t", + logOut: vi.fn(), +})); +vi.mock("../../src/client/ClientEnv", async (importOriginal) => ({ + ...(await importOriginal()), +})); + +import { setLevelVisibility } from "../../src/client/Api"; +import { logOut } from "../../src/client/Auth"; +import { ClientEnv } from "../../src/client/ClientEnv"; + +function response(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("setLevelVisibility", () => { + const fetchSpy = vi.fn(); + beforeEach(() => { + fetchSpy.mockReset(); + vi.mocked(logOut).mockClear(); + vi.stubGlobal("fetch", fetchSpy); + // getApiBase() reads window.BOOTSTRAP_CONFIG via ClientEnv (same shape as + // UpdateUsername.test.ts). + (window as any).BOOTSTRAP_CONFIG = { + gameEnv: "prod", + numWorkers: 1, + turnstileSiteKey: "x", + jwtAudience: "localhost", + instanceId: "test", + gitCommit: "test", + }; + ClientEnv.reset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete (window as any).BOOTSTRAP_CONFIG; + ClientEnv.reset(); + }); + + it("PUTs the desired state with the auth header and returns the echo", async () => { + fetchSpy.mockResolvedValue(response(200, { hidden: true })); + + expect(await setLevelVisibility(true)).toEqual({ ok: true, hidden: true }); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toMatch(/\/users\/@me\/level_visibility$/); + expect(init.method).toBe("PUT"); + expect(init.headers.Authorization).toBe("Bearer t"); + expect(init.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body)).toEqual({ hidden: true }); + }); + + it("sends hidden=false to show the level again", async () => { + fetchSpy.mockResolvedValue(response(200, { hidden: false })); + + expect(await setLevelVisibility(false)).toEqual({ + ok: true, + hidden: false, + }); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ + hidden: false, + }); + }); + + it("maps 400 to failed", async () => { + fetchSpy.mockResolvedValue(response(400, { error: "Bad Request" })); + + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + expect(logOut).not.toHaveBeenCalled(); + }); + + it("maps 401 to logged_out and runs the signed-out handling", async () => { + fetchSpy.mockResolvedValue(response(401, { error: "Unauthorized" })); + + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "logged_out", + }); + expect(logOut).toHaveBeenCalledTimes(1); + }); + + it("maps a rate limit, an unreadable body and a network error to failed", async () => { + fetchSpy.mockResolvedValueOnce(response(429, {})); + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + + fetchSpy.mockResolvedValueOnce(response(200, { hidden: "yes" })); + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + + fetchSpy.mockRejectedValueOnce(new TypeError("network")); + expect(await setLevelVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + }); +}); diff --git a/tests/client/LobbyRosterLevels.test.ts b/tests/client/LobbyRosterLevels.test.ts index 9c55c8cbf5..e8b1d04f3d 100644 --- a/tests/client/LobbyRosterLevels.test.ts +++ b/tests/client/LobbyRosterLevels.test.ts @@ -1,10 +1,16 @@ +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { lobbyLevelBadge, rememberLobbyRoster, } from "../../src/client/LobbyRosterLevels"; +const getUserMe = vi.hoisted(() => + vi.fn<() => Promise>(async () => false), +); +vi.mock("../../src/client/Api", () => ({ getUserMe })); + const VET = { level: 100, prestige: 10, legend: true }; // As the roster carries it. const VET_WIRE = packLevelBadge(VET)!; @@ -60,6 +66,74 @@ describe("lobbyLevelBadge", () => { }); }); +describe("lobbyLevelBadge: the viewer's own badge while they hide their level", () => { + const progress = { + prestige: 4, + level: 63, + xpInLevel: 10, + xpForNext: 900, + lifetimeXp: 99999, + legend: false, + canPrestige: false, + }; + const me = (levelHidden: boolean | undefined) => + ({ + user: {}, + player: { publicId: "me-pub", progress, levelHidden }, + }) as unknown as UserMeResponse; + // The server leaves a hidden player's badge off the roster, theirs included. + const roster = [ + { clientID: "self0001", username: "Me", clanTag: null }, + { clientID: "peer0001", username: "Peer", clanTag: null }, + ]; + let game = 100; + const nextGame = () => `game${game++}`; + const settle = () => new Promise((r) => setTimeout(r, 0)); + + beforeEach(() => { + document.dispatchEvent(new Event("session-cleared")); + getUserMe.mockReset(); + }); + + it("falls back to the viewer's own /users/@me badge", async () => { + getUserMe.mockResolvedValue(me(true)); + const id = nextGame(); + rememberLobbyRoster(id, roster, "self0001"); + await settle(); + expect(lobbyLevelBadge(id, "self0001")).toEqual({ + level: 63, + prestige: 4, + legend: false, + }); + // Nobody else gets it. + expect(lobbyLevelBadge(id, "peer0001")).toBeUndefined(); + }); + + it("reads /users/@me once per game, not per broadcast", async () => { + getUserMe.mockResolvedValue(me(true)); + const id = nextGame(); + for (let i = 0; i < 5; i++) rememberLobbyRoster(id, roster, "self0001"); + await settle(); + expect(getUserMe).toHaveBeenCalledTimes(1); + }); + + it("no fallback when the level is shown", async () => { + getUserMe.mockResolvedValue(me(false)); + const id = nextGame(); + rememberLobbyRoster(id, roster, "self0001"); + await settle(); + expect(lobbyLevelBadge(id, "self0001")).toBeUndefined(); + }); + + it("no fallback for a viewer who was not on that roster", async () => { + getUserMe.mockResolvedValue(me(true)); + const id = nextGame(); + rememberLobbyRoster(id, roster, "late0001"); + await settle(); + expect(lobbyLevelBadge(id, "late0001")).toBeUndefined(); + }); +}); + describe("lobbyLevelBadge decodes the packed roster value", () => { it.each([ { level: 1, prestige: 0, legend: false }, diff --git a/tests/client/OwnLevelBadge.test.ts b/tests/client/OwnLevelBadge.test.ts new file mode 100644 index 0000000000..ebc82b123b --- /dev/null +++ b/tests/client/OwnLevelBadge.test.ts @@ -0,0 +1,98 @@ +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + ownHiddenLevelBadge, + ownHiddenLevelBadgeFrom, + refreshOwnHiddenLevelBadge, +} from "../../src/client/OwnLevelBadge"; + +const getUserMe = vi.hoisted(() => + vi.fn<() => Promise>(async () => false), +); +vi.mock("../../src/client/Api", () => ({ getUserMe })); + +const progress = { + prestige: 10, + level: 100, + xpInLevel: 0, + xpForNext: 0, + lifetimeXp: 5_000_000, + legend: true, + canPrestige: false, +}; +function me(player: Record): UserMeResponse { + return { + user: {}, + player: { publicId: "me-pub", ...player }, + } as unknown as UserMeResponse; +} + +describe("ownHiddenLevelBadgeFrom", () => { + it("is the player's own badge while they hide their level", () => { + expect( + ownHiddenLevelBadgeFrom(me({ progress, levelHidden: true })), + ).toEqual({ level: 100, prestige: 10, legend: true }); + }); + + it("is nothing while the level is shown (the roster carries it)", () => { + expect( + ownHiddenLevelBadgeFrom(me({ progress, levelHidden: false })), + ).toBeUndefined(); + // An older API without the setting: shown. + expect(ownHiddenLevelBadgeFrom(me({ progress }))).toBeUndefined(); + }); + + it("is nothing without progress or signed out", () => { + expect(ownHiddenLevelBadgeFrom(me({ levelHidden: true }))).toBeUndefined(); + expect(ownHiddenLevelBadgeFrom(false)).toBeUndefined(); + }); +}); + +describe("refreshOwnHiddenLevelBadge", () => { + beforeEach(() => { + document.dispatchEvent(new Event("session-cleared")); + getUserMe.mockReset(); + }); + + it("caches the answer for synchronous readers", async () => { + getUserMe.mockResolvedValue(me({ progress, levelHidden: true })); + expect(ownHiddenLevelBadge()).toBeUndefined(); + await refreshOwnHiddenLevelBadge(); + expect(ownHiddenLevelBadge()).toEqual({ + level: 100, + prestige: 10, + legend: true, + }); + }); + + it("drops it when the session is cleared", async () => { + getUserMe.mockResolvedValue(me({ progress, levelHidden: true })); + await refreshOwnHiddenLevelBadge(); + document.dispatchEvent(new Event("session-cleared")); + expect(ownHiddenLevelBadge()).toBeUndefined(); + }); + + it("drops a refresh still pending when the session is cleared", async () => { + let answer!: (me: UserMeResponse | false) => void; + getUserMe.mockReturnValue(new Promise((r) => (answer = r))); + const pending = refreshOwnHiddenLevelBadge(); + document.dispatchEvent(new Event("session-cleared")); + // The old account's profile arrives after the clear. + answer(me({ progress, levelHidden: true })); + expect(await pending).toBeUndefined(); + expect(ownHiddenLevelBadge()).toBeUndefined(); + }); + + it("keeps the latest refresh when an older one answers last", async () => { + // Hidden, then shown again: the refresh for the change answers first, the + // one from before it last, with the old setting. + let older!: (me: UserMeResponse | false) => void; + getUserMe.mockReturnValueOnce(new Promise((r) => (older = r))); + getUserMe.mockResolvedValueOnce(me({ progress, levelHidden: false })); + const first = refreshOwnHiddenLevelBadge(); + await refreshOwnHiddenLevelBadge(); + older(me({ progress, levelHidden: true })); + await first; + expect(ownHiddenLevelBadge()).toBeUndefined(); + }); +}); diff --git a/tests/client/ProgressionModals.test.ts b/tests/client/ProgressionModals.test.ts index cea11cc232..dd040a3c96 100644 --- a/tests/client/ProgressionModals.test.ts +++ b/tests/client/ProgressionModals.test.ts @@ -282,6 +282,52 @@ describe("player profile level", () => { expect(fetchPublicPlayerProgress).toHaveBeenCalledWith("me"); }); + // The public endpoint 404s for a player who hides their level, them + // included — but they still see their own level on their own profile. + it("shows your own hidden level from /users/@me when the public endpoint has none", async () => { + getUserMe.mockResolvedValue({ + ...me, + player: { ...me.player, levelHidden: true }, + }); + fetchPublicPlayerProgress.mockResolvedValue(false); + await open("me"); + await vi.waitFor(async () => { + await settled(modal); + expect(levelLine()?.textContent).toContain( + 'progression.level:{"level":64}', + ); + expect(levelLine()?.textContent).toContain( + 'progression.prestige:{"prestige":2}', + ); + }); + expect(fetchPublicPlayerProgress).toHaveBeenCalledWith("me"); + }); + + it("never shows a hidden player's level on their profile to anyone else", async () => { + getUserMe.mockClear(); + getUserMe.mockResolvedValue({ + ...me, + player: { ...me.player, levelHidden: true }, + }); + fetchPublicPlayerProgress.mockResolvedValue(false); + await open("hidden-player"); + await vi.waitFor(() => expect(getUserMe).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + await settled(modal); + expect(modal.querySelector("profile-card")).toBeNull(); + }); + + it("has nothing to fall back on for a signed-out viewer", async () => { + getUserMe.mockClear(); + getUserMe.mockResolvedValue(false); + fetchPublicPlayerProgress.mockResolvedValue(false); + await open("me"); + await vi.waitFor(() => expect(getUserMe).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + await settled(modal); + expect(modal.querySelector("profile-card")).toBeNull(); + }); + it("shows no card when there is no progress", async () => { fetchPublicPlayerProgress.mockResolvedValue(false); await open("no-progress"); diff --git a/tests/client/SearchVisibilityApi.test.ts b/tests/client/SearchVisibilityApi.test.ts new file mode 100644 index 0000000000..f696137e1b --- /dev/null +++ b/tests/client/SearchVisibilityApi.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Auth", () => ({ + getAuthHeader: async () => "Bearer t", + logOut: vi.fn(), +})); +vi.mock("../../src/client/ClientEnv", async (importOriginal) => ({ + ...(await importOriginal()), +})); + +import { setLevelVisibility, setSearchVisibility } from "../../src/client/Api"; +import { logOut } from "../../src/client/Auth"; +import { ClientEnv } from "../../src/client/ClientEnv"; + +function response(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("setSearchVisibility", () => { + const fetchSpy = vi.fn(); + beforeEach(() => { + fetchSpy.mockReset(); + vi.mocked(logOut).mockClear(); + vi.stubGlobal("fetch", fetchSpy); + // getApiBase() reads window.BOOTSTRAP_CONFIG via ClientEnv (same shape as + // UpdateUsername.test.ts). + (window as any).BOOTSTRAP_CONFIG = { + gameEnv: "prod", + numWorkers: 1, + turnstileSiteKey: "x", + jwtAudience: "localhost", + instanceId: "test", + gitCommit: "test", + }; + ClientEnv.reset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete (window as any).BOOTSTRAP_CONFIG; + ClientEnv.reset(); + }); + + it("PUTs the desired state with the auth header and returns the echo", async () => { + fetchSpy.mockResolvedValue(response(200, { hidden: true })); + + expect(await setSearchVisibility(true)).toEqual({ ok: true, hidden: true }); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toMatch(/\/users\/@me\/search_visibility$/); + expect(init.method).toBe("PUT"); + expect(init.headers.Authorization).toBe("Bearer t"); + expect(init.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body)).toEqual({ hidden: true }); + }); + + it("sends hidden=false to show the profile in search again", async () => { + fetchSpy.mockResolvedValue(response(200, { hidden: false })); + + expect(await setSearchVisibility(false)).toEqual({ + ok: true, + hidden: false, + }); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ + hidden: false, + }); + }); + + it("maps 400 to failed", async () => { + fetchSpy.mockResolvedValue(response(400, { error: "Bad Request" })); + + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + expect(logOut).not.toHaveBeenCalled(); + }); + + it("maps 401 to logged_out and runs the signed-out handling", async () => { + fetchSpy.mockResolvedValue(response(401, { error: "Unauthorized" })); + + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "logged_out", + }); + expect(logOut).toHaveBeenCalledTimes(1); + }); + + it("maps a rate limit, an unreadable body and a network error to failed", async () => { + fetchSpy.mockResolvedValueOnce(response(429, {})); + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + + fetchSpy.mockResolvedValueOnce(response(200, { hidden: "yes" })); + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + + fetchSpy.mockRejectedValueOnce(new TypeError("network")); + expect(await setSearchVisibility(true)).toEqual({ + ok: false, + code: "failed", + }); + }); + + it("shares a write path with setLevelVisibility but never its endpoint", async () => { + fetchSpy.mockImplementation(async () => response(200, { hidden: true })); + + await setSearchVisibility(true); + await setLevelVisibility(false); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + const [[searchUrl, searchInit], [levelUrl, levelInit]] = + fetchSpy.mock.calls; + expect(String(searchUrl)).toMatch(/\/users\/@me\/search_visibility$/); + expect(JSON.parse(searchInit.body)).toEqual({ hidden: true }); + expect(String(levelUrl)).toMatch(/\/users\/@me\/level_visibility$/); + expect(JSON.parse(levelInit.body)).toEqual({ hidden: false }); + }); +}); diff --git a/tests/client/components/AccountSettingsPanel.test.ts b/tests/client/components/AccountSettingsPanel.test.ts index 6133ea3488..84cbe2d461 100644 --- a/tests/client/components/AccountSettingsPanel.test.ts +++ b/tests/client/components/AccountSettingsPanel.test.ts @@ -1,6 +1,7 @@ import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AccountSettingsPanel } from "../../../src/client/components/AccountSettingsPanel"; +import { ownHiddenLevelBadge } from "../../../src/client/OwnLevelBadge"; type UserMePlayer = UserMeResponse["player"]; type UserMeUser = UserMeResponse["user"]; @@ -15,6 +16,13 @@ vi.mock("../../../src/client/Api", () => ({ setMarketingConsent: vi.fn(async () => true), deleteAccount: vi.fn(async () => ({ ok: true })), getIdentityTokenAudiences: vi.fn(async () => []), + setLevelVisibility: vi.fn(async (hidden: boolean) => ({ ok: true, hidden })), + setSearchVisibility: vi.fn(async (hidden: boolean) => ({ ok: true, hidden })), + getUserMe: vi.fn(async () => false), +})); + +vi.mock("../../../src/client/InGameModal", () => ({ + showInGameAlert: vi.fn(async () => {}), })); vi.mock("../../../src/client/Auth", () => ({ @@ -167,3 +175,443 @@ describe("AccountSettingsPanel — marketing-consent card", () => { expect(panel.querySelector("input[type=email]")).toBeTruthy(); }); }); + +describe("AccountSettingsPanel — privacy card (hide my level)", () => { + let panel: AccountSettingsPanel; + const progress = { + prestige: 3, + level: 96, + xpInLevel: 10, + xpForNext: 900, + lifetimeXp: 1234567, + legend: false, + canPrestige: false, + }; + + beforeEach(async () => { + if (!customElements.get("account-settings-panel")) { + customElements.define("account-settings-panel", AccountSettingsPanel); + } + panel = document.createElement( + "account-settings-panel", + ) as AccountSettingsPanel; + document.body.appendChild(panel); + await panel.updateComplete; + }); + + afterEach(() => { + document.body.removeChild(panel); + vi.clearAllMocks(); + }); + + async function show(player: UserMePlayer): Promise { + panel.player = player; + panel.user = { email: "player@example.com" }; + await panel.updateComplete; + } + + // Lets the mocked request settle, then the re-render. + async function settle(): Promise { + await new Promise((r) => setTimeout(r, 0)); + await panel.updateComplete; + } + + const levelSwitch = () => + panel.querySelector( + 'button[role="switch"][aria-label="account_modal.level_visibility_title"]', + ) as HTMLButtonElement | null; + const preview = () => panel.querySelector(".level-visibility-preview"); + + it("renders no card when /users/@me has no levelHidden (older API)", async () => { + await show(makePlayer({ progress, username: "Kestrel" })); + + expect(panel.textContent).not.toContain("account_modal.privacy_title"); + expect(levelSwitch()).toBeNull(); + }); + + it("sits between the email-updates card and the delete card", async () => { + await show( + makePlayer({ + progress, + levelHidden: false, + marketingConsent: { consented: "approved", hasEmail: true }, + }), + ); + + const text = panel.textContent ?? ""; + const marketing = text.indexOf("account_modal.marketing_title"); + const privacy = text.indexOf("account_modal.privacy_title"); + const del = text.indexOf("account_modal.delete_account_title"); + expect(marketing).toBeGreaterThanOrEqual(0); + expect(privacy).toBeGreaterThan(marketing); + expect(del).toBeGreaterThan(privacy); + expect(text).toContain("account_modal.level_visibility_desc"); + }); + + it("while shown: switch on, and the badge in front of the name in the preview", async () => { + await show( + makePlayer({ progress, username: "Kestrel", levelHidden: false }), + ); + + const toggle = levelSwitch()!; + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(toggle.disabled).toBe(false); + + const strip = preview()!; + expect(strip.getAttribute("aria-hidden")).toBe("true"); + expect(strip.textContent).toContain( + "account_modal.level_visibility_others_see", + ); + expect(strip.textContent).toContain("Kestrel"); + expect(strip.textContent).not.toContain( + "account_modal.level_visibility_hidden", + ); + const badge = strip.querySelector("level-badge") as HTMLElement & { + level: number; + prestige: number; + }; + expect(badge.level).toBe(96); + expect(badge.prestige).toBe(3); + expect(badge.getAttribute("size")).toBe("24"); + }); + + it("while hidden: switch off, the name alone and 'Level hidden'", async () => { + await show( + makePlayer({ progress, username: "Kestrel", levelHidden: true }), + ); + + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + const strip = preview()!; + expect(strip.querySelector("level-badge")).toBeNull(); + expect(strip.textContent).toContain("Kestrel"); + expect(strip.textContent).toContain( + "account_modal.level_visibility_hidden", + ); + }); + + it("shows the switch without the preview when progression is off", async () => { + await show(makePlayer({ username: "Kestrel", levelHidden: false })); + + expect(levelSwitch()).toBeTruthy(); + expect(preview()).toBeNull(); + expect(panel.querySelector("level-badge")).toBeNull(); + }); + + it("turning it off: optimistic, disabled in flight, PUTs hidden=true", async () => { + const { setLevelVisibility } = await import("../../../src/client/Api"); + let answer!: (r: { ok: true; hidden: boolean }) => void; + vi.mocked(setLevelVisibility).mockImplementationOnce( + () => new Promise((r) => (answer = r)), + ); + const player = makePlayer({ progress, levelHidden: false }); + await show(player); + + levelSwitch()!.click(); + await panel.updateComplete; + + expect(setLevelVisibility).toHaveBeenCalledWith(true); + // Optimistic: already off, on the cached profile object too. + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(player.levelHidden).toBe(true); + expect(preview()!.querySelector("level-badge")).toBeNull(); + // Disabled while in flight; another click sends nothing. + expect(levelSwitch()!.disabled).toBe(true); + levelSwitch()!.click(); + expect(setLevelVisibility).toHaveBeenCalledTimes(1); + + answer({ ok: true, hidden: true }); + await settle(); + expect(levelSwitch()!.disabled).toBe(false); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(player.levelHidden).toBe(true); + }); + + it("turning it back on PUTs hidden=false", async () => { + const { setLevelVisibility } = await import("../../../src/client/Api"); + const player = makePlayer({ progress, levelHidden: true }); + await show(player); + + levelSwitch()!.click(); + await settle(); + + expect(setLevelVisibility).toHaveBeenCalledWith(false); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(player.levelHidden).toBe(false); + expect(preview()!.querySelector("level-badge")).toBeTruthy(); + }); + + it("reverts and shows the error alert when the request fails", async () => { + const { setLevelVisibility } = await import("../../../src/client/Api"); + const { showInGameAlert } = await import("../../../src/client/InGameModal"); + vi.mocked(setLevelVisibility).mockResolvedValueOnce({ + ok: false, + code: "failed", + }); + const player = makePlayer({ progress, levelHidden: false }); + await show(player); + + levelSwitch()!.click(); + await settle(); + + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(levelSwitch()!.disabled).toBe(false); + expect(player.levelHidden).toBe(false); + expect(showInGameAlert).toHaveBeenCalledWith( + "account_modal.level_visibility_failed", + ); + }); + + it("reverts without an alert when signed out (401)", async () => { + const { setLevelVisibility } = await import("../../../src/client/Api"); + const { showInGameAlert } = await import("../../../src/client/InGameModal"); + vi.mocked(setLevelVisibility).mockResolvedValueOnce({ + ok: false, + code: "logged_out", + }); + const player = makePlayer({ progress, levelHidden: true }); + await show(player); + + levelSwitch()!.click(); + await settle(); + + expect(player.levelHidden).toBe(true); + expect(levelSwitch()!.getAttribute("aria-checked")).toBe("false"); + // logOut() already ran inside setLevelVisibility; the signed-out state + // takes over from there. + expect(showInGameAlert).not.toHaveBeenCalled(); + }); + + it("refreshes the viewer's own-badge fallback once the change is saved", async () => { + const { getUserMe } = await import("../../../src/client/Api"); + const player = makePlayer({ progress, levelHidden: false }); + // getUserMe() memoises the same profile object the panel shows. + vi.mocked(getUserMe).mockResolvedValue({ + user: {}, + player, + } as unknown as UserMeResponse); + await show(player); + expect(ownHiddenLevelBadge()).toBeUndefined(); + + levelSwitch()!.click(); + await settle(); + expect(ownHiddenLevelBadge()).toEqual({ + level: 96, + prestige: 3, + legend: false, + }); + + levelSwitch()!.click(); + await settle(); + expect(ownHiddenLevelBadge()).toBeUndefined(); + }); + + it("leaves the fallback alone when the change fails", async () => { + const { getUserMe, setLevelVisibility } = + await import("../../../src/client/Api"); + vi.mocked(setLevelVisibility).mockResolvedValueOnce({ + ok: false, + code: "failed", + }); + await show(makePlayer({ progress, levelHidden: false })); + + levelSwitch()!.click(); + await settle(); + expect(getUserMe).not.toHaveBeenCalled(); + }); +}); + +describe("AccountSettingsPanel — privacy card (search engines)", () => { + let panel: AccountSettingsPanel; + + beforeEach(async () => { + if (!customElements.get("account-settings-panel")) { + customElements.define("account-settings-panel", AccountSettingsPanel); + } + panel = document.createElement( + "account-settings-panel", + ) as AccountSettingsPanel; + document.body.appendChild(panel); + await panel.updateComplete; + }); + + afterEach(() => { + document.body.removeChild(panel); + vi.clearAllMocks(); + }); + + async function show(player: UserMePlayer): Promise { + panel.player = player; + panel.user = { email: "player@example.com" }; + await panel.updateComplete; + } + + // Lets the mocked request settle, then the re-render. + async function settle(): Promise { + await new Promise((r) => setTimeout(r, 0)); + await panel.updateComplete; + } + + const searchSwitch = () => + panel.querySelector( + 'button[role="switch"][aria-label="account_modal.search_visibility_title"]', + ) as HTMLButtonElement | null; + const levelSwitch = () => + panel.querySelector( + 'button[role="switch"][aria-label="account_modal.level_visibility_title"]', + ) as HTMLButtonElement | null; + + it("renders no search row when /users/@me has no searchHidden (older API)", async () => { + await show(makePlayer({ levelHidden: false })); + + expect(levelSwitch()).toBeTruthy(); + expect(searchSwitch()).toBeNull(); + expect(panel.textContent).not.toContain( + "account_modal.search_visibility_title", + ); + }); + + it("sits below the level row, under a divider", async () => { + await show(makePlayer({ levelHidden: false, searchHidden: false })); + + const text = panel.textContent ?? ""; + const level = text.indexOf("account_modal.level_visibility_title"); + const search = text.indexOf("account_modal.search_visibility_title"); + expect(level).toBeGreaterThanOrEqual(0); + expect(search).toBeGreaterThan(level); + expect(text).toContain("account_modal.search_visibility_desc"); + const row = searchSwitch()!.closest("div.border-t"); + expect(row).toBeTruthy(); + expect(row!.contains(levelSwitch())).toBe(false); + }); + + it("shows the card with only the search row when levelHidden is absent", async () => { + await show(makePlayer({ searchHidden: false })); + + expect(panel.textContent).toContain("account_modal.privacy_title"); + expect(searchSwitch()).toBeTruthy(); + expect(levelSwitch()).toBeNull(); + // Nothing above it to divide from. + expect(searchSwitch()!.closest("div.border-t")).toBeNull(); + }); + + it("is on when shown in search (the default) and off when hidden", async () => { + await show(makePlayer({ searchHidden: false })); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(searchSwitch()!.disabled).toBe(false); + + await show(makePlayer({ searchHidden: true })); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); + }); + + it("turning it off: optimistic, disabled in flight, PUTs hidden=true", async () => { + const { setSearchVisibility, setLevelVisibility } = + await import("../../../src/client/Api"); + let answer!: (r: { ok: true; hidden: boolean }) => void; + vi.mocked(setSearchVisibility).mockImplementationOnce( + () => new Promise((r) => (answer = r)), + ); + const player = makePlayer({ levelHidden: false, searchHidden: false }); + await show(player); + + searchSwitch()!.click(); + await panel.updateComplete; + + expect(setSearchVisibility).toHaveBeenCalledWith(true); + expect(setLevelVisibility).not.toHaveBeenCalled(); + // Optimistic: already off, on the cached profile object too. + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(player.searchHidden).toBe(true); + // Disabled while in flight; another click sends nothing. The level + // switch is independent and stays usable. + expect(searchSwitch()!.disabled).toBe(true); + expect(levelSwitch()!.disabled).toBe(false); + searchSwitch()!.click(); + expect(setSearchVisibility).toHaveBeenCalledTimes(1); + + answer({ ok: true, hidden: true }); + await settle(); + expect(searchSwitch()!.disabled).toBe(false); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); + expect(player.searchHidden).toBe(true); + expect(player.levelHidden).toBe(false); + }); + + it("flipping the level switch writes only the level setting", async () => { + const { setSearchVisibility, setLevelVisibility } = + await import("../../../src/client/Api"); + let answer!: (r: { ok: true; hidden: boolean }) => void; + vi.mocked(setLevelVisibility).mockImplementationOnce( + () => new Promise((r) => (answer = r)), + ); + const player = makePlayer({ levelHidden: false, searchHidden: false }); + await show(player); + + levelSwitch()!.click(); + await panel.updateComplete; + + expect(setLevelVisibility).toHaveBeenCalledWith(true); + expect(setSearchVisibility).not.toHaveBeenCalled(); + expect(levelSwitch()!.disabled).toBe(true); + expect(searchSwitch()!.disabled).toBe(false); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + + answer({ ok: true, hidden: true }); + await settle(); + expect(player.levelHidden).toBe(true); + expect(player.searchHidden).toBe(false); + }); + + it("turning it back on PUTs hidden=false", async () => { + const { setSearchVisibility } = await import("../../../src/client/Api"); + const player = makePlayer({ searchHidden: true }); + await show(player); + + searchSwitch()!.click(); + await settle(); + + expect(setSearchVisibility).toHaveBeenCalledWith(false); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(player.searchHidden).toBe(false); + }); + + it("reverts and shows the error alert when the request fails", async () => { + const { setSearchVisibility } = await import("../../../src/client/Api"); + const { showInGameAlert } = await import("../../../src/client/InGameModal"); + vi.mocked(setSearchVisibility).mockResolvedValueOnce({ + ok: false, + code: "failed", + }); + const player = makePlayer({ searchHidden: false }); + await show(player); + + searchSwitch()!.click(); + await settle(); + + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("true"); + expect(searchSwitch()!.disabled).toBe(false); + expect(player.searchHidden).toBe(false); + expect(showInGameAlert).toHaveBeenCalledWith( + "account_modal.search_visibility_failed", + ); + }); + + it("reverts without an alert when signed out (401)", async () => { + const { setSearchVisibility } = await import("../../../src/client/Api"); + const { showInGameAlert } = await import("../../../src/client/InGameModal"); + vi.mocked(setSearchVisibility).mockResolvedValueOnce({ + ok: false, + code: "logged_out", + }); + const player = makePlayer({ searchHidden: true }); + await show(player); + + searchSwitch()!.click(); + await settle(); + + expect(player.searchHidden).toBe(true); + expect(searchSwitch()!.getAttribute("aria-checked")).toBe("false"); + // logOut() already ran inside setSearchVisibility; the signed-out state + // takes over from there. + expect(showInGameAlert).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/client/components/LobbyPlayerView.levelBadge.test.ts b/tests/client/components/LobbyPlayerView.levelBadge.test.ts index c1f8142adb..480811f77e 100644 --- a/tests/client/components/LobbyPlayerView.levelBadge.test.ts +++ b/tests/client/components/LobbyPlayerView.levelBadge.test.ts @@ -2,14 +2,24 @@ import { GameMode, HumansVsNations, } from "@openfront/engine-api/game/GameTypes"; +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; import type { LevelBadge } from "@openfront/shared/LevelBadgeWire"; import { packLevelBadge } from "@openfront/shared/LevelBadgeWire"; import type { ClientInfo } from "@openfront/shared/WireSchemas"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import "../../../src/client/components/LobbyPlayerView"; import type { LobbyTeamView } from "../../../src/client/components/LobbyPlayerView"; import { UserSettings } from "../../../src/client/UserSettings"; +// The viewer's own /users/@me, for the "hide my level" fallback. Signed out +// unless a test says otherwise. +const getUserMe = vi.hoisted(() => + vi.fn<() => Promise>(async () => false), +); +vi.mock("../../../src/client/Api", () => ({ getUserMe })); + +type LevelBadgeEl = HTMLElement & { level: number }; + const BADGE: LevelBadge = { level: 42, prestige: 2, legend: false }; // As the roster carries it. const BADGE_WIRE = packLevelBadge(BADGE)!; @@ -85,6 +95,9 @@ describe("lobby level badges", () => { UserSettings as unknown as { cache: Map } ).cache.clear(); document.body.replaceChildren(); + document.dispatchEvent(new Event("session-cleared")); + getUserMe.mockReset(); + getUserMe.mockResolvedValue(false); }); it("puts a 24px badge in front of the name in FFA pills", async () => { @@ -224,4 +237,143 @@ describe("lobby level badges", () => { expect(view.querySelector(".lobby-level-slot")).toBeNull(); }); }); + + describe("the viewer's own badge while they hide their level", () => { + const progress = { + prestige: 1, + level: 88, + xpInLevel: 5, + xpForNext: 900, + lifetimeXp: 777777, + legend: false, + canPrestige: false, + }; + function signedIn(levelHidden: boolean | undefined) { + getUserMe.mockResolvedValue({ + user: {}, + player: { publicId: "me-pub", progress, levelHidden }, + } as unknown as UserMeResponse); + } + // The refresh answers from a promise; let it land, then the re-render. + async function settle(view: LobbyTeamView) { + await new Promise((r) => setTimeout(r, 0)); + await view.updateComplete; + } + + it("shows the viewer their own badge from their /users/@me", async () => { + signedIn(true); + // The server sent no badge for them (hidden), and none for the guest. + const view = await mount({ + currentClientID: "me", + clients: [client("me"), client("guest")], + }); + await settle(view); + + expect(pills(view)).toEqual([["badge:24", "me"], ["guest"]]); + const badge = view.querySelector("level-badge") as HTMLElement & { + level: number; + prestige: number; + }; + expect(badge.level).toBe(88); + expect(badge.prestige).toBe(1); + }); + + it("never draws it beside anyone else", async () => { + signedIn(true); + const view = await mount({ + currentClientID: "me", + clients: [client("other"), client("me")], + }); + await settle(view); + + expect(pills(view)).toEqual([["other"], ["badge:24", "me"]]); + }); + + it("adds nothing while the level is shown", async () => { + signedIn(false); + const view = await mount({ + currentClientID: "me", + clients: [client("me")], + }); + await settle(view); + + expect(view.querySelector("level-badge")).toBeNull(); + }); + + it("prefers the roster's badge when there is one", async () => { + signedIn(true); + const view = await mount({ + currentClientID: "me", + clients: [client("me", { levelBadge: BADGE_WIRE })], + }); + await settle(view); + + const badges = view.querySelectorAll("level-badge"); + expect(badges.length).toBe(1); + expect((badges[0] as HTMLElement & { level: number }).level).toBe(42); + }); + + it("counts for the alignment slots in team mode", async () => { + signedIn(true); + const view = await mount({ + gameMode: GameMode.Team, + teamCount: HumansVsNations, + currentClientID: "me", + clients: [client("me"), client("guest")], + }); + await settle(view); + + expect(playerRows(view)).toEqual([ + ["badge:24", "me"], + ["slot", "guest"], + ]); + }); + + it("still shows with anonymous names on", async () => { + new UserSettings().toggleRandomName(); + signedIn(true); + const view = await mount({ + currentClientID: "me", + clients: [client("me"), client("other", { levelBadge: BADGE_WIRE })], + }); + await settle(view); + + expect(view.querySelectorAll("level-badge").length).toBe(1); + expect(pills(view)[0]).toEqual(["badge:24", "me"]); + }); + + it("follows a visibility change on the next roster update", async () => { + // One cached profile, as getUserMe() memoises it: the account settings + // toggle flips levelHidden on that same object. + const me = { + user: {}, + player: { publicId: "me-pub", progress, levelHidden: false }, + }; + getUserMe.mockResolvedValue(me as unknown as UserMeResponse); + const view = await mount({ + currentClientID: "me", + clients: [client("me", { levelBadge: BADGE_WIRE })], + }); + await settle(view); + expect((view.querySelector("level-badge") as LevelBadgeEl).level).toBe( + 42, + ); + + // Hidden: the server now sends no badge for them; their own shows. + me.player.levelHidden = true; + view.clients = [client("me"), client("guest")]; + await settle(view); + expect(pills(view)).toEqual([["badge:24", "me"], ["guest"]]); + expect((view.querySelector("level-badge") as LevelBadgeEl).level).toBe( + 88, + ); + + // Shown again, before the server's roster carries it back: no + // fallback badge lingers. + me.player.levelHidden = false; + view.clients = [client("me"), client("guest2")]; + await settle(view); + expect(view.querySelector("level-badge")).toBeNull(); + }); + }); }); diff --git a/tests/core/ApiSchemas.test.ts b/tests/core/ApiSchemas.test.ts index 8067605bc9..806828e1c7 100644 --- a/tests/core/ApiSchemas.test.ts +++ b/tests/core/ApiSchemas.test.ts @@ -121,3 +121,37 @@ describe("UserMeResponseSchema subscription.provider", () => { ); }); }); + +// "Show my profile in search engines" turned off. Optional so an older API +// still parses (account settings then show no search row); a malformed value +// reads as hidden rather than failing the /users/@me parse, like levelHidden: +// when the setting can't be read, the private answer is the safe one. +describe("UserMeResponseSchema searchHidden", () => { + const parseWith = (player: Record) => + UserMeResponseSchema.parse({ user: {}, player }); + + it("reads true and false", () => { + expect( + parseWith({ ...samplePlayer(), searchHidden: true }).player.searchHidden, + ).toBe(true); + expect( + parseWith({ ...samplePlayer(), searchHidden: false }).player.searchHidden, + ).toBe(false); + }); + + it("is undefined when the API does not send it", () => { + expect(parseWith(samplePlayer()).player.searchHidden).toBeUndefined(); + }); + + it("reads a malformed value as hidden instead of failing the parse", () => { + for (const bad of [null, "yes", "true", 1, 0, {}]) { + const parsed = parseWith({ + ...samplePlayer(), + levelHidden: false, + searchHidden: bad, + }); + expect(parsed.player.searchHidden).toBe(true); + expect(parsed.player.levelHidden).toBe(false); + } + }); +}); diff --git a/tests/core/ClientInfoLevelBadgeWire.test.ts b/tests/core/ClientInfoLevelBadgeWire.test.ts index 860ce5784b..d1c48e2653 100644 --- a/tests/core/ClientInfoLevelBadgeWire.test.ts +++ b/tests/core/ClientInfoLevelBadgeWire.test.ts @@ -59,6 +59,13 @@ const FULL: ClientInfo = { }; describe("ClientInfo.levelBadge on the wire", () => { + it("stays the last field of ClientInfo", () => { + // Its presence bit is the last of the one-byte header; a field after it + // would move it and break pre-badge frames. + const keys = Object.keys(ClientInfoSchema.shape); + expect(keys[keys.length - 1]).toBe("levelBadge"); + }); + it.each([ { level: 1, prestige: 0, legend: false }, { level: 37, prestige: 2, legend: false },