From 3c5c96c141fb54c7b98b84a9b8d8585be38df7db Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:13:11 +0100 Subject: [PATCH] profilelink (#4661) ## Description: image image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/client/AccountModal.ts | 17 +++++- src/client/ClanModal.ts | 54 +++++++++++++------ src/client/PlayerProfileModal.ts | 19 +++++-- src/client/components/FriendsList.ts | 16 ++++++ .../components/clan/ClanGameHistoryView.ts | 48 ++++++++++++----- src/core/ClanApiSchemas.ts | 6 +++ tests/client/PlayerProfileModal.test.ts | 12 +++-- tests/client/clan/ClanApiSchemas.test.ts | 20 +++++++ 8 files changed, 155 insertions(+), 37 deletions(-) diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index d23ddab08..233571a2c 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -306,7 +306,22 @@ export class AccountModal extends BaseModal { private renderFriendsTab(): TemplateResult { const myPublicId = this.userMeResponse?.player?.publicId ?? ""; - return html``; + return html`) => + this.openPlayerProfile(e.detail.publicId)} + >`; + } + + private openPlayerProfile(publicId: string): void { + const profileModal = document.querySelector< + HTMLElement & { openFromAccount(publicId: string): void } + >("player-profile-modal"); + profileModal?.openFromAccount(publicId); + } + + public returnToFriends(): void { + this.open({ tab: "friends" }); } private renderAccountTab(): TemplateResult { diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts index 51b6353fa..0b9989e15 100644 --- a/src/client/ClanModal.ts +++ b/src/client/ClanModal.ts @@ -76,11 +76,14 @@ export class ClanModal extends BaseModal { // would persist across hops if that becomes desired. private gameHistoryCache: ClanGameHistoryCache | null = null; private gameHistoryScrollTop = 0; - // Moving to the dedicated stats page closes this inline modal. These - // one-shot flags keep that close/open pair from clearing or reloading the - // clan detail that the stats Back button returns to. - private preserveStateForGameStats = false; - private returningFromGameStats = false; + // Opening a sibling modal (game stats or a player profile) closes this + // inline modal. These one-shot flags keep that close/open pair from clearing + // or reloading the clan detail that the sibling's Back button returns to. + private preserveStateForModalHandoff = false; + private returningFromModalHandoff = false; + // Which detail tab opened the player-profile modal, so its Back button lands + // on that tab (Members vs Game History) rather than always Members. + private profileOpenedFromGameHistory = false; private previousListTab: ListTab = "my-clans"; private get onListView(): boolean { @@ -212,8 +215,8 @@ export class ClanModal extends BaseModal { } protected onOpen(args?: Record): void { - if (this.returningFromGameStats) { - this.returningFromGameStats = false; + if (this.returningFromModalHandoff) { + this.returningFromModalHandoff = false; return; } const targetTag = @@ -229,7 +232,7 @@ export class ClanModal extends BaseModal { } protected onClose(): void { - if (this.preserveStateForGameStats) return; + if (this.preserveStateForModalHandoff) return; this.activeTab = "my-clans"; this.previousListTab = "my-clans"; this.view = "list"; @@ -240,7 +243,7 @@ export class ClanModal extends BaseModal { this.detailCache = null; this.gameHistoryCache = null; this.gameHistoryScrollTop = 0; - this.returningFromGameStats = false; + this.returningFromModalHandoff = false; } private async loadMyClans(opts: { allowGuest?: boolean } = {}) { @@ -385,6 +388,8 @@ export class ClanModal extends BaseModal { }} @view-stats=${(e: CustomEvent<{ gameId: string }>) => this.openGameStats(e.detail.gameId)} + @view-profile=${(e: CustomEvent<{ publicId: string }>) => + this.openPlayerProfile(e.detail.publicId)} @close-clan-modal=${() => this.close()} >`; } @@ -526,11 +531,11 @@ export class ClanModal extends BaseModal { if (!statsModal) return; this.gameHistoryScrollTop = this.modalEl?.getScrollTop() ?? 0; - this.preserveStateForGameStats = true; + this.preserveStateForModalHandoff = true; try { statsModal.openFromClan(gameId); } finally { - this.preserveStateForGameStats = false; + this.preserveStateForModalHandoff = false; } } @@ -540,13 +545,30 @@ export class ClanModal extends BaseModal { >("player-profile-modal"); if (!profileModal) return; + // Route the profile modal's Back button to whichever tab opened it. Only + // the game-history tab needs its scroll position preserved on return. + this.profileOpenedFromGameHistory = this.activeTab === "game-history"; + if (this.profileOpenedFromGameHistory) { + this.gameHistoryScrollTop = this.modalEl?.getScrollTop() ?? 0; + } + // Same handoff as openGameStats: keep clan state so the profile modal's - // back button can land on the members tab without a refetch. - this.preserveStateForGameStats = true; + // back button can land on the originating tab without a refetch. + this.preserveStateForModalHandoff = true; try { profileModal.openFromClan(publicId); } finally { - this.preserveStateForGameStats = false; + this.preserveStateForModalHandoff = false; + } + } + + // Entry point for the profile modal's Back button (opened via openFromClan + // from either the Members or Game History tab). + public returnFromPlayerProfile(): void { + if (this.profileOpenedFromGameHistory) { + this.returnToGameHistory(); + } else { + this.returnToMembers(); } } @@ -554,7 +576,7 @@ export class ClanModal extends BaseModal { const tag = this.selectedClanTag; if (!tag) return; - this.returningFromGameStats = true; + this.returningFromModalHandoff = true; this.open({ clan: tag, tab: "members" }); } @@ -562,7 +584,7 @@ export class ClanModal extends BaseModal { const tag = this.selectedClanTag; if (!tag) return; - this.returningFromGameStats = true; + this.returningFromModalHandoff = true; this.open({ clan: tag, tab: "game-history" }); void this.restoreGameHistoryScroll(); } diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 65e5153a2..8373f3e41 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -22,7 +22,7 @@ export class PlayerProfileModal extends BaseModal { @state() private username: string | null = null; @state() private statsTree: PlayerStatsTree | null = null; @state() private loading = false; - private openedFrom: "clan" | "leaderboard" | null = null; + private openedFrom: "clan" | "leaderboard" | "account" | null = null; protected modalConfig() { return { maxWidth: "960px" }; @@ -130,17 +130,30 @@ export class PlayerProfileModal extends BaseModal { this.open({ publicID: publicId }); } + public openFromAccount(publicId: string): void { + this.openedFrom = "account"; + this.open({ publicID: publicId }); + } + private back(): void { const openedFrom = this.openedFrom; this.close(); if (openedFrom === "clan") { document - .querySelector("clan-modal") - ?.returnToMembers(); + .querySelector< + HTMLElement & { returnFromPlayerProfile(): void } + >("clan-modal") + ?.returnFromPlayerProfile(); } else if (openedFrom === "leaderboard") { document .querySelector("leaderboard-modal") ?.open(); + } else if (openedFrom === "account") { + document + .querySelector< + HTMLElement & { returnToFriends(): void } + >("account-modal") + ?.returnToFriends(); } } } diff --git a/src/client/components/FriendsList.ts b/src/client/components/FriendsList.ts index 8a9ec610d..6ed22d7de 100644 --- a/src/client/components/FriendsList.ts +++ b/src/client/components/FriendsList.ts @@ -217,6 +217,18 @@ export class FriendsList extends LitElement { return new Date(iso).toLocaleDateString(); } + // Bubble up to the account modal, which opens the player profile modal + // (and its back button returns here). Same handoff as clan/leaderboard rows. + private viewProfile(publicId: string): void { + this.dispatchEvent( + new CustomEvent<{ publicId: string }>("view-profile", { + detail: { publicId }, + bubbles: true, + composed: true, + }), + ); + } + render(): TemplateResult { if (this.loading) { return html` @@ -334,6 +346,8 @@ export class FriendsList extends LitElement { this.viewProfile(entry.publicId)} >
${this.formatDate(entry.createdAt)} @@ -406,6 +420,8 @@ export class FriendsList extends LitElement { this.viewProfile(f.publicId)} >
${translateText("friends.friends_since", { diff --git a/src/client/components/clan/ClanGameHistoryView.ts b/src/client/components/clan/ClanGameHistoryView.ts index c39ebd8c6..ea6d46fd3 100644 --- a/src/client/components/clan/ClanGameHistoryView.ts +++ b/src/client/components/clan/ClanGameHistoryView.ts @@ -1,4 +1,4 @@ -import { html, LitElement, type TemplateResult } from "lit"; +import { html, LitElement, nothing, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { GameMapType } from "../../../core/game/Game"; import { @@ -16,6 +16,7 @@ import { groupByDay, } from "../baseComponents/stats/GameHistoryDates"; import { formatGameType, isFfa } from "../baseComponents/stats/GameTypeLabels"; +import { verifiedBadge } from "../ui/VerifiedBadge"; import { renderLoadingSpinner, showToast } from "./ClanShared"; type FilterKey = ClanGameFilter | "all"; @@ -207,6 +208,16 @@ export class ClanGameHistoryView extends LitElement { ); } + private viewProfile(publicId: string) { + this.dispatchEvent( + new CustomEvent<{ publicId: string }>("view-profile", { + detail: { publicId }, + bubbles: true, + composed: true, + }), + ); + } + render() { if (this.loadState === "forbidden") { return html` @@ -568,21 +579,34 @@ export class ClanGameHistoryView extends LitElement { class="text-[10px] font-bold uppercase tracking-wider mr-1 ${labelClass}" >${label}: - ${players.map( - (p) => html` - - `, - )} + ${players.map((p) => this.renderClanPlayerName(p))}
`; } + // Game history shows the name each player actually used *in that game* (their + // in-game/session name), not their current account name — the record should + // reflect who they were at match time. `verified` is recorded per session at + // ingest (server-validated at join), so the blue check reflects whether they + // actually played under their verified account name in that specific game. + private renderClanPlayerName( + p: ClanGame["clanPlayers"][number], + ): TemplateResult { + return html` + + + ${p.verified === true ? verifiedBadge() : nothing} + + `; + } + // Ranked games cap clan participation at a single player, so // "1 / N total" is noise — just show the total. FFA can carry // multiple clan members (renderResultBadge already handles partial diff --git a/src/core/ClanApiSchemas.ts b/src/core/ClanApiSchemas.ts index be1d37780..66343c1b1 100644 --- a/src/core/ClanApiSchemas.ts +++ b/src/core/ClanApiSchemas.ts @@ -188,7 +188,13 @@ export type JoinClanResponse = z.infer; export const ClanGamePlayerSchema = z.object({ publicId: z.string(), + // The name the player actually used in that game (the in-lobby name). This + // is what the history displays. username: z.string(), + // Whether the player joined that game under their verified account name + // (recorded per session at ingest, server-validated at join). Drives the + // verified check. Optional so older API responses still parse (→ no badge). + verified: z.boolean().optional(), won: z.boolean(), }); export type ClanGamePlayer = z.infer; diff --git a/tests/client/PlayerProfileModal.test.ts b/tests/client/PlayerProfileModal.test.ts index fd4e9d1f1..b6c3685c8 100644 --- a/tests/client/PlayerProfileModal.test.ts +++ b/tests/client/PlayerProfileModal.test.ts @@ -159,18 +159,20 @@ describe("public player profile route", () => { expect(fetchPublicPlayerProfileMock).not.toHaveBeenCalled(); }); - it("returns to the clan members tab when opened from a clan", async () => { + it("hands back to the clan modal when opened from a clan", async () => { fetchPublicPlayerProfileMock.mockResolvedValue({ createdAt: "2026-01-01T00:00:00.000Z", stats: statsTree, }); - const returnToMembers = vi.fn(); + // The clan modal decides which tab to restore (Members vs Game History) + // via returnFromPlayerProfile — the profile modal just hands back to it. + const returnFromPlayerProfile = vi.fn(); const fakeClanModal = document.createElement( "clan-modal", ) as HTMLElement & { - returnToMembers: () => void; + returnFromPlayerProfile: () => void; }; - fakeClanModal.returnToMembers = returnToMembers; + fakeClanModal.returnFromPlayerProfile = returnFromPlayerProfile; document.body.appendChild(fakeClanModal); modal.openFromClan("clan-member"); @@ -183,7 +185,7 @@ describe("public player profile route", () => { backButton.click(); expect(modal.isOpen()).toBe(false); - expect(returnToMembers).toHaveBeenCalled(); + expect(returnFromPlayerProfile).toHaveBeenCalled(); fakeClanModal.remove(); }); }); diff --git a/tests/client/clan/ClanApiSchemas.test.ts b/tests/client/clan/ClanApiSchemas.test.ts index 654d4be85..e14e80543 100644 --- a/tests/client/clan/ClanApiSchemas.test.ts +++ b/tests/client/clan/ClanApiSchemas.test.ts @@ -312,6 +312,26 @@ describe("ClanGamePlayerSchema", () => { expect(ClanGamePlayerSchema.safeParse(validPlayer).success).toBe(true); }); + it("accepts the verified flag or its absence (older API)", () => { + const verified = ClanGamePlayerSchema.safeParse({ + ...validPlayer, + verified: true, + }); + expect(verified.success).toBe(true); + if (verified.success) expect(verified.data.verified).toBe(true); + // validPlayer omits verified entirely — still valid (→ undefined). + const bare = ClanGamePlayerSchema.safeParse(validPlayer); + expect(bare.success).toBe(true); + if (bare.success) expect(bare.data.verified).toBeUndefined(); + }); + + it("rejects a non-boolean verified", () => { + expect( + ClanGamePlayerSchema.safeParse({ ...validPlayer, verified: "yes" }) + .success, + ).toBe(false); + }); + it("rejects when won is not a boolean", () => { expect( ClanGamePlayerSchema.safeParse({ ...validPlayer, won: "true" }).success,