diff --git a/index.html b/index.html index 50f2802c9..b206e5a12 100644 --- a/index.html +++ b/index.html @@ -267,6 +267,7 @@ + diff --git a/src/client/Api.ts b/src/client/Api.ts index 9456f88db..2c85544c5 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -47,34 +47,42 @@ export async function fetchPlayerById( return false; } } -export async function getUserMe(): Promise { - try { - const userAuthResult = await userAuth(); - if (!userAuthResult) return false; - const { jwt } = userAuthResult; - // Get the user object - const response = await fetch(getApiBase() + "/users/@me", { - headers: { - authorization: `Bearer ${jwt}`, - }, - }); - if (response.status === 401) { - await logOut(); - return false; - } - if (response.status !== 200) return false; - const body = await response.json(); - const result = UserMeResponseSchema.safeParse(body); - if (!result.success) { - const error = z.prettifyError(result.error); - console.error("Invalid response", error); - return false; - } - return result.data; - } catch (e) { - return false; +let __userMe: Promise | null = null; +export async function getUserMe(): Promise { + if (__userMe !== null) { + return __userMe; } + __userMe = (async () => { + try { + const userAuthResult = await userAuth(); + if (!userAuthResult) return false; + const { jwt } = userAuthResult; + + // Get the user object + const response = await fetch(getApiBase() + "/users/@me", { + headers: { + authorization: `Bearer ${jwt}`, + }, + }); + if (response.status === 401) { + await logOut(); + return false; + } + if (response.status !== 200) return false; + const body = await response.json(); + const result = UserMeResponseSchema.safeParse(body); + if (!result.success) { + const error = z.prettifyError(result.error); + console.error("Invalid response", error); + return false; + } + return result.data; + } catch (e) { + return false; + } + })(); + return __userMe; } export async function createCheckoutSession( diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index d1f00e88d..0f368175c 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -30,6 +30,18 @@ export async function handlePurchase( } let __cosmetics: Promise | null = null; +let __cosmeticsHash: string | null = null; + +function simpleHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return hash.toString(36); +} + export async function fetchCosmetics(): Promise { if (__cosmetics !== null) { return __cosmetics; @@ -46,6 +58,11 @@ export async function fetchCosmetics(): Promise { console.error(`Invalid cosmetics: ${result.error.message}`); return null; } + const patternKeys = Object.keys(result.data.patterns).sort(); + const hashInput = patternKeys + .map((k) => k + (result.data.patterns[k].product ? "sale" : "")) + .join(","); + __cosmeticsHash = simpleHash(hashInput); return result.data; } catch (error) { console.error("Error getting cosmetics:", error); @@ -55,6 +72,11 @@ export async function fetchCosmetics(): Promise { return __cosmetics; } +export async function getCosmeticsHash(): Promise { + await fetchCosmetics(); + return __cosmeticsHash; +} + export function patternRelationship( pattern: Pattern, colorPalette: { name: string; isArchived?: boolean } | null, diff --git a/src/client/Main.ts b/src/client/Main.ts index 2b742e278..8774f9b54 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -178,6 +178,24 @@ declare global { slots?: any; }; spaNewPage: (url?: string) => void; + // Video ad methods + onPlayerReady: (() => void) | null; + addUnits: (units: Array<{ type: string }>) => Promise; + displayUnits: () => void; + }; + Bolt: { + on: (unitType: string, event: string, callback: () => void) => void; + BOLT_AD_REQUEST_START: string; + BOLT_AD_IMPRESSION: string; + BOLT_AD_STARTED: string; + BOLT_FIRST_QUARTILE: string; + BOLT_MIDPOINT: string; + BOLT_THIRD_QUARTILE: string; + BOLT_AD_COMPLETE: string; + BOLT_AD_ERROR: string; + BOLT_AD_PAUSED: string; + BOLT_AD_CLICKED: string; + SHOW_HIDDEN_CONTAINER: string; }; showPage?: (pageId: string) => void; } diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index e495ef131..c29931896 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -15,24 +15,15 @@ import { translateText } from "./Utils"; @customElement("matchmaking-modal") export class MatchmakingModal extends BaseModal { private gameCheckInterval: ReturnType | null = null; + private connectTimeout: ReturnType | null = null; @state() private connected = false; @state() private socket: WebSocket | null = null; @state() private gameID: string | null = null; - private elo = "unknown"; + private elo: number | "unknown" = "unknown"; constructor() { super(); this.id = "page-matchmaking"; - document.addEventListener("userMeResponse", (event: Event) => { - const customEvent = event as CustomEvent; - if (customEvent.detail) { - const userMeResponse = customEvent.detail as UserMeResponse; - this.elo = - userMeResponse.player?.leaderboard?.oneVone?.elo?.toString() ?? - "unknown"; - this.requestUpdate(); - } - }); } createRenderRoot() { @@ -125,18 +116,24 @@ export class MatchmakingModal extends BaseModal { ); this.socket.onopen = async () => { console.log("Connected to matchmaking server"); - setTimeout(() => { + 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(); - }, 1000); - this.socket?.send( - JSON.stringify({ - type: "join", - jwt: await getPlayToken(), - }), - ); + }, 2000); }; this.socket.onmessage = (event) => { console.log(event.data); @@ -145,6 +142,7 @@ export class MatchmakingModal extends BaseModal { 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: ErrorEvent) => { @@ -157,7 +155,6 @@ export class MatchmakingModal extends BaseModal { protected async onOpen(): Promise { const userMe = await getUserMe(); - // Early return if modal was closed during async operation if (!this.isModalOpen) { return; @@ -180,15 +177,21 @@ export class MatchmakingModal extends BaseModal { this.close(); return; } + + this.elo = userMe.player.leaderboard?.oneVone?.elo ?? "unknown"; + this.connected = false; this.gameID = null; this.connect(); - this.gameCheckInterval = setInterval(() => this.checkGame(), 1000); } 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; @@ -263,7 +266,7 @@ export class MatchmakingButton extends LitElement { } render() { - const button = this.isLoggedIn + return this.isLoggedIn ? html` + ` : html` `; - - return html` ${button} `; } private handleLoggedInClick() { diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index db44e5485..0f7d7b2e4 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -25,6 +25,16 @@ export abstract class BaseModal extends LitElement { return this; } + protected firstUpdated(): void { + if (this.modalEl) { + this.modalEl.onClose = () => { + if (this.isModalOpen) { + this.close(); + } + }; + } + } + disconnectedCallback() { this.unregisterEscapeHandler(); super.disconnectedCallback(); diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 1e13f87c1..6fbe8d398 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -1,12 +1,15 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { getCosmeticsHash } from "../Cosmetics"; import { getGamesPlayed } from "../Utils"; const HELP_SEEN_KEY = "helpSeen"; +const STORE_SEEN_HASH_KEY = "storeSeenHash"; @customElement("desktop-nav-bar") export class DesktopNavBar extends LitElement { @state() private _helpSeen = localStorage.getItem(HELP_SEEN_KEY) === "true"; + @state() private _hasNewCosmetics = false; createRenderRoot() { return this; @@ -23,6 +26,12 @@ export class DesktopNavBar extends LitElement { this._updateActiveState(current); }); } + + // Check if cosmetics have changed + getCosmeticsHash().then((hash: string | null) => { + const seenHash = localStorage.getItem(STORE_SEEN_HASH_KEY); + this._hasNewCosmetics = hash !== null && hash !== seenHash; + }); } disconnectedCallback() { @@ -46,14 +55,29 @@ export class DesktopNavBar extends LitElement { } private showHelpDot(): boolean { + // Only show one dot at a time to prevent + // overwhelming users. return getGamesPlayed() < 10 && !this._helpSeen; } + private showStoreDot(): boolean { + return this._hasNewCosmetics && !this.showHelpDot(); + } + private onHelpClick = () => { localStorage.setItem(HELP_SEEN_KEY, "true"); this._helpSeen = true; }; + private onStoreClick = () => { + this._hasNewCosmetics = false; + getCosmeticsHash().then((hash: string | null) => { + if (hash !== null) { + localStorage.setItem(STORE_SEEN_HASH_KEY, hash); + } + }); + }; + render() { return html`