profilelink (#4661)

## Description:

<img width="930" height="755" alt="image"
src="https://github.com/user-attachments/assets/f606bdce-fbeb-4fc3-98fb-ba6622634350"
/>

<img width="875" height="368" alt="image"
src="https://github.com/user-attachments/assets/81c8243d-5dc0-4687-bed4-cadb6bafb808"
/>

## 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
This commit is contained in:
Ryan
2026-07-21 12:13:11 -07:00
committed by GitHub
parent 49c358d304
commit 3c5c96c141
8 changed files with 155 additions and 37 deletions
+16 -1
View File
@@ -306,7 +306,22 @@ export class AccountModal extends BaseModal {
private renderFriendsTab(): TemplateResult {
const myPublicId = this.userMeResponse?.player?.publicId ?? "";
return html`<friends-list .myPublicId=${myPublicId}></friends-list>`;
return html`<friends-list
.myPublicId=${myPublicId}
@view-profile=${(e: CustomEvent<{ publicId: string }>) =>
this.openPlayerProfile(e.detail.publicId)}
></friends-list>`;
}
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 {
+38 -16
View File
@@ -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<string, unknown>): 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()}
></clan-game-history-view>`;
}
@@ -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();
}
+16 -3
View File
@@ -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<HTMLElement & { returnToMembers(): void }>("clan-modal")
?.returnToMembers();
.querySelector<
HTMLElement & { returnFromPlayerProfile(): void }
>("clan-modal")
?.returnFromPlayerProfile();
} else if (openedFrom === "leaderboard") {
document
.querySelector<HTMLElement & { open(): void }>("leaderboard-modal")
?.open();
} else if (openedFrom === "account") {
document
.querySelector<
HTMLElement & { returnToFriends(): void }
>("account-modal")
?.returnToFriends();
}
}
}
+16
View File
@@ -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 {
<player-name
.username=${entry.username}
.publicId=${entry.publicId}
.nameClass=${"font-bold text-blue-300 truncate hover:underline"}
.onNameClick=${() => this.viewProfile(entry.publicId)}
></player-name>
<div class="text-white/30 text-[10px] mt-0.5">
${this.formatDate(entry.createdAt)}
@@ -406,6 +420,8 @@ export class FriendsList extends LitElement {
<player-name
.username=${f.username}
.publicId=${f.publicId}
.nameClass=${"font-bold text-blue-300 truncate hover:underline"}
.onNameClick=${() => this.viewProfile(f.publicId)}
></player-name>
<div class="text-white/30 text-[10px] mt-0.5">
${translateText("friends.friends_since", {
@@ -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}:</span
>
${players.map(
(p) => html`
<copy-button
compact
.copyText=${p.publicId}
.displayText=${p.username ?? p.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
`,
)}
${players.map((p) => this.renderClanPlayerName(p))}
</div>
`;
}
// 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`
<span class="inline-flex items-center gap-1 min-w-0 max-w-full">
<button
type="button"
class="font-bold text-blue-300 truncate hover:underline"
title=${translateText("player_profile.view")}
@click=${() => this.viewProfile(p.publicId)}
>
${p.username}
</button>
${p.verified === true ? verifiedBadge() : nothing}
</span>
`;
}
// 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
+6
View File
@@ -188,7 +188,13 @@ export type JoinClanResponse = z.infer<typeof JoinClanResponseSchema>;
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<typeof ClanGamePlayerSchema>;
+7 -5
View File
@@ -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();
});
});
+20
View File
@@ -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,