From 9937cca8f8884898919cbe90618acf09f4be1da4 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Thu, 18 Sep 2025 12:37:32 -0500 Subject: [PATCH] allow up to 7 usernames in panel before P02 --- src/ui/handlers/login-form-ui-handler.ts | 361 +++++++++++++---------- 1 file changed, 205 insertions(+), 156 deletions(-) diff --git a/src/ui/handlers/login-form-ui-handler.ts b/src/ui/handlers/login-form-ui-handler.ts index 44c5b93131f..034023d5789 100644 --- a/src/ui/handlers/login-form-ui-handler.ts +++ b/src/ui/handlers/login-form-ui-handler.ts @@ -20,6 +20,18 @@ interface BuildInteractableImageOpts { origin?: { x: number; y: number }; } +/** + * The maximum number of saves that are allowed to show up in the username panel pefore + * the `P02: Too many saves` popup is displayed. + * + * @privateRemarks + * This limitation is in place to allow for the password reset helpers to get + * enough information in one screenshot. If the user has too many saves, this + * complicates the interaction as it would require scrolling, which will + * make tickets take longer to resolve. + */ +const MAX_SAVES_FOR_USERNAME_PANEL = 7; + export class LoginFormUiHandler extends FormModalUiHandler { private readonly ERR_USERNAME: string = "invalid username"; private readonly ERR_PASSWORD: string = "invalid password"; @@ -54,21 +66,16 @@ export class LoginFormUiHandler extends FormModalUiHandler { new Phaser.Geom.Rectangle(0, 0, globalScene.scaledCanvas.width / 2, globalScene.scaledCanvas.height / 2), Phaser.Geom.Rectangle.Contains, ); - this.externalPartyTitle = addTextObject(0, 4, "", TextStyle.SETTINGS_LABEL); - this.externalPartyTitle.setOrigin(0.5, 0); + this.externalPartyTitle = addTextObject(0, 4, "", TextStyle.SETTINGS_LABEL).setOrigin(0.5, 0); this.externalPartyBg = addWindow(0, 0, 0, 0); - this.externalPartyContainer.add(this.externalPartyBg); - this.externalPartyContainer.add(this.externalPartyTitle); this.googleImage = this.buildInteractableImage("google", "google-icon"); this.discordImage = this.buildInteractableImage("discord", "discord-icon"); - this.externalPartyContainer.add(this.googleImage); - this.externalPartyContainer.add(this.discordImage); + this.externalPartyContainer + .add([this.externalPartyBg, this.externalPartyTitle, this.googleImage, this.discordImage]) + .setVisible(false); this.getUi().add(this.externalPartyContainer); - this.externalPartyContainer.add(this.googleImage); - this.externalPartyContainer.add(this.discordImage); - this.externalPartyContainer.setVisible(false); } private buildInfoContainer() { @@ -89,12 +96,11 @@ export class LoginFormUiHandler extends FormModalUiHandler { scale: 0.5, }); - this.infoContainer.add(this.usernameInfoImage); - this.infoContainer.add(this.saveDownloadImage); - this.infoContainer.add(this.changeLanguageImage); + this.infoContainer + .add([this.usernameInfoImage, this.saveDownloadImage, this.changeLanguageImage]) + .setVisible(false) + .disableInteractive(); this.getUi().add(this.infoContainer); - this.infoContainer.setVisible(false); - this.infoContainer.disableInteractive(); } override getModalTitle(_config?: ModalConfig): string { @@ -142,60 +148,61 @@ export class LoginFormUiHandler extends FormModalUiHandler { override getInputFieldConfigs(): InputFieldConfig[] { const inputFieldConfigs: InputFieldConfig[] = []; - inputFieldConfigs.push({ label: i18next.t("menu:username") }); - inputFieldConfigs.push({ - label: i18next.t("menu:password"), - isPassword: true, - }); + inputFieldConfigs.push( + { label: i18next.t("menu:username") }, + { + label: i18next.t("menu:password"), + isPassword: true, + }, + ); return inputFieldConfigs; } override show(args: any[]): boolean { - if (super.show(args)) { - const config = args[0] as ModalConfig; - this.processExternalProvider(config); - const originalLoginAction = this.submitAction; - this.submitAction = _ => { - if (globalScene.tweens.getTweensOf(this.modalContainer).length === 0) { - // Prevent overlapping overrides on action modification - this.submitAction = originalLoginAction; - this.sanitizeInputs(); - globalScene.ui.setMode(UiMode.LOADING, { buttonActions: [] }); - const onFail = error => { - globalScene.ui.setMode(UiMode.LOGIN_FORM, Object.assign(config, { errorMessage: error?.trim() })); - globalScene.ui.playError(); - }; - if (!this.inputs[0].text) { - return onFail(i18next.t("menu:emptyUsername")); - } - - const [usernameInput, passwordInput] = this.inputs; - - pokerogueApi.account - .login({ - username: usernameInput.text, - password: passwordInput.text, - }) - .then(error => { - if (!error && originalLoginAction) { - originalLoginAction(); - } else { - onFail(error); - } - }); - } - }; - - return true; + if (!super.show(args)) { + return false; } + const config = args[0] as ModalConfig; + this.processExternalProvider(config); + const originalLoginAction = this.submitAction; + this.submitAction = _ => { + if (globalScene.tweens.getTweensOf(this.modalContainer).length === 0) { + // Prevent overlapping overrides on action modification + this.submitAction = originalLoginAction; + this.sanitizeInputs(); + globalScene.ui.setMode(UiMode.LOADING, { buttonActions: [] }); + const onFail = error => { + globalScene.ui.setMode(UiMode.LOGIN_FORM, Object.assign(config, { errorMessage: error?.trim() })); + globalScene.ui.playError(); + }; + if (!this.inputs[0].text) { + return onFail(i18next.t("menu:emptyUsername")); + } - return false; + const [usernameInput, passwordInput] = this.inputs; + + pokerogueApi.account + .login({ + username: usernameInput.text, + password: passwordInput.text, + }) + .then(error => { + if (!error && originalLoginAction) { + originalLoginAction(); + } else { + onFail(error); + } + }); + } + }; + + return true; } override clear() { super.clear(); - this.externalPartyContainer.setVisible(false); - this.infoContainer.setVisible(false); + this.externalPartyContainer.setVisible(false).setActive(false); + this.infoContainer.setVisible(false).setActive(false); this.setMouseCursorStyle("default"); //reset cursor [ @@ -204,109 +211,150 @@ export class LoginFormUiHandler extends FormModalUiHandler { this.usernameInfoImage, this.saveDownloadImage, this.changeLanguageImage, - ].forEach(img => img.off("pointerdown")); + ].forEach(img => { + img.off("pointerdown"); + }); + } + + override destroy() { + super.destroy(); + this.externalPartyContainer.destroy(); + this.infoContainer.destroy(); + } + + /** + * Show a panel with all usernames found in localStorage + * + * @remarks + * Up to {@linkcode MAX_SAVES_FOR_USERNAME_PANEL} usernames are shown, otherwise P02 is triggered + * @param onFail - Callback function for failure + */ + private showUsernames(config: ModalConfig) { + if (globalScene.tweens.getTweensOf(this.infoContainer).length === 0) { + const localStorageKeys = Object.keys(localStorage); // this gets the keys for localStorage + const keyToFind = "data_"; + const dataKeys = localStorageKeys.filter(ls => ls.indexOf(keyToFind) >= 0); + if (dataKeys.length === 0) { + this.onFail(this.ERR_NO_SAVES, config); + return; + } + if (dataKeys.length > MAX_SAVES_FOR_USERNAME_PANEL) { + this.onFail(this.ERR_TOO_MANY_SAVES, config); + return; + } + const options: OptionSelectItem[] = []; + const handler = () => { + globalScene.ui.revertMode(); + this.infoContainer.disableInteractive(); + return true; + }; + for (const key of dataKeys) { + options.push({ + label: key.replace(keyToFind, ""), + handler, + }); + } + globalScene.ui.setOverlayMode(UiMode.OPTION_SELECT, { + options, + delay: 1000, + }); + this.infoContainer.setInteractive( + new Phaser.Geom.Rectangle(0, 0, globalScene.game.canvas.width, globalScene.game.canvas.height), + Phaser.Geom.Rectangle.Contains, + ); + } + } + + /** + * + */ + private onFail(error: string, config: ModalConfig) { + const ui = globalScene.ui; + ui.setMode(UiMode.LOADING, { buttonActions: [] }); + ui.setModeForceTransition(UiMode.LOGIN_FORM, Object.assign(config, { errorMessage: error?.trim() })); + ui.playError(); + } + + /** + * Collect the user's save files from localStorage and download them as a zip file + * + * @remarks + * Used as the `pointerDown` callback for the save download image + * @param config - The modal configuration + */ + private async downloadSaves(config: ModalConfig): Promise { + // find all data_ and sessionData keys, put them in a .txt file and download everything in a single zip + const localStorageKeys = Object.keys(localStorage); // this gets the keys for localStorage + const keyToFind = "data_"; + const sessionKeyToFind = "sessionData"; + const dataKeys = localStorageKeys.filter(ls => ls.indexOf(keyToFind) >= 0); + const sessionKeys = localStorageKeys.filter(ls => ls.indexOf(sessionKeyToFind) >= 0); + if (dataKeys.length <= 0 && sessionKeys.length <= 0) { + this.onFail(this.ERR_NO_SAVES, config); + return; + } + const zip = new JSZip(); + // Bang is safe here because of the filter above + for (const dataKey of dataKeys) { + zip.file(dataKey + ".prsv", localStorage.getItem(dataKey)!); + } + for (const sessionKey of sessionKeys) { + zip.file(sessionKey + ".prsv", localStorage.getItem(sessionKey)!); + } + const content = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(content); + const a = document.createElement("a"); + a.href = url; + a.download = "pokerogue_saves.zip"; + a.click(); + URL.revokeObjectURL(url); } private processExternalProvider(config: ModalConfig): void { - this.externalPartyTitle.setText(i18next.t("menu:orUse") ?? ""); - this.externalPartyTitle.setX(20 + this.externalPartyTitle.text.length); - this.externalPartyTitle.setVisible(true); - this.externalPartyContainer.setPositionRelative(this.modalContainer, 175, 0); - this.externalPartyContainer.setVisible(true); - this.externalPartyBg.setSize(this.externalPartyTitle.text.length + 50, this.modalBg.height); - this.getUi().moveTo(this.externalPartyContainer, this.getUi().length - 1); - this.googleImage.setPosition(this.externalPartyBg.width / 3.1, this.externalPartyBg.height - 60); - this.discordImage.setPosition(this.externalPartyBg.width / 3.1, this.externalPartyBg.height - 40); + this.externalPartyTitle + .setText(i18next.t("menu:orUse") ?? "") + .setX(20 + this.externalPartyTitle.text.length) + .setVisible(true); - this.infoContainer.setPosition(5, -76); - this.infoContainer.setVisible(true); - this.getUi().moveTo(this.infoContainer, this.getUi().length - 1); - this.usernameInfoImage.setPositionRelative(this.infoContainer, 0, 0); - this.saveDownloadImage.setPositionRelative(this.infoContainer, 20, 0); - this.changeLanguageImage.setPositionRelative(this.infoContainer, 40, 0); + const externalPartyContainer = this.externalPartyContainer + .setPositionRelative(this.modalContainer, 175, 0) + .setVisible(true); - this.discordImage.on("pointerdown", () => { - const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/discord/callback`); - const discordId = import.meta.env.VITE_DISCORD_CLIENT_ID; - const discordUrl = `https://discord.com/api/oauth2/authorize?client_id=${discordId}&redirect_uri=${redirectUri}&response_type=code&scope=identify&prompt=none`; - window.open(discordUrl, "_self"); - }); + const externalPartyBg = this.externalPartyBg.setSize(this.externalPartyTitle.text.length + 50, this.modalBg.height); + this.getUi().moveTo(externalPartyContainer, this.getUi().length - 1); - this.googleImage.on("pointerdown", () => { - const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/google/callback`); - const googleId = import.meta.env.VITE_GOOGLE_CLIENT_ID; - const googleUrl = `https://accounts.google.com/o/oauth2/auth?client_id=${googleId}&redirect_uri=${redirectUri}&response_type=code&scope=openid`; - window.open(googleUrl, "_self"); - }); + const externalPartyIconWidth = externalPartyBg.width / 3.1; + this.discordImage; + const infoContainer = this.infoContainer.setPosition(5, -76).setVisible(true); + this.getUi().moveTo(infoContainer, this.getUi().length - 1); - const onFail = error => { - globalScene.ui.setMode(UiMode.LOADING, { buttonActions: [] }); - globalScene.ui.setModeForceTransition(UiMode.LOGIN_FORM, Object.assign(config, { errorMessage: error?.trim() })); - globalScene.ui.playError(); - }; + this.discordImage // formatting + .setPosition(externalPartyIconWidth, externalPartyBg.height - 40) + .on("pointerdown", () => { + const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/discord/callback`); + const discordId = import.meta.env.VITE_DISCORD_CLIENT_ID; + const discordUrl = `https://discord.com/api/oauth2/authorize?client_id=${discordId}&redirect_uri=${redirectUri}&response_type=code&scope=identify&prompt=none`; + window.open(discordUrl, "_self"); + }); - this.usernameInfoImage.on("pointerdown", () => { - if (globalScene.tweens.getTweensOf(this.infoContainer).length === 0) { - const localStorageKeys = Object.keys(localStorage); // this gets the keys for localStorage - const keyToFind = "data_"; - const dataKeys = localStorageKeys.filter(ls => ls.indexOf(keyToFind) >= 0); - if (dataKeys.length > 0 && dataKeys.length <= 2) { - const options: OptionSelectItem[] = []; - for (const key of dataKeys) { - options.push({ - label: key.replace(keyToFind, ""), - handler: () => { - globalScene.ui.revertMode(); - this.infoContainer.disableInteractive(); - return true; - }, - }); - } - globalScene.ui.setOverlayMode(UiMode.OPTION_SELECT, { - options, - delay: 1000, - }); - this.infoContainer.setInteractive( - new Phaser.Geom.Rectangle(0, 0, globalScene.game.canvas.width, globalScene.game.canvas.height), - Phaser.Geom.Rectangle.Contains, - ); - } else { - if (dataKeys.length > 2) { - return onFail(this.ERR_TOO_MANY_SAVES); - } - return onFail(this.ERR_NO_SAVES); - } - } - }); + this.googleImage // formatting + .setPosition(externalPartyIconWidth, externalPartyBg.height - 60) + .on("pointerdown", () => { + const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/google/callback`); + const googleId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + const googleUrl = `https://accounts.google.com/o/oauth2/auth?client_id=${googleId}&redirect_uri=${redirectUri}&response_type=code&scope=openid`; + window.open(googleUrl, "_self"); + }); - this.saveDownloadImage.on("pointerdown", async () => { - // find all data_ and sessionData keys, put them in a .txt file and download everything in a single zip - const localStorageKeys = Object.keys(localStorage); // this gets the keys for localStorage - const keyToFind = "data_"; - const sessionKeyToFind = "sessionData"; - const dataKeys = localStorageKeys.filter(ls => ls.indexOf(keyToFind) >= 0); - const sessionKeys = localStorageKeys.filter(ls => ls.indexOf(sessionKeyToFind) >= 0); - if (dataKeys.length > 0 || sessionKeys.length > 0) { - const zip = new JSZip(); - for (const dataKey of dataKeys) { - zip.file(dataKey + ".prsv", localStorage.getItem(dataKey)!); - } - for (const sessionKey of sessionKeys) { - zip.file(sessionKey + ".prsv", localStorage.getItem(sessionKey)!); - } - const content = await zip.generateAsync({ type: "blob" }); - const url = URL.createObjectURL(content); - const a = document.createElement("a"); - a.href = url; - a.download = "pokerogue_saves.zip"; - a.click(); - URL.revokeObjectURL(url); - } else { - return onFail(this.ERR_NO_SAVES); - } - }); + this.usernameInfoImage // formatting + .setPositionRelative(infoContainer, 0, 0) + .on("pointerdown", () => this.showUsernames(config)); - this.changeLanguageImage.on("pointerdown", () => { + this.saveDownloadImage // formatting + .setPositionRelative(infoContainer, 20, 0) + .on("pointerdown", () => this.downloadSaves(config)); + + this.changeLanguageImage.setPositionRelative(infoContainer, 40, 0).on("pointerdown", () => { globalScene.ui.setOverlayMode(UiMode.OPTION_SELECT, { options: languageOptions, maxOptions: 7, @@ -335,11 +383,12 @@ export class LoginFormUiHandler extends FormModalUiHandler { private buildInteractableImage(texture: string, name: string, opts: BuildInteractableImageOpts = {}) { const { scale = 0.07, x = 0, y = 0, origin = { x: 0, y: 0 } } = opts; - const img = globalScene.add.image(x, y, texture); - img.setName(name); - img.setOrigin(origin.x, origin.y); - img.setScale(scale); - img.setInteractive(); + const img = globalScene.add + .image(x, y, texture) + .setName(name) + .setOrigin(origin.x, origin.y) + .setScale(scale) + .setInteractive(); this.addInteractionHoverEffect(img); return img;