Discord(et al.) embedded URLs (#2740)

## Description:

Changes URL embeds within other platforms, e.g. Discord, WhatsApp & X.

Updates game URLs to `/game/<code>` instead of `/#join=<code>` (required
for embedded URLs). An added benefit of this is that you would be able
to change a url from `openfront.io/game/RQDUy8nP?replay` to
`api.openfront.io/game/RQDUy8nP?replay` (add api. In front) and be in
the right place for the API data.

Updates URLs when joining/leaving private lobbies

Appends a random string to the end of the URL when inside a private
lobby and options change - this is to force discord to update the
embedded details.

Updates URL in different game states to ?lobby / ?live and ?replay.
These do nothing other than being used as a _cache-busting_ solution.


-----------------------------------------------
### **Lobby Info**

Discord:
<img width="556" height="487" alt="image"
src="https://github.com/user-attachments/assets/efd4a06d-506c-4036-9403-ee7c9a669e21"
/>

WhatsApp:
<img width="353" height="339" alt="image"
src="https://github.com/user-attachments/assets/3b2d0c69-988c-424f-9dee-f4e6a6868f6b"
/>


x.com:
<img width="588" height="325" alt="image"
src="https://github.com/user-attachments/assets/d9e78169-20be-4a3e-8df4-8ad41d08a750"
/>


-------------------------
### **Game Win Details**
Discord:
<img width="506" height="468" alt="image"
src="https://github.com/user-attachments/assets/69947774-c943-4a50-b470-5634ed3bf3d7"
/>

WhatsApp:
<img width="770" height="132" alt="image"
src="https://github.com/user-attachments/assets/eec28bf8-bf64-4ab8-954e-03dfdd1aae40"
/>

x.com
<img width="584" height="350" alt="image"
src="https://github.com/user-attachments/assets/168063e2-b707-422b-b7a1-0025f3ebeb92"
/>


## 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:

w.o.n
This commit is contained in:
Ryan
2026-01-14 03:48:00 +00:00
committed by GitHub
parent c80ccaece9
commit 247c78151c
15 changed files with 832 additions and 345 deletions
+9 -7
View File
@@ -5,6 +5,7 @@ import {
PlayerStatsTree,
UserMeResponse,
} from "../core/ApiSchemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
@@ -198,7 +199,7 @@ export class AccountModal extends BaseModal {
</h3>
<game-list
.games=${this.recentGames}
.onViewGame=${(id: string) => this.viewGame(id)}
.onViewGame=${(id: string) => void this.viewGame(id)}
></game-list>
</div>
</div>
@@ -229,15 +230,16 @@ export class AccountModal extends BaseModal {
return html``;
}
private viewGame(gameId: string): void {
private async viewGame(gameId: string): Promise<void> {
this.close();
const path = location.pathname;
const { search } = location;
const hash = `#join=${encodeURIComponent(gameId)}`;
const newUrl = `${path}${search}${hash}`;
const config = await getServerConfigFromClient();
const encodedGameId = encodeURIComponent(gameId);
const newUrl = `/${config.workerPath(gameId)}/game/${encodedGameId}`;
history.pushState({ join: gameId }, "", newUrl);
window.dispatchEvent(new HashChangeEvent("hashchange"));
window.dispatchEvent(
new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }),
);
}
private renderLogoutButton(): TemplateResult {
+51 -3
View File
@@ -21,6 +21,7 @@ import {
GameConfig,
GameInfo,
TeamCountConfig,
isValidGameID,
} from "../core/Schemas";
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
@@ -61,6 +62,7 @@ export class HostLobbyModal extends BaseModal {
@state() private compactMap: boolean = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
@state() private lobbyUrlSuffix = "";
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [];
@@ -74,6 +76,8 @@ export class HostLobbyModal extends BaseModal {
private userSettings: UserSettings = new UserSettings();
private mapLoader = terrainMapFileLoader;
private leaveLobbyOnClose = true;
private renderOptionToggle(
labelKey: string,
checked: boolean,
@@ -100,6 +104,28 @@ export class HostLobbyModal extends BaseModal {
`;
}
private getRandomString(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
return Array.from(
{ length: 5 },
() => chars[Math.floor(Math.random() * chars.length)],
).join("");
}
private async buildLobbyUrl(): Promise<string> {
const config = await getServerConfigFromClient();
return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`;
}
private async constructUrl(): Promise<string> {
this.lobbyUrlSuffix = this.getRandomString();
return await this.buildLobbyUrl();
}
private updateHistory(url: string): void {
history.replaceState(null, "", url);
}
render() {
const content = html`
<div
@@ -109,7 +135,7 @@ export class HostLobbyModal extends BaseModal {
${modalHeader({
title: translateText("host_modal.title"),
onBack: () => {
this.leaveLobby();
this.leaveLobbyOnClose = true;
this.close();
},
ariaLabel: translateText("common.back"),
@@ -821,9 +847,14 @@ export class HostLobbyModal extends BaseModal {
);
createLobby(this.lobbyCreatorClientID)
.then((lobby) => {
.then(async (lobby) => {
this.lobbyId = lobby.gameID;
if (!isValidGameID(this.lobbyId)) {
throw new Error(`Invalid lobby ID format: ${this.lobbyId}`);
}
crazyGamesSDK.showInviteButton(this.lobbyId);
const url = await this.constructUrl();
this.updateHistory(url);
})
.then(() => {
this.dispatchEvent(
@@ -895,6 +926,10 @@ export class HostLobbyModal extends BaseModal {
protected onClose(): void {
console.log("Closing host lobby modal");
if (this.leaveLobbyOnClose) {
this.leaveLobby();
this.updateHistory("/"); // Reset URL to base
}
crazyGamesSDK.hideInviteButton();
// Clean up timers and resources
@@ -933,6 +968,8 @@ export class HostLobbyModal extends BaseModal {
this.lobbyCreatorClientID = "";
this.lobbyIdVisible = true;
this.nationCount = 0;
this.leaveLobbyOnClose = true;
}
private async handleSelectRandomMap() {
@@ -1075,6 +1112,8 @@ export class HostLobbyModal extends BaseModal {
const spawnImmunityTicks = this.spawnImmunityDurationMinutes
? this.spawnImmunityDurationMinutes * 60 * 10
: 0;
const url = await this.constructUrl();
this.updateHistory(url);
this.dispatchEvent(
new CustomEvent("update-game-config", {
detail: {
@@ -1134,6 +1173,10 @@ export class HostLobbyModal extends BaseModal {
console.log(
`Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
);
// If the modal closes as part of starting the game, do not leave the lobby
this.leaveLobbyOnClose = false;
const config = await getServerConfigFromClient();
const response = await fetch(
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`,
@@ -1144,12 +1187,17 @@ export class HostLobbyModal extends BaseModal {
},
},
);
if (!response.ok) {
this.leaveLobbyOnClose = true;
}
return response;
}
private async copyToClipboard() {
const url = await this.buildLobbyUrl();
await copyToClipboard(
`${location.origin}/#join=${this.lobbyId}`,
url,
() => (this.copySuccess = true),
() => (this.copySuccess = false),
);
+51 -26
View File
@@ -3,6 +3,7 @@ import { customElement, query, state } from "lit/decorators.js";
import { copyToClipboard, translateText } from "../client/Utils";
import {
ClientInfo,
GAME_ID_REGEX,
GameConfig,
GameInfo,
GameRecordSchema,
@@ -32,6 +33,8 @@ export class JoinPrivateLobbyModal extends BaseModal {
private playersInterval: NodeJS.Timeout | null = null;
private userSettings: UserSettings = new UserSettings();
private leaveLobbyOnClose = true;
updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
}
@@ -354,21 +357,10 @@ export class JoinPrivateLobbyModal extends BaseModal {
}
}
protected onClose(): void {
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
this.currentLobbyId = "";
this.gameConfig = null;
this.players = [];
if (this.playersInterval) {
clearInterval(this.playersInterval);
this.playersInterval = null;
private leaveLobby() {
if (!this.currentLobbyId || !this.hasJoined) {
return;
}
}
public closeAndLeave() {
this.close();
this.hasJoined = false;
this.message = "";
this.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: this.currentLobbyId },
@@ -378,16 +370,43 @@ export class JoinPrivateLobbyModal extends BaseModal {
);
}
protected onClose(): void {
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
this.gameConfig = null;
this.players = [];
if (this.playersInterval) {
clearInterval(this.playersInterval);
this.playersInterval = null;
}
if (this.leaveLobbyOnClose) {
this.leaveLobby();
// Reset URL to base when modal closes
history.replaceState(null, "", window.location.origin + "/");
}
this.hasJoined = false;
this.message = "";
this.currentLobbyId = "";
this.leaveLobbyOnClose = true;
}
public closeAndLeave() {
this.leaveLobbyOnClose = true;
this.close();
}
private async copyToClipboard() {
const config = await getServerConfigFromClient();
await copyToClipboard(
`${location.origin}/#join=${this.currentLobbyId}`,
`${location.origin}/${config.workerPath(this.currentLobbyId)}/game/${this.currentLobbyId}`,
() => (this.copySuccess = true),
() => (this.copySuccess = false),
);
}
private isValidLobbyId(value: string): boolean {
return /^[a-zA-Z0-9]{8}$/.test(value);
return GAME_ID_REGEX.test(value);
}
private normalizeLobbyId(input: string): string | null {
@@ -403,16 +422,19 @@ export class JoinPrivateLobbyModal extends BaseModal {
}
private extractLobbyIdFromUrl(input: string): string {
if (input.startsWith("http")) {
if (input.includes("#join=")) {
const params = new URLSearchParams(input.split("#")[1]);
return params.get("join") ?? input;
} else if (input.includes("join/")) {
return input.split("join/")[1];
} else {
return input;
}
} else {
if (!input.startsWith("http")) {
return input;
}
try {
const url = new URL(input);
const match = url.pathname.match(/game\/([^/]+)/);
const candidate = match?.[1];
if (candidate && GAME_ID_REGEX.test(candidate)) return candidate;
return input;
} catch (error) {
console.warn("Failed to parse lobby URL", error);
return input;
}
}
@@ -502,6 +524,9 @@ export class JoinPrivateLobbyModal extends BaseModal {
this.message = "";
this.hasJoined = true;
// If the modal closes as part of joining the game, do not leave the lobby
this.leaveLobbyOnClose = false;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
+36 -14
View File
@@ -1,7 +1,7 @@
import version from "resources/version.txt?raw";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
@@ -193,6 +193,7 @@ declare global {
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"kick-player": CustomEvent;
"join-changed": CustomEvent;
}
}
@@ -607,6 +608,7 @@ class Client {
onHashUpdate();
});
window.addEventListener("hashchange", onHashUpdate);
window.addEventListener("join-changed", onHashUpdate);
function updateSliderProgress(slider: HTMLInputElement) {
const percent =
@@ -632,7 +634,7 @@ class Client {
// Check if CrazyGames SDK is enabled first (no hash needed in CrazyGames)
if (crazyGamesSDK.isOnCrazyGames()) {
const lobbyId = crazyGamesSDK.getInviteGameId();
if (lobbyId && ID.safeParse(lobbyId).success) {
if (lobbyId && GAME_ID_REGEX.test(lobbyId)) {
window.showPage?.("page-join-private-lobby");
this.joinModal?.open(lobbyId);
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
@@ -708,14 +710,16 @@ class Client {
return;
}
// Fallback to hash-based join for non-CrazyGames environments
if (decodedHash.startsWith("#join=")) {
const lobbyId = decodedHash.substring(6); // Remove "#join="
if (lobbyId && ID.safeParse(lobbyId).success) {
window.showPage?.("page-join-private-lobby");
this.joinModal?.open(lobbyId);
console.log(`joining lobby ${lobbyId}`);
}
const pathMatch = window.location.pathname.match(
/^\/(?:w\d+\/)?game\/([^/]+)/,
);
const lobbyId =
pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null;
if (lobbyId) {
window.showPage?.("page-join-private-lobby");
this.joinModal.open(lobbyId);
console.log(`joining lobby ${lobbyId}`);
return;
}
if (decodedHash.startsWith("#affiliate=")) {
const affiliateCode = decodedHash.replace("#affiliate=", "");
@@ -738,6 +742,7 @@ class Client {
document.body.classList.remove("in-game");
}
const config = await getServerConfigFromClient();
this.updateJoinUrlForShare(lobby.gameID, config);
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
@@ -778,15 +783,16 @@ class Client {
"host-lobby-modal",
"join-private-lobby-modal",
"game-starting-modal",
"game-top-bar",
"help-modal",
"user-setting",
"territory-patterns-modal",
"language-modal",
"news-modal",
"flag-input-modal",
"account-button",
"stats-button",
"token-login",
"matchmaking-modal",
"lang-selector",
].forEach((tag) => {
@@ -817,7 +823,7 @@ class Client {
this.gutterAds.hide();
},
() => {
this.joinModal?.close();
this.joinModal.close();
this.publicLobby.stop();
incrementGamesPlayed();
@@ -833,11 +839,27 @@ class Client {
if (window.location.hash === "" || window.location.hash === "#") {
history.replaceState(null, "", window.location.origin + "#refresh");
}
history.pushState(null, "", `#join=${lobby.gameID}`);
history.pushState(
null,
"",
`/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
);
},
);
}
private updateJoinUrlForShare(
lobbyId: string,
config: Awaited<ReturnType<typeof getServerConfigFromClient>>,
) {
const targetUrl = `/${config.workerPath(lobbyId)}/game/${lobbyId}`;
const currentUrl = window.location.pathname;
if (currentUrl !== targetUrl) {
history.replaceState(null, "", targetUrl);
}
}
private async handleLeaveLobby(/* event: CustomEvent */) {
if (this.gameStop === null) {
return;
@@ -73,7 +73,7 @@ export class OModal extends LitElement {
? html`
<aside
class="${backdropClass}"
@click=${this.inline ? null : this.close}
@click=${this.inline ? null : () => this.close()}
>
<div
@click=${(e: Event) => e.stopPropagation()}
@@ -83,7 +83,7 @@ export class OModal extends LitElement {
? html``
: html`<div
class="absolute top-4 right-4 z-10 text-white cursor-pointer"
@click=${this.close}
@click=${() => this.close()}
>
</div>`}
+2
View File
@@ -302,6 +302,7 @@ export class WinModal extends LitElement implements Layer {
});
this.isWin = false;
}
history.replaceState(null, "", `${window.location.pathname}?replay`);
this.show();
} else if (wu.winner[0] === "nation") {
this._title = translateText("win_modal.nation_won", {
@@ -331,6 +332,7 @@ export class WinModal extends LitElement implements Layer {
});
this.isWin = false;
}
history.replaceState(null, "", `${window.location.pathname}?replay`);
this.show();
}
});