Files
OpenFrontIO/src/client/Matchmaking.ts
T
Evan bbe727cc84 Refactor modal system: BaseModal renders shell, unified open(args) API (#3923)
## Description

Refactors the modal system so that `BaseModal` owns the `<o-modal>`
shell rendering, tab state, and lifecycle. Modal subclasses now provide
content via small hook methods (`renderHeaderSlot()`, `renderBody(tab)`,
`modalConfig()`) instead of each rebuilding the `<o-modal>` template and
inline-mode branching.

This sets up the foundation for a future modal URL router (e.g.
`#modal=store&tab=flags`), which will be a follow-up PR.

### What changed

**`BaseModal`** — `src/client/components/BaseModal.ts`
- Now renders the `<o-modal>` shell itself; subclasses no longer
duplicate it
- Owns `activeTab` state and dispatches per-tab rendering via
`renderBody(tab)`
- Single `modalConfig()` method returns `{ title?, tabs?, hideHeader?,
hideCloseButton?, alwaysMaximized?, maxWidth? }`
- Uniform `open(args?)` / `close(args?)` interface; subclasses interpret
args in `onOpen(args)` / `onClose(args)`
- Tabbed modals can lazy-load via `onTabEnter(tab)` lifecycle hook
- Re-entrancy guard on `open()` so `showPage()` re-invocations don't
clobber state set by the outer call
- Initial tab defaults to first entry in `modalConfig().tabs` so the
active tab is highlighted on first open

**17 modals migrated** to the new shape:
- Tabbed: Store, UserSetting, Leaderboard, Clan
- Non-tabbed: FlagInput, Account, TokenLogin, News, TerritoryPatterns,
Troubleshooting, SinglePlayer, Matchmaking, RankedModal, Help, Language
- Lobby: JoinLobbyModal, HostLobbyModal (kept their `confirmBeforeClose`
/ `closeAndLeave` / `closeWithoutLeaving` methods)

Per-modal diffs are mostly mechanical:
- Drop the `<o-modal>` wrapper template and the `if (this.inline) return
content` branch
- Drop the inner `<div class="${this.modalContainerClass}">` wrapper
(shell styling now lives on `<o-modal>`)
- Move header content into `renderHeaderSlot()` so it lives in the
sticky header area
- Convert `super.open()`/`super.close()` overrides into
`onOpen(args)`/`onClose(args)` hooks
- For tabbed modals: drop subclass `@state activeTab`, manual
`handleTabChange`, and the `render()` switch — all owned by BaseModal
now

**Other changes:**
- `Store`: in affiliate mode (`#affiliate=X`), tabs are hidden and a
single combined grid of purchasable affiliate items is shown
- `Main.ts`: `joinModal.open(lobbyId, lobbyInfo)` callsites converted to
the new `open({ lobbyId, lobbyInfo })` shape

### Follow-up

Modal URL router (`#modal=X&tab=Y&...`) is a separate PR on top of this
foundation.

## Please complete the following:

- [x] I have added screenshots for all UI updates _(no visual changes;
smoke-tested in dev)_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file _(no new user-visible strings)_
- [ ] I have added relevant tests to the test directory _(no test
coverage; tested in browser)_
- [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
2026-05-14 15:33:41 -07:00

266 lines
7.8 KiB
TypeScript

import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { UserMeResponse } from "../core/ApiSchemas";
import { getUserMe, hasLinkedAccount } from "./Api";
import { getPlayToken } from "./Auth";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
@customElement("matchmaking-modal")
export class MatchmakingModal extends BaseModal {
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
@state() private connected = false;
@state() private socket: WebSocket | null = null;
@state() private gameID: string | null = null;
private elo: number | string = "...";
constructor() {
super();
this.id = "page-matchmaking";
}
createRenderRoot() {
return this;
}
protected renderHeaderSlot() {
return modalHeader({
title: translateText("matchmaking_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
const eloDisplay = html`
<p class="text-center mt-2 mb-4 text-white/60">
${translateText("matchmaking_modal.elo", { elo: this.elo })}
</p>
`;
return html`
<div class="flex flex-col items-center justify-center gap-6 p-6">
${eloDisplay} ${this.renderInner()}
</div>
`;
}
private renderInner() {
if (!this.connected) {
return this.renderLoadingSpinner(
translateText("matchmaking_modal.connecting"),
"blue",
);
}
if (this.gameID === null) {
return this.renderLoadingSpinner(
translateText("matchmaking_modal.searching"),
"green",
);
} else {
return this.renderLoadingSpinner(
translateText("matchmaking_modal.waiting_for_game"),
"yellow",
);
}
}
private async connect() {
this.socket = new WebSocket(
`${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}`,
);
this.socket.onopen = async () => {
console.log("Connected to matchmaking server");
this.connectTimeout = setTimeout(async () => {
if (this.socket?.readyState !== WebSocket.OPEN) {
console.warn("[Matchmaking] socket not ready");
return;
}
// Set a delay so the user can see the "connecting" message,
// otherwise the "searching" message will be shown immediately.
// Also wait so people who back out immediately aren't added
// to the matchmaking queue.
this.socket.send(
JSON.stringify({
type: "join",
jwt: await getPlayToken(),
}),
);
this.connected = true;
this.requestUpdate();
}, 2000);
};
this.socket.onmessage = (event) => {
console.log(event.data);
const data = JSON.parse(event.data);
if (data.type === "match-assignment") {
this.socket?.close();
console.log(`matchmaking: got game ID: ${data.gameId}`);
this.gameID = data.gameId;
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
}
};
this.socket.onerror = (event: Event) => {
console.error("WebSocket error occurred:", event);
};
this.socket.onclose = () => {
console.log("Matchmaking server closed connection");
};
}
protected async onOpen(): Promise<void> {
const userMe = await getUserMe();
// Early return if modal was closed during async operation
if (!this.isModalOpen) {
return;
}
const isLoggedIn =
userMe &&
userMe.user &&
(userMe.user.discord !== undefined || userMe.user.email !== undefined);
if (!isLoggedIn) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("matchmaking_button.must_login"),
color: "red",
duration: 3000,
},
}),
);
this.close();
window.showPage?.("page-account");
return;
}
this.elo =
userMe.player.leaderboard?.oneVone?.elo ??
translateText("matchmaking_modal.no_elo");
this.connected = false;
this.gameID = null;
this.connect();
}
protected onClose(): void {
this.connected = false;
this.socket?.close();
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
}
}
private async checkGame() {
if (this.gameID === null) {
return;
}
const url = `/${ClientEnv.workerPath(this.gameID)}/api/game/${this.gameID}/exists`;
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const gameInfo = await response.json();
if (response.status !== 200) {
console.error(`Error checking game ${this.gameID}: ${response.status}`);
return;
}
if (!gameInfo.exists) {
console.info(`Game ${this.gameID} does not exist or hasn't started yet`);
return;
}
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
}
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: this.gameID,
source: "matchmaking",
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
}
@customElement("matchmaking-button")
export class MatchmakingButton extends LitElement {
@state() private isLoggedIn = false;
constructor() {
super();
}
async connectedCallback() {
super.connectedCallback();
// Listen for user authentication changes
document.addEventListener("userMeResponse", (event: Event) => {
const customEvent = event as CustomEvent;
if (customEvent.detail) {
const userMeResponse = customEvent.detail as UserMeResponse | false;
this.isLoggedIn = hasLinkedAccount(userMeResponse);
}
});
}
createRenderRoot() {
return this;
}
render() {
return this.isLoggedIn
? html`
<button
@click="${this.handleLoggedInClick}"
class="no-crazygames w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center group overflow-hidden relative"
title="${translateText("matchmaking_modal.title")}"
>
<span class="relative z-10 text-2xl">
${translateText("matchmaking_button.play_ranked")}
</span>
<span
class="relative z-10 text-xs font-medium text-purple-100 opacity-90 group-hover:opacity-100 transition-opacity"
>
${translateText("matchmaking_button.description")}
</span>
</button>
`
: html`
<button
@click="${this.handleLoggedOutClick}"
class="no-crazygames w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center overflow-hidden relative cursor-pointer"
>
<span class="relative z-10 text-2xl">
${translateText("matchmaking_button.login_required")}
</span>
</button>
`;
}
private handleLoggedInClick() {
document.dispatchEvent(new CustomEvent("open-matchmaking"));
}
private handleLoggedOutClick() {
window.showPage?.("page-account");
}
}