Repository: smogon/pokemon-showdown-client
Pull request #2369: Preact: Add more user preferences in options menu
Author: login: ISenseAura
Created: 2025-04-16T16:30:11Z | Merged: 2025-04-17T05:07:35Z

for the highlight stuff, i will redo it at the time of implementing the `/highlight `command.



Diff:
diff --git a/play.pokemonshowdown.com/src/battle-log.ts b/play.pokemonshowdown.com/src/battle-log.ts
index 348cb045974..24514542fb4 100644
--- a/play.pokemonshowdown.com/src/battle-log.ts
+++ b/play.pokemonshowdown.com/src/battle-log.ts
@@ -154,11 +154,16 @@ export class BattleLog {
 				}
 				timestampHtml = `<small class="gray">[${components.map(x => x < 10 ? `0${x}` : x).join(':')}] </small>`;
 			}
-			let isHighlighted = window.app?.rooms?.[battle!.roomid].getHighlight(message);
+			let isHighlighted = window.app?.rooms?.[battle!.roomid].getHighlight(message) || window.PS?.getHighlight(message);
 			[divClass, divHTML, noNotify] = this.parseChatMessage(message, name, timestampHtml, isHighlighted);
 			if (!noNotify && isHighlighted) {
-				let notifyTitle = "Mentioned by " + name + " in " + battle!.roomid;
-				app.rooms[battle!.roomid].notifyOnce(notifyTitle, "\"" + message + "\"", 'highlight');
+				let notifyTitle = "Mentioned by " + name + " in " + (battle?.roomid || '');
+				window.app?.rooms[battle?.roomid || '']?.notifyOnce(notifyTitle, "\"" + message + "\"", 'highlight');
+				window.PS?.rooms[battle?.roomid || '']?.notify({
+					title: notifyTitle,
+					body: "\"" + message + "\"",
+					id: 'highlight',
+				});
 			}
 			break;
 
@@ -234,7 +239,7 @@ export class BattleLog {
 			return;
 
 		case 'pm':
-			divHTML = '<strong>' + BattleLog.escapeHTML(args[1]) + ':</strong> <span class="message-pm"><i style="cursor:pointer" onclick="selectTab(\'lobby\');rooms.lobby.popupOpen(\'' + BattleLog.escapeHTML(args[2], true) + '\')">(Private to ' + BattleLog.escapeHTML(args[3]) + ')</i> ' + BattleLog.parseMessage(args[4]) + '</span>';
+			divHTML = `<strong data-href="user-${BattleLog.escapeHTML(args[1])}"> ${BattleLog.escapeHTML(args[1])}:</strong> <span class="message-pm"><i style="cursor:pointer" data-href="user-${BattleLog.escapeHTML(args[1], true)}">(Private to ${BattleLog.escapeHTML(args[2])})</i> ${BattleLog.parseMessage(args[3])} </span>`;
 			break;
 
 		case 'askreg':
diff --git a/play.pokemonshowdown.com/src/client-main.ts b/play.pokemonshowdown.com/src/client-main.ts
index 2b3f11a5428..8b8380d64aa 100644
--- a/play.pokemonshowdown.com/src/client-main.ts
+++ b/play.pokemonshowdown.com/src/client-main.ts
@@ -55,6 +55,22 @@ class PSPrefs extends PSStreamModel<string | null> {
 	 * null - Enable GIFs only on Chrome 64.
 	 */
 	nogif: boolean | null = null;
+
+	/* Graphics Preferences */
+	noanim: boolean | null = null;
+	bwgfx: boolean | null = null;
+	nopastgens: boolean | null = null;
+
+	/* Chat Preferences */
+	blockPMs: boolean | null = null;
+	blockChallenges: boolean | null = null;
+	inchatpm: boolean | null = null;
+	noselfhighlight: boolean | null = null;
+	temporarynotifications: boolean | null = null;
+	leavePopupRoom: boolean | null = null;
+	refreshprompt: boolean | null = null;
+	language: boolean | null = null;
+
 	/**
 	 * Show "User joined" and "User left" messages. serverid:roomid
 	 * table. Uses 1 and 0 instead of true/false for JSON packing
@@ -366,6 +382,7 @@ class PSUser extends PSStreamModel<PSLoginState | null> {
 	loggingIn: string | null = null;
 	initializing = true;
 	gapiLoaded = false;
+	nameRegExp: RegExp | null = null;
 	setName(fullName: string, named: boolean, avatar: string) {
 		const loggingIn = (!this.named && named);
 		const { name, group } = BattleTextParser.parseNameParts(fullName);
@@ -381,6 +398,7 @@ class PSUser extends PSStreamModel<PSLoginState | null> {
 				if (room.connectWhenLoggedIn) room.connect();
 			}
 		}
+		this.updateRegExp();
 	}
 	validateName(name: string): string {
 		// | , ; are not valid characters in names
@@ -427,6 +445,7 @@ class PSUser extends PSStreamModel<PSLoginState | null> {
 			'getassertion', { userid, challstr: this.challstr }
 		).then(res => {
 			this.handleAssertion(name, res);
+			this.updateRegExp();
 		});
 	}
 	changeNameWithPassword(name: string, password: string, special: PSLoginState = { needsPassword: true }) {
@@ -445,6 +464,7 @@ class PSUser extends PSStreamModel<PSLoginState | null> {
 			this.loggingIn = null;
 			if (data?.curuser?.loggedin) {
 				// success!
+				this.registered = true;
 				this.handleAssertion(name, data.assertion);
 			} else {
 				// wrong password
@@ -513,6 +533,24 @@ class PSUser extends PSStreamModel<PSLoginState | null> {
 		this.registered = false;
 		this.update(null);
 	}
+
+	updateRegExp() {
+		if (!this.named) {
+			this.nameRegExp = null;
+		} else {
+			let escaped = this.name.replace(/[^A-Za-z0-9]+$/, '');
+			// we'll use `,` as a sentinel character to mean "any non-alphanumeric char"
+			// unicode characters can be replaced with any non-alphanumeric char
+			for (let i = escaped.length - 1; i > 0; i--) {
+				if (/[^ -~]/.test(escaped[i])) {
+					escaped = escaped.slice(0, i) + ',' + escaped.slice(i + 1);
+				}
+			}
+			escaped = escaped.replace(/[[\]/{}()*+?.\\^$|-]/g, "\\$&");
+			escaped = escaped.replace(/,/g, "[^A-Za-z0-9]?");
+			this.nameRegExp = new RegExp('(?:\\b|(?!\\w))' + escaped + '(?:\\b|\\B(?!\\w))', 'i');
+		}
+	}
 }
 
 /**********************************************************************
@@ -1879,4 +1917,32 @@ export const PS = new class extends PSModel {
 			this.prefs.set('autojoin', autojoin);
 		}
 	}
+
+	getHighlight(message: string) {
+		if (!this.prefs.noselfhighlight && this.user.nameRegExp) {
+			if (this.user.nameRegExp.test(message)) return true;
+		}
+		/*
+		// TODO!
+		if (!this.highlightRegExp) {
+			try {
+				//this.updateHighlightRegExp(highlights);
+			} catch (e) {
+				// If the expression above is not a regexp, we'll get here.
+				// Don't throw an exception because that would prevent the chat
+				// message from showing up, or, when the lobby is initialising,
+				// it will prevent the initialisation from completing.
+				return false;
+			}
+		}
+		var id = PS.server.id + '#' + this.id;
+		var globalHighlightsRegExp = this.highlightRegExp['global'];
+		var roomHighlightsRegExp = this.highlightRegExp[id];
+
+		return (((globalHighlightsRegExp &&
+		 globalHighlightsRegExp.test(message)) ||
+		  (roomHighlightsRegExp && roomHighlightsRegExp.test(message))));
+		*/
+		return false;
+	}
 };
diff --git a/play.pokemonshowdown.com/src/panel-battle.tsx b/play.pokemonshowdown.com/src/panel-battle.tsx
index 8c8edfa8ec0..b58b34c86f2 100644
--- a/play.pokemonshowdown.com/src/panel-battle.tsx
+++ b/play.pokemonshowdown.com/src/panel-battle.tsx
@@ -36,6 +36,10 @@ export class BattlesRoom extends PSRoom {
 	constructor(options: RoomOptions) {
 		super(options);
 		this.refresh();
+		// If graphics preference is set to use BW sprites
+		if (PS.prefs.bwgfx) {
+			Dex.loadSpriteData('bw');
+		}
 	}
 	setFormat(format: string) {
 		if (format === this.format) return this.refresh();
@@ -299,6 +303,7 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
 			break;
 		}
 		room.battle.add('|' + args.join('|'));
+		if (PS.prefs.noanim) this.props.room.battle.seekTurn(Infinity);
 	}
 	receiveRequest(request: BattleRequest | null) {
 		const room = this.props.room;
diff --git a/play.pokemonshowdown.com/src/panel-mainmenu.tsx b/play.pokemonshowdown.com/src/panel-mainmenu.tsx
index aca66a539d8..40ff02020fc 100644
--- a/play.pokemonshowdown.com/src/panel-mainmenu.tsx
+++ b/play.pokemonshowdown.com/src/panel-mainmenu.tsx
@@ -124,6 +124,8 @@ export class MainMenuRoom extends PSRoom {
 		} case 'pm': {
 			const [, user1, user2, message] = args;
 			this.handlePM(user1, user2, message);
+			let sideRoom = PS.rightPanel as ChatRoom;
+			if (sideRoom?.type === "chat" && PS.prefs.inchatpm) sideRoom?.log?.add(args);
 			return;
 		} case 'formats': {
 			this.parseFormats(args);
diff --git a/play.pokemonshowdown.com/src/panel-popups.tsx b/play.pokemonshowdown.com/src/panel-popups.tsx
index 183ae454f27..6d70f05c551 100644
--- a/play.pokemonshowdown.com/src/panel-popups.tsx
+++ b/play.pokemonshowdown.com/src/panel-popups.tsx
@@ -2,6 +2,7 @@ import preact from "../js/lib/preact";
 import { toID, toRoomid, toUserid, Dex } from "./battle-dex";
 import type { ID } from "./battle-dex-data";
 import { BattleLog } from "./battle-log";
+import { PSLoginServer } from "./client-connection";
 import { PSRoom, type RoomOptions, PS, type PSLoginState, type RoomID, type TimestampOptions } from "./client-main";
 import { type BattleRoom } from "./panel-battle";
 import { PSRoomPanel, PSPanelWrapper } from "./panels";
@@ -303,12 +304,12 @@ class OptionsPanel extends PSRoomPanel {
 		}
 		PS.update();
 	};
-	setChatroomTimestamp = (e: Event) => {
-		const timestamp = (e.currentTarget as HTMLSelectElement).value as TimestampOptions;
+	setChatroomTimestamp = (ev: Event) => {
+		const timestamp = (ev.currentTarget as HTMLSelectElement).value as TimestampOptions;
 		PS.prefs.set('timestamps', { ...PS.prefs.timestamps, chatrooms: timestamp || undefined });
 	};
-	setPMsTimestamp = (e: Event) => {
-		const timestamp = (e.currentTarget as HTMLSelectElement).value as TimestampOptions;
+	setPMsTimestamp = (ev: Event) => {
+		const timestamp = (ev.currentTarget as HTMLSelectElement).value as TimestampOptions;
 		PS.prefs.set('timestamps', { ...PS.prefs.timestamps, pms: timestamp || undefined });
 	};
 
@@ -318,6 +319,34 @@ class OptionsPanel extends PSRoomPanel {
 		this.setState({ showStatusInput: !this.state.showStatusInput });
 	};
 
+	handleOnChange = (ev: Event) => {
+		let elem = ev.currentTarget as HTMLInputElement;
+		let setting = elem.name;
+		let value = elem.checked;
+		switch (setting) {
+		case 'blockPMs': {
+			PS.prefs.set("blockPMs", value);
+			PS.send(value ? '/blockpms' : '/unblockpms');
+			break;
+		}
+		case 'blockChallenges': {
+			PS.prefs.set("blockChallenges", value);
+			PS.send(value ? '/blockchallenges' : '/unblockchallenges');
+			break;
+		}
+		case 'bwgfx': {
+			PS.prefs.set('bwgfx', value);
+			Dex.loadSpriteData(value || PS.prefs.noanim ? 'bw' : 'xy');
+			break;
+		}
+		case 'noanim':
+		case 'nopastgens':
+		case 'noselfhighlight':
+		case 'inchatpm': PS.prefs.set(setting, value);
+			break;
+		}
+	};
+
 	editStatus = (ev: Event) => {
 		const statusInput = this.base!.querySelector<HTMLInputElement>('input[name=statustext]');
 		PS.send(statusInput?.value?.length ? `|/status ${statusInput.value}` : `|/clearstatus`);
@@ -342,7 +371,7 @@ class OptionsPanel extends PSRoomPanel {
 
 			{this.state.showStatusInput ? (
 				<p>
-					<input name="statustext"></input>
+					<input name="statustext" />
 					<button class="button" onClick={this.editStatus}><i class="fa fa-pencil"></i></button>
 				</p>
 			) : (
@@ -352,6 +381,10 @@ class OptionsPanel extends PSRoomPanel {
 				</p>
 			)}
 
+			{PS.user.named && (PS.user.registered ?
+				<button className="button" data-href="changepassword">Change Password</button> :
+				<button className="button" data-href="register">Register</button>)}
+
 			<hr />
 			<h3>Graphics</h3>
 			<p>
@@ -368,15 +401,67 @@ class OptionsPanel extends PSRoomPanel {
 					<option value="vertical" selected={PS.prefs.onepanel === 'vertical'}>Vertical tabs</option>
 				</select></label>
 			</p>
-			<hr />
-			{PS.user.named ? <p class="buttonbar" style="text-align: right">
-				<button class="button" data-href="login"><i class="fa fa-pencil"></i> Change name</button> {}
-				<button class="button" data-cmd="/logout"><i class="fa fa-power-off"></i> Log out</button>
-			</p> : <p class="buttonbar" style="text-align: right">
-				<button class="button" data-href="login"><i class="fa fa-pencil"></i> Choose name</button>
-			</p> }
+			<p>
+				<label class="checkbox">
+					<input
+						type="checkbox" name="noanim"
+						checked={PS.prefs.noanim || false} onChange={this.handleOnChange}
+					/> Disable animations
+				</label>
+			</p>
+			<p>
+				<label class="checkbox">
+					<input
+						type="checkbox"
+						name="bwgfx"
+						onChange={this.handleOnChange}
+						checked={PS.prefs.bwgfx || false}
+					/>  Use 2D sprites instead of 3D models</label>
+			</p>
+			<p>
+				<label class="checkbox"><input
+					type="checkbox"
+					name="nopastgens"
+					onChange={this.handleOnChange}
+					checked={PS.prefs.nopastgens || false}
+				/> Use modern sprites for past generations</label>
+			</p>
 			<hr />
 			<h3>Chat</h3>
+			<p>
+				<label class="checkbox">
+					<input type="checkbox" onChange={this.handleOnChange} name="blockPMs" checked={PS.prefs.blockPMs || false}>
+					</input> Block PMs
+				</label>
+			</p>
+			<p>
+				<label class="checkbox">
+					<input
+						type="checkbox"
+						name="blockChallenges"
+						onChange={this.handleOnChange}
+						checked={PS.prefs.blockChallenges || false}
+					>
+					</input> Block Challenges</label>
+			</p>
+			<p>
+				<label class="checkbox">
+					<input
+						type="checkbox"
+						name="inchatpm"
+						onChange={this.handleOnChange}
+						checked={PS.prefs.inchatpm || false}
+					/> Show PMs in chatrooms</label>
+			</p>
+			<p>
+				<label class="checkbox">
+					<input
+						type="checkbox"
+						name="noselfhighlight"
+						onChange={this.handleOnChange}
+						checked={PS.prefs.noselfhighlight || false}
+					/> Do Not Highlight when your name is said in chat</label>
+			</p>
 			<p>
 				<label class="optlabel">Timestamps: <select name="layout" class="button" onChange={this.setChatroomTimestamp}>
 					<option value="" selected={!PS.prefs.timestamps.chatrooms}>Off</option>
@@ -391,6 +476,13 @@ class OptionsPanel extends PSRoomPanel {
 					<option value="seconds" selected={PS.prefs.timestamps.pms === "seconds"}>[HH:MM:SS]</option>
 				</select></label>
 			</p>
+			<hr />
+			{PS.user.named ? <p class="buttonbar" style="text-align: right">
+				<button class="button" data-href="login"><i class="fa fa-pencil"></i> Change name</button> {}
+				<button class="button" data-cmd="/logout"><i class="fa fa-power-off"></i> Log out</button>
+			</p> : <p class="buttonbar" style="text-align: right">
+				<button class="button" data-href="login"><i class="fa fa-pencil"></i> Choose name</button>
+			</p> }
 		</div></PSPanelWrapper>;
 	}
 }
@@ -642,7 +734,7 @@ class BattleForfeitPanel extends PSRoomPanel {
 			<form>
 				<p>Forfeiting makes you lose the battle. Are you sure?</p>
 				<p>
-					<label class="checkbox"><input type="checkbox" name="closeroom" checked={true}></input> Close after
+					<label class="checkbox"><input type="checkbox" name="closeroom" checked={true} /> Close after
 						forfeiting</label>
 				</p>
 				<p>
@@ -703,6 +795,224 @@ class ReplacePlayerPanel extends PSRoomPanel {
 	}
 }
 
+class ChangePasswordPanel extends PSRoomPanel {
+	static readonly id = "changepassword";
+	static readonly routes = ["changepassword"];
+	static readonly location = "semimodal-popup";
+	static readonly noURL = true;
+
+	declare state: { errorMsg: string };
+
+	update = () => {
+		this.forceUpdate();
+	};
+
+	handleChangePassword = (ev: Event) => {
+		ev.preventDefault();
+		let oldpassword = this.base?.querySelector<HTMLInputElement>('input[name=oldpassword]')?.value;
+		let password = this.base?.querySelector<HTMLInputElement>('input[name=password]')?.value;
+		let cpassword = this.base?.querySelector<HTMLInputElement>('input[name=cpassword]')?.value;
+		if (!oldpassword?.length ||
+			!password?.length ||
+			!cpassword?.length) return this.setState({ errorMsg: "All fields are required" });
+		if (password !== cpassword) return this.setState({ errorMsg: 'Passwords do not match' });
+		PSLoginServer.query("changepassword", {
+			oldpassword,
+			password,
+			cpassword,
+		}).then(data => {
+			if (data?.actionerror) return this.setState({ errorMsg: data?.actionerror });
+			PS.alert("Your password was successfully changed!");
+
+		}).catch(err => {
+			console.error(err);
+			this.setState({ errorMsg: err.message });
+		});
+
+		this.setState({ errorMsg: '' });
+	};
+
+	override render() {
+		const room = this.props.room;
+
+		return <PSPanelWrapper room={room} width={280}><div class="pad">
+			<form onSubmit={this.handleChangePassword}>
+				{ !!this.state.errorMsg?.length && <p>
+					<b class="message-error"> {this.state.errorMsg}</b>
+				</p> }
+				<p>Change your password:</p>
+				<p>
+					<label class="label">Username:
+						<strong><input
+							type="text"
+							name="username"
+							value={PS.user.name}
+							style="
+						color: inherit;
+						background: transparent;
+						border: 0;
+						font: inherit;
+						font-size: inherit;
+						display: block;
+					"
+							readOnly={true}
+							autocomplete="username"
+						/></strong></label>
+				</p>
+				<p>
+					<label class="label">Old password:
+						<input
+							class="textbox autofocus"
+							type="password"
+							name="oldpassword"
+							autocomplete="current-password"
+						/></label>
+				</p>
+				<p>
+					<label class="label">New password:
+						<input
+							class="textbox"
+							type="password"
+							name="password"
+							autocomplete="new-password"
+						/></label>
+				</p>
+				<p>
+					<label class="label">New password (confirm):
+						<input
+							class="textbox"
+							type="password"
+							name="cpassword"
+							autocomplete="new-password"
+						/></label>
+				</p>
+				<p class="buttonbar">
+					<button type="submit" class="button">
+						<strong>Change password</strong>
+					</button>
+					<button type="button" data-cmd="/close" class="button">Cancel</button>
+				</p>
+			</form>
+
+		</div>
+		</PSPanelWrapper>;
+	}
+}
+
+class RegisterPanel extends PSRoomPanel {
+	static readonly id = "register";
+	static readonly routes = ["register"];
+	static readonly location = "semimodal-popup";
+	static readonly noURL = true;
+	static readonly rightPopup = true;
+
+	declare state: { errorMsg: string };
+
+	update = () => {
+		this.forceUpdate();
+	};
+
+	handleRegisterUser = (ev: Event) => {
+		ev.preventDefault();
+		let captcha = this.base?.querySelector<HTMLInputElement>('input[name=captcha]')?.value;
+		let password = this.base?.querySelector<HTMLInputElement>('input[name=password]')?.value;
+		let cpassword = this.base?.querySelector<HTMLInputElement>('input[name=cpassword]')?.value;
+		if (!captcha?.length ||
+			!password?.length ||
+			!cpassword?.length) return this.setState({ errorMsg: "All fields are required" });
+		if (password !== cpassword) return this.setState({ errorMsg: 'Passwords do not match' });
+		PSLoginServer.query("register", {
+			captcha,
+			password,
+			cpassword,
+			username: PS.user.name,
+			challstr: PS.user.challstr,
+		}).then(data => {
+			console.log(data);
+			if (data?.actionerror) this.setState({ errorMsg: data?.actionerror });
+			if (data?.curuser?.loggedin) {
+				PS.user.registered = true;
+				let name = data.curuser.username;
+				if (data?.assertion) PS.user.handleAssertion(name, data?.assertion);
+				this.close();
+				PS.alert("You have been successfully registered.");
+			}
+		}).catch(err => {
+			console.error(err);
+			this.setState({ errorMsg: err.message });
+		});
+
+		this.setState({ errorMsg: '' });
+	};
+
+	override render() {
+		const room = this.props.room;
+
+		return <PSPanelWrapper room={room} width={280}><div class="pad">
+			<form onSubmit={this.handleRegisterUser}>
+				{ !!this.state.errorMsg?.length && <p>
+					<b class="message-error"> {this.state.errorMsg}</b>
+				</p> }
+				<p>Register your account:</p>
+				<p>
+					<label class="label">Username:
+						<strong><input
+							type="text"
+							name="name"
+							value={PS.user.name}
+							style="
+						color: inherit;
+						background: transparent;
+						border: 0;
+						font: inherit;
+						font-size: inherit;
+						display: block;
+					"
+							readOnly={true}
+							autocomplete="username"
+						/></strong></label>
+				</p>
+				<p>
+					<label class="label">Password:
+						<input
+							class="textbox autofocus"
+							type="password"
+							name="password"
+							autocomplete="new-password"
+						/></label>
+				</p>
+				<p>
+					<label class="label">Password (confirm):
+						<input
+							class="textbox"
+							type="password"
+							name="cpassword"
+							autocomplete="new-password"
+						/></label>
+				</p>
+				<p>
+					<label class="label"> <img
+						src="https://play.pokemonshowdown.com/sprites/gen5ani/pikachu.gif"
+						alt="An Electric-type mouse that is the mascot of the Pokémon franchise."
+					/></label>
+				</p>
+				<p>
+					<label class="label">What is this pokemon?
+						<input
+							class="textbox" type="text" name="captcha" value=""
+						/></label>
+				</p>
+				<p class="buttonbar">
+					<button type="submit" class="button"><strong>Register</strong></button>
+					<button type="button" data-cmd="/close" class="button">Cancel</button>
+				</p>
+			</form>
+
+		</div>
+		</PSPanelWrapper>;
+	}
+}
+
 class PopupPanel extends PSRoomPanel {
 	static readonly id = 'popup';
 	static readonly routes = ['popup-*'];
@@ -729,6 +1039,8 @@ PS.addRoomType(UserPanel,
 	OptionsPanel,
 	LoginPanel,
 	AvatarsPanel,
+	ChangePasswordPanel,
+	RegisterPanel,
 	BattleForfeitPanel,
 	ReplacePlayerPanel,
 	PopupPanel);


Reviews:
user: login: Zarel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:49:12Z
user: login: Zarel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:50:46Z
user: login: Zarel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:53:19Z
user: login: Zarel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:56:06Z
user: login: Zarel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T02:45:22Z

Review comments:
diff_hunk: @@ -513,6 +533,24 @@ class PSUser extends PSStreamModel<PSLoginState | null> {
 		this.registered = false;
 		this.update(null);
 	}
+
+	updateRegExp() {
+		if (!this.named) {
+			this.nameRegExp = null;
+		} else {
+			let escaped = this.name.replace(/[^A-Za-z0-9]+$/, '');
+			// we'll use `,` as a sentinel character to mean "any non-alphanumeric char"
+			// unicode characters can be replaced with any non-alphanumeric char
+			for (let i = escaped.length - 1; i > 0; i--) {
+				if (/[^\ -\~]/.test(escaped[i])) {
path: play.pokemonshowdown.com/src/client-main.ts
user: login: Zarel
body: ```suggestion
				if (/[^ -~]/.test(escaped[i])) {
```
created_at: 2025-04-16T19:49:12Z
author_association: MEMBER
diff_hunk: @@ -368,15 +402,68 @@ class OptionsPanel extends PSRoomPanel {
 					<option value="vertical" selected={PS.prefs.onepanel === 'vertical'}>Vertical tabs</option>
 				</select></label>
 			</p>
-			<hr />
-			{PS.user.named ? <p class="buttonbar" style="text-align: right">
-				<button class="button" data-href="login"><i class="fa fa-pencil"></i> Change name</button> {}
-				<button class="button" data-cmd="/logout"><i class="fa fa-power-off"></i> Log out</button>
-			</p> : <p class="buttonbar" style="text-align: right">
-				<button class="button" data-href="login"><i class="fa fa-pencil"></i> Choose name</button>
-			</p> }
path: play.pokemonshowdown.com/src/panel-popups.tsx
user: login: Zarel
body: Heh yeah I was so surprised to see this guy up in the middle of the Options box; thanks for moving it back.
created_at: 2025-04-16T19:50:45Z
author_association: MEMBER
diff_hunk: @@ -281,6 +282,7 @@ class OptionsPanel extends PSRoomPanel {
 	static readonly id = 'options';
 	static readonly routes = ['options'];
 	static readonly location = 'popup';
+	static readonly noURL = true;
path: play.pokemonshowdown.com/src/panel-popups.tsx
user: login: Zarel
body: ```suggestion
```

Options was always intended to have a URL and be linkable.
created_at: 2025-04-16T19:53:19Z
author_association: MEMBER
diff_hunk: @@ -368,15 +402,68 @@ class OptionsPanel extends PSRoomPanel {
 					<option value="vertical" selected={PS.prefs.onepanel === 'vertical'}>Vertical tabs</option>
 				</select></label>
 			</p>
-			<hr />
-			{PS.user.named ? <p class="buttonbar" style="text-align: right">
-				<button class="button" data-href="login"><i class="fa fa-pencil"></i> Change name</button> {}
-				<button class="button" data-cmd="/logout"><i class="fa fa-power-off"></i> Log out</button>
-			</p> : <p class="buttonbar" style="text-align: right">
-				<button class="button" data-href="login"><i class="fa fa-pencil"></i> Choose name</button>
-			</p> }
+			<p>
+				<label class="checkbox">
+					<input
+						type="checkbox"
+						name="noanim"
+						onChange={this.handleOnChange}
+						checked={PS.prefs.noanim || false}
+					></input> Disable animations</label>
path: play.pokemonshowdown.com/src/panel-popups.tsx
user: login: Zarel
body: ```suggestion
				<label class="checkbox">
					<input
						type="checkbox" name="noanim"
						checked={PS.prefs.noanim || false} onChange={this.handleOnChange}
					/> Disable animations
				</label>
```
Self-closing tags are written like this in JSX. Also PS standard is to put closing tag on the same indentation as opening tag.
created_at: 2025-04-16T19:56:06Z
author_association: MEMBER
diff_hunk: @@ -154,11 +154,11 @@ export class BattleLog {
 				}
 				timestampHtml = `<small class="gray">[${components.map(x => x < 10 ? `0${x}` : x).join(':')}] </small>`;
 			}
-			let isHighlighted = window.app?.rooms?.[battle!.roomid].getHighlight(message);
+			let isHighlighted = window.app?.rooms?.[battle!.roomid].getHighlight(message) || window.PS?.getHighlight(message);
 			[divClass, divHTML, noNotify] = this.parseChatMessage(message, name, timestampHtml, isHighlighted);
 			if (!noNotify && isHighlighted) {
-				let notifyTitle = "Mentioned by " + name + " in " + battle!.roomid;
-				app.rooms[battle!.roomid].notifyOnce(notifyTitle, "\"" + message + "\"", 'highlight');
+				let notifyTitle = "Mentioned by " + name + " in " + (battle?.roomid || '');
+				window.app?.rooms[battle?.roomid || '']?.notifyOnce(notifyTitle, "\"" + message + "\"", 'highlight');
path: play.pokemonshowdown.com/src/battle-log.ts
user: login: Zarel
body: ```suggestion
				window.app?.rooms[battle?.roomid || '']?.notifyOnce(notifyTitle, "\"" + message + "\"", 'highlight');
				window.PS?.rooms[battle?.roomid || '']?.notify({
					title: notifyTitle,
					body: "\"" + message + "\"",
					id: 'highlight',
				});
```
created_at: 2025-04-17T02:45:22Z
author_association: MEMBER

PR comments:
user: login: ISenseAura
created_at: 2025-04-16T16:40:20Z
body: ok my eslint is actually broken, atleast these warning should have appeared in my vscode
author_association: MEMBER
user: login: Zarel
created_at: 2025-04-16T20:04:59Z
body: ESLint continues to work perfectly in VS Code for me. But if it doesn't, `npm test` always works for me.
author_association: MEMBER
user: login: ISenseAura
created_at: 2025-04-17T03:01:38Z
body: Also i forgot to mention, i could not test change password as the query was always returning “Session expired” even after i passed my sid.

Registration however worked normally
author_association: MEMBER
user: login: Zarel
created_at: 2025-04-17T05:07:50Z
body: All right, that's everything. Thanks!
author_association: MEMBER