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`