diff --git a/resources/images/ShareIconWhite.svg b/resources/images/ShareIconWhite.svg new file mode 100644 index 00000000000..e6b8578c012 --- /dev/null +++ b/resources/images/ShareIconWhite.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index e468782b407..c60838b48ce 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1008,6 +1008,9 @@ "hvn": "Humans vs Nations", "teams": "Teams" }, + "game_right_sidebar": { + "share_link": "Share Link" + }, "game_settings": { "bots": "Tribes: ", "compact_map": "Compact Map", diff --git a/src/client/hud/layers/GameRightSidebar.ts b/src/client/hud/layers/GameRightSidebar.ts index 4447fd6569c..ad2b92d0240 100644 --- a/src/client/hud/layers/GameRightSidebar.ts +++ b/src/client/hud/layers/GameRightSidebar.ts @@ -13,7 +13,12 @@ import { gamePortal } from "../../GamePortal"; import { showInGameAlert, showInGameConfirm } from "../../InGameModal"; import { TogglePauseIntentEvent } from "../../InputHandler"; import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; -import { homeHref, showToast, translateText } from "../../Utils"; +import { + copyToClipboard, + homeHref, + showToast, + translateText, +} from "../../Utils"; import { GameView } from "../../view"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; import { ShowReplayPanelEvent } from "./ReplayPanel"; @@ -25,6 +30,7 @@ const pauseIcon = assetUrl("images/PauseIconWhite.svg"); const playIcon = assetUrl("images/PlayIconWhite.svg"); const newLobbyIcon = assetUrl("images/ReplayRegularIconWhite.svg"); const settingsIcon = assetUrl("images/SettingIconWhite.svg"); +const shareIcon = assetUrl("images/ShareIconWhite.svg"); const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg"); const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg"); @@ -288,6 +294,34 @@ export class GameRightSidebar extends LitElement implements Controller { ); } + private isShareable(): boolean { + return Boolean(this.game?.gameID?.()); + } + + private async onShareButtonClick() { + const gameId = this.game?.gameID?.(); + if (!gameId) { + showToast(translateText("common.failed_copy"), "red"); + return; + } + + try { + const text = gamePortal.isOnGamePortal() + ? await gamePortal.createInviteLink(gameId) + : `${ClientEnv.shareOrigin()}${ClientEnv.gamePath(gameId)}`; + + if (!text) { + showToast(translateText("common.failed_copy"), "red"); + return; + } + + await copyToClipboard(text); + showToast(translateText("common.copied"), "green"); + } catch { + showToast(translateText("common.failed_copy"), "red"); + } + } + private onFullscreenButtonClick() { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch((err) => { @@ -380,6 +414,25 @@ export class GameRightSidebar extends LitElement implements Controller { settings + ${ + this.isShareable() + ? html`` + : "" + } ${ document.fullscreenEnabled && !this.hideFullscreenButton ? html`
{ function fullscreenButton(el: TestSidebar): HTMLImageElement | undefined { const images = [ ...el.renderRoot.querySelectorAll("img"), - ].filter((img) => img.alt !== "settings" && img.alt !== "exit"); + ].filter((img) => img.src.includes("Fullscreen")); return images[0]; } diff --git a/tests/client/GameRightSidebar.share.test.ts b/tests/client/GameRightSidebar.share.test.ts new file mode 100644 index 00000000000..7e4402e42aa --- /dev/null +++ b/tests/client/GameRightSidebar.share.test.ts @@ -0,0 +1,331 @@ +import { GameType } from "@openfront/engine-api/game/GameTypes"; +import { GAME_ID_REGEX } from "@openfront/engine-api/Schemas"; +import { EventBus } from "@openfront/shared/EventBus"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const copyToClipboardMock = vi.hoisted(() => + vi.fn(async (_text: string) => {}), +); +const showToastMock = vi.hoisted(() => vi.fn()); + +const gamePortalMock = vi.hoisted(() => ({ + gameplayStop: vi.fn(), + gameplayStart: vi.fn(), + requestMidgameAd: vi.fn(), + isInstantMultiplayer: vi.fn(async () => false), + isOnGamePortal: vi.fn(() => false), + createInviteLink: vi.fn( + async (gameId: string): Promise => + `https://crazygames.com/game?invite=${gameId}`, + ), +})); + +vi.mock("../../src/client/Utils", async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: copyToClipboardMock, + showToast: showToastMock, + translateText: vi.fn((key: string) => { + const translations: Record = { + "game_right_sidebar.share_link": "Share Link", + "common.copied": "Copied!", + "common.failed_copy": "Failed to copy", + "fullscreen.exit": "Exit Fullscreen", + "fullscreen.enter": "Enter Fullscreen", + }; + return translations[key] || key; + }), +})); + +vi.mock("../../src/client/GamePortal", () => ({ + gamePortal: gamePortalMock, +})); + +import { ClientEnv } from "../../src/client/ClientEnv"; +import "../../src/client/hud/layers/GameRightSidebar"; +import type { GameRightSidebar } from "../../src/client/hud/layers/GameRightSidebar"; +import type { GameView } from "../../src/client/view"; + +const realLocationDescriptor = Object.getOwnPropertyDescriptor( + window, + "location", +)!; + +type TestSidebar = GameRightSidebar & { updateComplete: Promise }; + +interface CreateGameOpts { + gameId?: string; + gameType?: GameType; + isReplay?: boolean; +} + +function fakeGame(opts: CreateGameOpts = {}): GameView { + const gameType = opts.gameType ?? GameType.Public; + const isReplay = opts.isReplay ?? false; + const gameId = opts.gameId ?? "gamexyz123"; + + return { + config: () => ({ + gameConfig: () => ({ gameType, maxTimerValue: undefined }), + doomsdayClockConfig: () => ({ enabled: false }), + overtimeConfig: () => ({ enabled: false, startMinutes: 0 }), + isReplay: () => isReplay, + listed: true, + numSpawnPhaseTurns: () => 400, + }), + inSpawnPhase: () => false, + elapsedGameSeconds: () => 0, + myPlayer: () => undefined, + gameID: () => gameId, + resumedFromSave: () => false, + } as unknown as GameView; +} + +describe("GameRightSidebar share button", () => { + let sidebar: TestSidebar | undefined; + + function stubLocation(href: string) { + const url = new URL(href); + const origin = + url.protocol === "app:" ? `${url.protocol}//${url.hostname}` : url.origin; + Object.defineProperty(window, "location", { + configurable: true, + value: { + href, + origin, + protocol: url.protocol, + pathname: url.pathname, + hash: url.hash, + search: url.search, + host: url.host, + hostname: url.hostname, + }, + }); + } + + function setBootstrapConfig( + overrides: { jwtAudience?: string; serverHost?: string } = {}, + ) { + (window as any).BOOTSTRAP_CONFIG = { + gameEnv: "prod", + numWorkers: 1, + turnstileSiteKey: "x", + jwtAudience: "openfront.io", + instanceId: "d", + gitCommit: "t", + ...overrides, + }; + ClientEnv.reset(); + } + + beforeEach(() => { + stubLocation("https://openfront.io/"); + setBootstrapConfig({ serverHost: "openfront.io" }); + copyToClipboardMock.mockClear(); + showToastMock.mockClear(); + gamePortalMock.isOnGamePortal.mockReturnValue(false); + gamePortalMock.createInviteLink.mockClear(); + }); + + afterEach(() => { + sidebar?.remove(); + sidebar = undefined; + Object.defineProperty(window, "location", realLocationDescriptor); + delete (window as { BOOTSTRAP_CONFIG?: unknown }).BOOTSTRAP_CONFIG; + ClientEnv.reset(); + vi.restoreAllMocks(); + }); + + async function mount(opts: CreateGameOpts = {}): Promise { + sidebar = document.createElement("game-right-sidebar") as TestSidebar; + sidebar.game = fakeGame(opts); + sidebar.eventBus = new EventBus(); + document.body.appendChild(sidebar); + sidebar.init(); + await sidebar.updateComplete; + return sidebar; + } + + function getShareButton(el: TestSidebar): HTMLElement | null { + const img = el.renderRoot.querySelector( + 'img[src*="ShareIconWhite"]', + ); + return img ? (img.parentElement as HTMLElement) : null; + } + + it("renders a 20x20 share button between settings and exit in multiplayer games", async () => { + const el = await mount({ gameType: GameType.Public }); + const shareBtn = getShareButton(el); + + expect(shareBtn).not.toBeNull(); + expect(shareBtn?.tagName.toLowerCase()).toBe("button"); + expect(shareBtn?.classList.contains("cursor-pointer")).toBe(true); + expect(shareBtn?.getAttribute("title")).toBe("Share Link"); + expect(shareBtn?.getAttribute("aria-label")).toBe("Share Link"); + + const img = shareBtn?.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("alt")).toBe(""); + expect(img?.getAttribute("aria-hidden")).toBe("true"); + expect(img?.getAttribute("width")).toBe("20"); + expect(img?.getAttribute("height")).toBe("20"); + + // Verify ordering: settings button -> share button -> exit button + const container = el.renderRoot.querySelector("aside"); + expect(container).not.toBeNull(); + const buttons = Array.from( + container!.querySelectorAll(".cursor-pointer"), + ); + const settingsIdx = buttons.findIndex((b) => + b.querySelector('img[alt="settings"]'), + ); + const shareIdx = buttons.findIndex( + (b) => + b.tagName.toLowerCase() === "button" && + b.querySelector('img[src*="ShareIconWhite"]'), + ); + const exitIdx = buttons.findIndex((b) => + b.querySelector('img[alt="exit"]'), + ); + + expect(settingsIdx).toBeGreaterThanOrEqual(0); + expect(shareIdx).toBeGreaterThan(settingsIdx); + expect(exitIdx).toBeGreaterThan(shareIdx); + }); + + it("copies working game link using ClientEnv.shareOrigin and shows green toast", async () => { + const gameId = "game123456"; + expect(GAME_ID_REGEX.test(gameId)).toBe(true); + + const el = await mount({ gameId }); + const shareBtn = getShareButton(el); + expect(shareBtn).not.toBeNull(); + + shareBtn!.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const expectedUrl = `${ClientEnv.shareOrigin()}${ClientEnv.gamePath(gameId)}`; + expect(copyToClipboardMock).toHaveBeenCalledWith(expectedUrl); + expect(showToastMock).toHaveBeenCalledWith("Copied!", "green"); + + const url = new URL(expectedUrl); + const pathMatch = url.pathname.match( + /^(?:\/v\/[^/]+)?\/(?:w\d+\/)?game\/([^/]+)/, + ); + expect(pathMatch).not.toBeNull(); + expect(pathMatch![1]).toBe(gameId); + expect(GAME_ID_REGEX.test(pathMatch![1])).toBe(true); + }); + + it("uses share origin from desktop shell instead of app://", async () => { + stubLocation("app://openfront/index.html"); + setBootstrapConfig({ serverHost: "openfront.io" }); + + const gameId = "desk456789"; + expect(GAME_ID_REGEX.test(gameId)).toBe(true); + + const el = await mount({ gameId }); + const shareBtn = getShareButton(el); + expect(shareBtn).not.toBeNull(); + + shareBtn!.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(copyToClipboardMock).toHaveBeenCalledTimes(1); + const copiedUrl = copyToClipboardMock.mock.calls[0][0]; + expect(copiedUrl).toBe(`https://openfront.io${ClientEnv.gamePath(gameId)}`); + expect(copiedUrl).not.toContain("app:"); + expect(showToastMock).toHaveBeenCalledWith("Copied!", "green"); + + const url = new URL(copiedUrl); + const pathMatch = url.pathname.match( + /^(?:\/v\/[^/]+)?\/(?:w\d+\/)?game\/([^/]+)/, + ); + expect(pathMatch).not.toBeNull(); + expect(pathMatch![1]).toBe(gameId); + expect(GAME_ID_REGEX.test(pathMatch![1])).toBe(true); + }); + + it("uses gamePortal.createInviteLink on game portals", async () => { + const gameId = "cg99912345"; + expect(GAME_ID_REGEX.test(gameId)).toBe(true); + + gamePortalMock.isOnGamePortal.mockReturnValue(true); + gamePortalMock.createInviteLink.mockResolvedValue( + `https://crazygames.com/game?invite=${gameId}`, + ); + + const el = await mount({ gameId }); + const shareBtn = getShareButton(el); + expect(shareBtn).not.toBeNull(); + + shareBtn!.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(gamePortalMock.createInviteLink).toHaveBeenCalledWith(gameId); + expect(copyToClipboardMock).toHaveBeenCalledWith( + `https://crazygames.com/game?invite=${gameId}`, + ); + expect(showToastMock).toHaveBeenCalledWith("Copied!", "green"); + }); + + it("shows failure toast when game portal invite link fails", async () => { + const gameId = "cgfail1234"; + gamePortalMock.isOnGamePortal.mockReturnValue(true); + gamePortalMock.createInviteLink.mockResolvedValue(null); + + const el = await mount({ gameId }); + const shareBtn = getShareButton(el); + expect(shareBtn).not.toBeNull(); + + shareBtn!.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(copyToClipboardMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith("Failed to copy", "red"); + }); + + it("shows failure toast when game ID is empty", async () => { + const el = await mount({ gameId: "" }); + const shareBtn = getShareButton(el); + // Since gameId is empty, button should not even be rendered + expect(shareBtn).toBeNull(); + }); + + it("shows failure toast when copyToClipboard throws", async () => { + copyToClipboardMock.mockRejectedValueOnce(new Error("clipboard denied")); + + const gameId = "gamefail12"; + const el = await mount({ gameId }); + const shareBtn = getShareButton(el); + expect(shareBtn).not.toBeNull(); + + shareBtn!.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(copyToClipboardMock).toHaveBeenCalledTimes(1); + expect(showToastMock).toHaveBeenCalledWith("Failed to copy", "red"); + }); + + it("shows failure toast when link generation throws", async () => { + vi.spyOn(ClientEnv, "gamePath").mockImplementationOnce(() => { + throw new Error("missing config"); + }); + + const gameId = "gameerr123"; + const el = await mount({ gameId }); + const shareBtn = getShareButton(el); + expect(shareBtn).not.toBeNull(); + + shareBtn!.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(copyToClipboardMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith("Failed to copy", "red"); + }); + + it("renders the share button in singleplayer games", async () => { + const el = await mount({ gameType: GameType.Singleplayer }); + const shareBtn = getShareButton(el); + expect(shareBtn).not.toBeNull(); + }); +});