feat: friends panel (#3990)

## Description:

# Add Friends tab to Account modal

## Summary

- Adds a "Friends" tab to the Account modal, alongside Account / Stats /
Games.
- New `<friends-list>` Lit component covering the full friend lifecycle:
send request, accept / deny incoming, withdraw outgoing, remove friend,
paginated list with "Load more".
- New `FriendsApi.ts` wrapping `GET/POST/DELETE /friends*` endpoints
with typed error codes (`not_found` / `conflict` / `bad_request` /
`request_failed`).
- Zod schemas for the friend API responses in `core/ApiSchemas.ts`.
- Translations under a new `friends.*` block in `en.json`.

Friends and pending requests are displayed by public ID via
`copy-button`, matching the existing clan convention.


## 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-05-23 16:16:16 +01:00
committed by GitHub
parent 545ad313e3
commit fd6cd762e6
5 changed files with 644 additions and 0 deletions
+30
View File
@@ -434,9 +434,39 @@
"tab_account": "Account",
"tab_stats": "Stats",
"tab_games": "Games",
"tab_friends": "Friends",
"no_stats": "No stats available yet. Play some games to start tracking.",
"no_games": "No games played yet."
},
"friends": {
"add_friend": "Add Friend",
"public_id_placeholder": "Enter their public player ID",
"send_request": "Send Request",
"pending_requests": "Pending Requests",
"incoming": "Incoming",
"outgoing": "Outgoing",
"accept": "Accept",
"deny": "Deny",
"withdraw": "Withdraw",
"your_friends": "Your Friends",
"no_friends": "You haven't added any friends yet.",
"friends_since": "Friends since {date}",
"remove": "Remove",
"load_more": "Load More",
"confirm_remove": "Remove {publicId} from your friends?",
"request_sent": "Friend request sent",
"request_auto_accepted": "Friend request accepted — you are now friends",
"request_accepted": "Friend request accepted",
"request_denied": "Friend request denied",
"request_withdrawn": "Friend request withdrawn",
"friend_removed": "Friend removed",
"load_failed": "Failed to load friends",
"cannot_friend_self": "You can't add yourself as a friend",
"error_not_found": "Player not found",
"error_conflict": "Already friends or request already sent",
"error_bad_request": "Invalid request",
"error_generic": "Something went wrong. Please try again."
},
"leaderboard_modal": {
"title": "Leaderboard",
"ranked_tab": "1v1 Ranked",
+9
View File
@@ -18,6 +18,7 @@ import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/CurrencyDisplay";
import "./components/Difficulties";
import "./components/FriendsList";
import "./components/SubscriptionPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
@@ -107,6 +108,7 @@ export class AccountModal extends BaseModal {
{ key: "account", label: translateText("account_modal.tab_account") },
{ key: "stats", label: translateText("account_modal.tab_stats") },
{ key: "games", label: translateText("account_modal.tab_games") },
{ key: "friends", label: translateText("account_modal.tab_friends") },
],
};
}
@@ -135,11 +137,18 @@ export class AccountModal extends BaseModal {
return this.renderStatsTab();
case "games":
return this.renderGamesTab();
case "friends":
return this.renderFriendsTab();
default:
return this.renderAccountTab();
}
}
private renderFriendsTab(): TemplateResult {
const myPublicId = this.userMeResponse?.player?.publicId ?? "";
return html`<friends-list .myPublicId=${myPublicId}></friends-list>`;
}
private renderAccountTab(): TemplateResult {
return html`
<div class="flex flex-col gap-6">
+146
View File
@@ -0,0 +1,146 @@
import {
type FriendRequestsResponse,
FriendRequestsResponseSchema,
type FriendsListResponse,
FriendsListResponseSchema,
type SendFriendRequestResponse,
SendFriendRequestResponseSchema,
} from "../core/ApiSchemas";
import { getApiBase } from "./Api";
import { getAuthHeader } from "./Auth";
async function friendsFetch(
path: string,
options?: RequestInit,
): Promise<Response> {
return fetch(`${getApiBase()}${path}`, {
...options,
headers: {
Accept: "application/json",
...options?.headers,
Authorization: await getAuthHeader(),
},
});
}
export type FriendActionError =
| "not_found"
| "conflict"
| "bad_request"
| "request_failed";
export async function fetchFriendRequests(): Promise<
FriendRequestsResponse | false
> {
try {
const res = await friendsFetch("/friends/requests");
if (!res.ok) return false;
const parsed = FriendRequestsResponseSchema.safeParse(await res.json());
if (!parsed.success) {
console.warn("fetchFriendRequests: zod failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchFriendRequests: request failed", err);
return false;
}
}
export async function fetchFriends(
page: number,
limit: number,
): Promise<FriendsListResponse | false> {
try {
const url = new URL(`${getApiBase()}/friends`);
url.searchParams.set("page", String(page));
url.searchParams.set("limit", String(limit));
const res = await fetch(url.toString(), {
headers: {
Accept: "application/json",
Authorization: await getAuthHeader(),
},
});
if (!res.ok) return false;
const parsed = FriendsListResponseSchema.safeParse(await res.json());
if (!parsed.success) {
console.warn("fetchFriends: zod failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchFriends: request failed", err);
return false;
}
}
export async function sendFriendRequest(
publicId: string,
): Promise<SendFriendRequestResponse | FriendActionError> {
try {
const res = await friendsFetch(
`/friends/requests/${encodeURIComponent(publicId)}`,
{ method: "POST" },
);
if (res.status === 404) return "not_found";
if (res.status === 409) return "conflict";
if (res.status === 400) return "bad_request";
if (!res.ok) return "request_failed";
const parsed = SendFriendRequestResponseSchema.safeParse(await res.json());
if (!parsed.success) return "request_failed";
return parsed.data;
} catch (err) {
console.warn("sendFriendRequest: request failed", err);
return "request_failed";
}
}
export async function acceptFriendRequest(
publicId: string,
): Promise<true | FriendActionError> {
try {
const res = await friendsFetch(
`/friends/requests/${encodeURIComponent(publicId)}/accept`,
{ method: "POST" },
);
if (res.status === 404) return "not_found";
if (!res.ok) return "request_failed";
return true;
} catch (err) {
console.warn("acceptFriendRequest: request failed", err);
return "request_failed";
}
}
export async function deleteFriendRequest(
publicId: string,
): Promise<true | FriendActionError> {
try {
const res = await friendsFetch(
`/friends/requests/${encodeURIComponent(publicId)}`,
{ method: "DELETE" },
);
if (res.status === 404) return "not_found";
if (!res.ok) return "request_failed";
return true;
} catch (err) {
console.warn("deleteFriendRequest: request failed", err);
return "request_failed";
}
}
export async function removeFriend(
publicId: string,
): Promise<true | FriendActionError> {
try {
const res = await friendsFetch(`/friends/${encodeURIComponent(publicId)}`, {
method: "DELETE",
});
if (res.status === 404) return "not_found";
if (!res.ok) return "request_failed";
return true;
} catch (err) {
console.warn("removeFriend: request failed", err);
return "request_failed";
}
}
+430
View File
@@ -0,0 +1,430 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { FriendEntry } from "../../core/ApiSchemas";
import {
acceptFriendRequest,
deleteFriendRequest,
fetchFriendRequests,
fetchFriends,
removeFriend,
sendFriendRequest,
} from "../FriendsApi";
import { showToast, translateText } from "../Utils";
import "./CopyButton";
const PAGE_LIMIT = 20;
@customElement("friends-list")
export class FriendsList extends LitElement {
createRenderRoot() {
return this;
}
@property({ type: String }) myPublicId = "";
@state() private loading = true;
@state() private actionPending = false;
@state() private friends: FriendEntry[] = [];
@state() private friendsTotal = 0;
@state() private friendsPage = 0;
@state() private incoming: FriendEntry[] = [];
@state() private outgoing: FriendEntry[] = [];
@state() private addInput = "";
connectedCallback(): void {
super.connectedCallback();
void this.loadAll();
}
private async loadAll(): Promise<void> {
this.loading = true;
try {
const [requests, firstPage] = await Promise.all([
fetchFriendRequests(),
fetchFriends(1, PAGE_LIMIT),
]);
if (requests) {
this.incoming = requests.incoming;
this.outgoing = requests.outgoing;
}
if (firstPage) {
this.friends = firstPage.results;
this.friendsTotal = firstPage.total;
this.friendsPage = firstPage.page;
}
} finally {
this.loading = false;
}
}
private async loadMore(): Promise<void> {
if (this.actionPending) return;
this.actionPending = true;
try {
const next = await fetchFriends(this.friendsPage + 1, PAGE_LIMIT);
if (!next) {
showToast(translateText("friends.load_failed"), "red");
return;
}
this.friends = [...this.friends, ...next.results];
this.friendsPage = next.page;
this.friendsTotal = next.total;
} finally {
this.actionPending = false;
}
}
// Re-fetch every currently-loaded page from page 1. Server pagination is
// offset-based, so any insert/delete shifts later pages — leaving the local
// cache divergent from the server. Call this after add/remove to resync.
private async refreshFriends(): Promise<void> {
const targetPages = Math.max(1, this.friendsPage);
const accumulated: FriendEntry[] = [];
let total = this.friendsTotal;
let lastPage = 0;
for (let p = 1; p <= targetPages; p++) {
const data = await fetchFriends(p, PAGE_LIMIT);
if (!data) return;
accumulated.push(...data.results);
total = data.total;
lastPage = data.page;
if (data.results.length < PAGE_LIMIT) break;
}
this.friends = accumulated;
this.friendsTotal = total;
this.friendsPage = lastPage;
}
private async handleSend(): Promise<void> {
const target = this.addInput.trim();
if (!target) return;
if (target === this.myPublicId) {
showToast(translateText("friends.cannot_friend_self"), "red");
return;
}
if (this.actionPending) return;
this.actionPending = true;
try {
const result = await sendFriendRequest(target);
if (typeof result === "string") {
showToast(translateText(this.errorKey(result)), "red");
return;
}
this.addInput = "";
if (result.status === "accepted") {
showToast(translateText("friends.request_auto_accepted"), "green");
await this.loadAll();
} else {
showToast(translateText("friends.request_sent"), "green");
this.outgoing = [
...this.outgoing,
{ publicId: target, createdAt: new Date().toISOString() },
];
}
} finally {
this.actionPending = false;
}
}
private async handleAccept(publicId: string): Promise<void> {
if (this.actionPending) return;
this.actionPending = true;
try {
const result = await acceptFriendRequest(publicId);
if (result !== true) {
showToast(translateText(this.errorKey(result)), "red");
return;
}
this.incoming = this.incoming.filter((r) => r.publicId !== publicId);
this.friends = [
{ publicId, createdAt: new Date().toISOString() },
...this.friends,
];
this.friendsTotal++;
showToast(translateText("friends.request_accepted"), "green");
await this.refreshFriends();
} finally {
this.actionPending = false;
}
}
private async handleDenyOrWithdraw(
publicId: string,
direction: "incoming" | "outgoing",
): Promise<void> {
if (this.actionPending) return;
this.actionPending = true;
try {
const result = await deleteFriendRequest(publicId);
if (result !== true) {
showToast(translateText(this.errorKey(result)), "red");
return;
}
if (direction === "incoming") {
this.incoming = this.incoming.filter((r) => r.publicId !== publicId);
showToast(translateText("friends.request_denied"), "green");
} else {
this.outgoing = this.outgoing.filter((r) => r.publicId !== publicId);
showToast(translateText("friends.request_withdrawn"), "green");
}
} finally {
this.actionPending = false;
}
}
private async handleRemove(publicId: string): Promise<void> {
if (this.actionPending) return;
const confirmed = window.confirm(
translateText("friends.confirm_remove", { publicId }),
);
if (!confirmed) return;
this.actionPending = true;
try {
const result = await removeFriend(publicId);
if (result !== true) {
showToast(translateText(this.errorKey(result)), "red");
return;
}
this.friends = this.friends.filter((f) => f.publicId !== publicId);
this.friendsTotal = Math.max(0, this.friendsTotal - 1);
showToast(translateText("friends.friend_removed"), "green");
await this.refreshFriends();
} finally {
this.actionPending = false;
}
}
private errorKey(err: string): string {
switch (err) {
case "not_found":
return "friends.error_not_found";
case "conflict":
return "friends.error_conflict";
case "bad_request":
return "friends.error_bad_request";
default:
return "friends.error_generic";
}
}
private formatDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
render(): TemplateResult {
if (this.loading) {
return html`
<div class="flex items-center justify-center p-12">
<div
class="w-10 h-10 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
></div>
</div>
`;
}
return html`
<div class="flex flex-col gap-6">
${this.renderAddSection()} ${this.renderRequestsSection()}
${this.renderFriendsSection()}
</div>
`;
}
private renderAddSection(): TemplateResult {
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-blue-400"></span>
${translateText("friends.add_friend")}
</h3>
<div class="flex flex-col sm:flex-row gap-3">
<input
type="text"
.value=${this.addInput}
@input=${(e: Event) =>
(this.addInput = (e.target as HTMLInputElement).value)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") void this.handleSend();
}}
class="flex-1 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-mono text-sm"
placeholder=${translateText("friends.public_id_placeholder")}
maxlength="22"
?disabled=${this.actionPending}
/>
<button
@click=${() => void this.handleSend()}
?disabled=${this.actionPending || this.addInput.trim().length === 0}
class="px-4 py-2 text-sm font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("friends.send_request")}
</button>
</div>
</div>
`;
}
private renderRequestsSection(): TemplateResult | "" {
if (this.incoming.length === 0 && this.outgoing.length === 0) return "";
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-blue-400">✉️</span>
${translateText("friends.pending_requests")}
</h3>
${this.incoming.length > 0
? html`
<div
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-2"
>
${translateText("friends.incoming")}
</div>
<div class="space-y-2 mb-4">
${this.incoming.map((r) =>
this.renderRequestRow(r, "incoming"),
)}
</div>
`
: ""}
${this.outgoing.length > 0
? html`
<div
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-2"
>
${translateText("friends.outgoing")}
</div>
<div class="space-y-2">
${this.outgoing.map((r) =>
this.renderRequestRow(r, "outgoing"),
)}
</div>
`
: ""}
</div>
`;
}
private renderRequestRow(
entry: FriendEntry,
direction: "incoming" | "outgoing",
): TemplateResult {
return html`
<div
class="flex items-center gap-3 bg-white/5 rounded-lg border border-white/10 p-3"
>
<div class="flex-1 min-w-0">
<copy-button
compact
.copyText=${entry.publicId}
.displayText=${entry.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<div class="text-white/30 text-[10px] mt-0.5">
${this.formatDate(entry.createdAt)}
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
${direction === "incoming"
? html`
<button
@click=${() => void this.handleAccept(entry.publicId)}
?disabled=${this.actionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("friends.accept")}
</button>
<button
@click=${() =>
void this.handleDenyOrWithdraw(entry.publicId, "incoming")}
?disabled=${this.actionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("friends.deny")}
</button>
`
: html`
<button
@click=${() =>
void this.handleDenyOrWithdraw(entry.publicId, "outgoing")}
?disabled=${this.actionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-white/10 text-white/70 border border-white/10 hover:bg-white/20 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("friends.withdraw")}
</button>
`}
</div>
</div>
`;
}
private renderFriendsSection(): TemplateResult {
if (this.friendsTotal === 0) {
return html`
<div
class="bg-white/5 rounded-xl border border-white/10 p-12 flex flex-col items-center justify-center text-center"
>
<div class="text-4xl mb-3">👥</div>
<p class="text-white/60 text-sm">
${translateText("friends.no_friends")}
</p>
</div>
`;
}
const hasMore = this.friends.length < this.friendsTotal;
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-blue-400">👥</span>
${translateText("friends.your_friends")}
<span class="text-xs text-white/40 font-medium">
(${this.friendsTotal})
</span>
</h3>
<div class="space-y-2">
${this.friends.map(
(f) => html`
<div
class="flex items-center gap-3 bg-white/5 rounded-lg border border-white/10 p-3"
>
<div class="flex-1 min-w-0">
<copy-button
compact
.copyText=${f.publicId}
.displayText=${f.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<div class="text-white/30 text-[10px] mt-0.5">
${translateText("friends.friends_since", {
date: this.formatDate(f.createdAt),
})}
</div>
</div>
<button
@click=${() => void this.handleRemove(f.publicId)}
?disabled=${this.actionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none shrink-0"
>
${translateText("friends.remove")}
</button>
</div>
`,
)}
</div>
${hasMore
? html`
<div class="flex justify-center mt-4">
<button
@click=${() => void this.loadMore()}
?disabled=${this.actionPending}
class="px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-lg bg-white/10 text-white/80 border border-white/10 hover:bg-white/20 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("friends.load_more")}
</button>
</div>
`
: ""}
</div>
`;
}
}
+29
View File
@@ -218,6 +218,35 @@ export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;
export const FriendEntrySchema = z.object({
publicId: z.string(),
createdAt: z.iso.datetime(),
});
export type FriendEntry = z.infer<typeof FriendEntrySchema>;
export const FriendRequestsResponseSchema = z.object({
incoming: FriendEntrySchema.array(),
outgoing: FriendEntrySchema.array(),
});
export type FriendRequestsResponse = z.infer<
typeof FriendRequestsResponseSchema
>;
export const FriendsListResponseSchema = z.object({
results: FriendEntrySchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type FriendsListResponse = z.infer<typeof FriendsListResponseSchema>;
export const SendFriendRequestResponseSchema = z.object({
status: z.enum(["requested", "accepted"]),
});
export type SendFriendRequestResponse = z.infer<
typeof SendFriendRequestResponseSchema
>;
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),