mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 06:03:05 +00:00
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:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user