Preact: More misc fixes

Moderate:

- Fix double messages when being DMed (and otherwise joining a room
  server-side)
- Refactor PSModel to support passing a value with updates, so
  PSStreamModel is explicitly about tracking an update backlog.
- Work on supporting AFD mode (it doesn't seem to work yet)
- Fix music still playing after closing a battle
- Show username (with capitalization and spaces) in DM titles
- Don't let people try to search for battles without choosing a username

Minor:

- Remove random BOM that made it into some files?
- Correct date Preact client started (I mistook the date testclient was
  created)
- Refactor updateLayout
This commit is contained in:
Guangcong Luo 2025-04-11 07:27:42 +00:00
parent 0eeed0d7ce
commit 9f18a13bbf
8 changed files with 142 additions and 105 deletions

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<meta charset="UTF-8" />
<script src="/js/lib/jquery-2.2.4.min.js"></script>
<script>

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<!--
,///////,
+#########+ ///////,

View File

@ -47,7 +47,7 @@ https://psim.us/dev
<div id="room-news">
<div class="mini-window-body">
<div class="newsentry"><h4>New updates</h4><p>The Preact alpha test client is very far along!</p><p>Other than the teambuilder, everything else should be ready.</p><p>&mdash;<strong>Zarel</strong> <small class="date">on Apr 9, 2025</small></p></div>
<div class="newsentry"><h4>Preact alpha</h4><p>Welcome to the Preact alpha test client!</p><p>Many parts are unimplemented and there may be bugs; we're using this page to test it.</p><p>&mdash;<strong>Zarel</strong> <small class="date">on Sep 25, 2015</small></p></div>
<div class="newsentry"><h4>Preact alpha</h4><p>Welcome to the Preact alpha test client!</p><p>Many parts are unimplemented and there may be bugs; we're using this page to test it.</p><p>&mdash;<strong>Zarel</strong> <small class="date">on Oct 16, 2018</small></p></div>
</div>
</div>
</div>

View File

@ -20,74 +20,78 @@
const PSURL = `${document.location.protocol !== 'http:' ? 'https:' : ''}//${Config.routes.client}/`;
export class PSSubscription {
observable: PSModel | PSStreamModel<any>;
listener: (value?: any) => void;
constructor(observable: PSModel | PSStreamModel<any>, listener: (value?: any) => void) {
export class PSSubscription<T = any> {
observable: PSModel<T> | PSStreamModel<T>;
listener: (value: T) => void;
constructor(observable: PSModel<T> | PSStreamModel<T>, listener: (value: T) => void) {
this.observable = observable;
this.listener = listener;
}
unsubscribe() {
const index = this.observable.subscriptions.indexOf(this);
const index = this.observable.subscriptions.indexOf(this as any);
if (index >= 0) this.observable.subscriptions.splice(index, 1);
}
}
/**
* PS Models roughly implement the Observable spec. Not the entire
* spec - just the parts we use. PSModel just notifies subscribers of
* updates - a simple model for React.
* PS Models roughly implement the Observable spec. By default,
* PSModel notifies listeners when the model is updated. With a
* value, PSModel can also stream data out.
*
* Note that unlike React's usual paradigm, PS Models are not
* immutable.
*/
export class PSModel {
subscriptions = [] as PSSubscription[];
subscribe(listener: () => void) {
const subscription = new PSSubscription(this, listener);
export class PSModel<T = null> {
subscriptions: PSSubscription<T>[] = [];
subscribe(listener: (value: T) => void) {
const subscription = new PSSubscription<T>(this, listener);
this.subscriptions.push(subscription);
return subscription;
}
subscribeAndRun(listener: () => void) {
subscribeAndRun(listener: (value: T) => void, value?: T) {
const subscription = this.subscribe(listener);
subscription.listener();
subscription.listener(value!);
return subscription;
}
update() {
update(this: PSModel): void;
update(value: T): void;
update(value?: T) {
for (const subscription of this.subscriptions) {
subscription.listener();
subscription.listener(value!);
}
}
}
/**
* PS Models roughly implement the Observable spec. PSStreamModel
* streams some data out. This is very not-React, which generally
* expects the DOM to be a pure function of state. Instead PSModels
* which hold state, PSStreamModels give state directly to views,
* so that the model doesn't need to hold a redundant copy of state.
* @see PSModel
*
* The main difference is that StreamModel keeps a backlog,
* so events generated before something subscribes are not
* lost. Nullish values are not kept in the backlog.
*/
export class PSStreamModel<T = string> {
subscriptions = [] as PSSubscription[];
updates = [] as T[];
subscriptions: PSSubscription<T>[] = [];
backlog: NonNullable<T>[] | null = [];
subscribe(listener: (value: T) => void) {
// TypeScript bug
const subscription: PSSubscription = new PSSubscription(this, listener);
const subscription: PSSubscription<T> = new PSSubscription<T>(this, listener);
this.subscriptions.push(subscription);
if (this.updates.length) {
for (const update of this.updates) {
if (this.backlog) {
for (const update of this.backlog) {
subscription.listener(update);
}
this.updates = [];
this.backlog = null;
}
return subscription;
}
subscribeAndRun(listener: (value: T) => void) {
subscribeAndRun(listener: (value: T) => void, value: T = null!) {
const subscription = this.subscribe(listener);
subscription.listener(null);
subscription.listener(value);
return subscription;
}
update(value: T) {
if (!this.subscriptions.length) {
if (!this.subscriptions.length && value !== null && value !== undefined) {
// save updates for later
this.updates.push(value);
(this.backlog ||= []).push(value);
}
for (const subscription of this.subscriptions) {
subscription.listener(value);

View File

@ -14,9 +14,12 @@ import { PSModel, PSStreamModel } from './client-core';
import type { PSRoomPanel, PSRouter } from './panels';
import type { ChatRoom } from './panel-chat';
import type { MainMenuRoom } from './panel-mainmenu';
import { toID, type ID } from './battle-dex';
import { Dex, toID, type ID } from './battle-dex';
import { BattleTextParser, type Args } from './battle-text-parser';
declare const BattleTextAFD: any;
declare const BattleTextNotAFD: any;
/**********************************************************************
* Prefs
*********************************************************************/
@ -67,6 +70,8 @@ class PSPrefs extends PSStreamModel<string | null> {
musicvolume = 50;
notifvolume = 50;
afd: boolean | 'sprites' = false;
// PREFS END HERE
storageEngine: 'localStorage' | 'iframeLocalStorage' | '' = '';
@ -104,6 +109,7 @@ class PSPrefs extends PSStreamModel<string | null> {
this.update(key);
this.save();
}
load(newPrefs: object, noSave?: boolean) {
this.fixPrefs(newPrefs);
Object.assign(this, PSPrefsDefaults);
@ -111,6 +117,7 @@ class PSPrefs extends PSStreamModel<string | null> {
for (const key in PSPrefsDefaults) {
if (key in newPrefs) (this as any)[key] = (newPrefs as any)[key];
}
this.setAFD();
this.update(null);
if (!noSave) this.save();
}
@ -155,6 +162,38 @@ class PSPrefs extends PSStreamModel<string | null> {
delete newPrefs['dark'];
}
}
setAFD(mode?: typeof this['afd']) {
if (mode === undefined) {
// init
if (typeof BattleTextAFD !== 'undefined') {
for (const id in BattleTextNotAFD) {
if (!BattleTextAFD[id]) {
BattleTextAFD[id] = BattleTextNotAFD[id];
} else {
BattleTextAFD[id] = { ...BattleTextNotAFD[id], ...BattleTextAFD[id] };
}
}
}
if (Config.server.afd) {
mode = true;
} else if (this.afd !== undefined) {
mode = this.afd;
} else {
// uncomment on April Fools' Day
// mode = true;
}
}
Dex.afdMode = mode;
if (mode === true) {
(BattleText as any) = BattleTextAFD;
} else {
(BattleText as any) = BattleTextNotAFD;
}
}
}
/**********************************************************************
@ -551,10 +590,8 @@ export interface RoomOptions {
location?: PSRoomLocation | null;
/**
* In case the room received messages before it was ready for them.
*
* Fed to `room.receiveLine` after initialization, after the constructor is run.
*/
backlog?: Args[];
backlog?: Args[] | null;
/**
* Popup parent element. If it exists, a popup shows up right above/below that element.
*
@ -643,6 +680,7 @@ export class PSRoom extends PSStreamModel<Args | null> implements RoomOptions {
if (this.location !== 'popup' && this.location !== 'semimodal-popup') this.parentElem = null;
if (options.rightPopup) this.rightPopup = true;
if (options.connected) this.connected = true;
if (options.backlog) this.backlog = options.backlog;
this.noURL = options.noURL || false;
this.args = options.args || null;
}
@ -741,17 +779,9 @@ export class PSRoom extends PSStreamModel<Args | null> implements RoomOptions {
}
class PlaceholderRoom extends PSRoom {
backlog = [] as Args[];
override readonly classType = 'placeholder';
override receiveLine(args: Args) {
try {
this.backlog.push(args);
} catch (err: any) {
console.log(this.backlog.length);
console.log(args.length);
console.log(this.backlog.slice(0, 1000));
throw err;
}
(this.backlog ||= []).push(args);
}
}
@ -936,7 +966,10 @@ export const PS = new class extends PSModel {
}
this.updateLayout();
window.addEventListener('resize', () => this.updateLayout());
window.addEventListener('resize', () => {
// super.update() skips another updateLayout() call
if (this.updateLayout()) super.update();
});
}
// Panel layout
@ -987,7 +1020,8 @@ export const PS = new class extends PSModel {
maxWidth: 640,
};
}
updateLayout(alreadyUpdating?: boolean) {
/** @returns changed */
updateLayout(): boolean {
const leftPanelWidth = this.calculateLeftPanelWidth();
const totalWidth = document.body.offsetWidth;
const totalHeight = document.body.offsetHeight;
@ -1007,8 +1041,9 @@ export const PS = new class extends PSModel {
if (this.leftPanelWidth !== leftPanelWidth) {
this.leftPanelWidth = leftPanelWidth;
if (!alreadyUpdating) this.update(true);
return true;
}
return false;
}
getRoom(elem: HTMLElement | EventTarget | null | undefined, skipClickable?: boolean): PSRoom | null {
let curElem: HTMLElement | null = elem as HTMLElement;
@ -1039,8 +1074,8 @@ export const PS = new class extends PSModel {
PS.moveRoom(fromRoom, toLocation, onHome, toIndex);
PS.update();
}
override update(layoutAlreadyUpdated?: boolean) {
if (!layoutAlreadyUpdated) this.updateLayout(true);
override update() {
this.updateLayout();
super.update();
}
receive(msg: string) {
@ -1234,11 +1269,6 @@ export const PS = new class extends PSModel {
if (this.room === room) this.room = newRoom;
if (roomid === '') this.mainmenu = newRoom as MainMenuRoom;
if (options.backlog) {
for (const args of options.backlog) {
room.receiveLine(args);
}
}
updated = true;
}
if (updated) this.update();

View File

@ -41,15 +41,13 @@ export class ChatRoom extends PSRoom {
/** n.b. this will be null outside of battle rooms */
battle: Battle | null = null;
log: BattleLog | null = null;
/** during initialization, room could get messages before it has a log */
backlog: Args[] | null = null;
constructor(options: RoomOptions) {
super(options);
if (options.args?.pmTarget) this.pmTarget = options.args.pmTarget as string;
if (options.args?.challengeMenuOpen) this.challengeMenuOpen = true;
if (options.args?.initialSlash) this.initialSlash = true;
this.updateTarget();
this.updateTarget(this.pmTarget);
this.connect();
}
override connect() {
@ -60,23 +58,45 @@ export class ChatRoom extends PSRoom {
}
}
override receiveLine(args: Args) {
if (!this.log) (this.backlog ||= []).push(args);
switch (args[0]) {
case 'users':
const usernames = args[1].split(',');
const count = parseInt(usernames.shift()!, 10);
this.setUsers(count, usernames);
return;
case 'join': case 'j': case 'J':
this.addUser(args[1]);
break;
case 'leave': case 'l': case 'L':
this.removeUser(args[1]);
break;
case 'name': case 'n': case 'N':
this.renameUser(args[1], args[2]);
break;
case 'c':
if (`${args[2]} `.startsWith('/challenge ')) {
this.updateChallenge(args[1], args[2].slice(11));
return;
}
break;
}
super.receiveLine(args);
}
updateTarget() {
updateTarget(name?: string | null) {
if (this.id === 'dm-') {
this.pmTarget = PS.user.userid;
this.setUsers(1, [` ${PS.user.userid}`]);
this.pmTarget = PS.user.name;
this.setUsers(1, [` ${PS.user.name}`]);
this.title = `Console`;
} else if (this.id.startsWith('dm-')) {
const id = this.id.slice(3);
this.pmTarget = id;
if (!name || toID(name) !== id) name = this.pmTarget || id;
this.pmTarget = name;
if (!PS.user.userid) {
this.setUsers(1, [` ${id}`]);
this.setUsers(1, [` ${name}`]);
} else {
this.setUsers(2, [` ${id}`, ` ${PS.user.userid}`]);
this.setUsers(2, [` ${name}`, ` ${PS.user.name}`]);
}
this.title = `[DM] ${this.pmTarget}`;
this.title = `[DM] ${name}`;
}
}
/**
@ -212,6 +232,12 @@ export class ChatRoom extends PSRoom {
}
override destroy() {
if (this.pmTarget) this.connected = false;
if (this.battle) {
// since battle is defined here, we might as well deallocate it here
this.battle.destroy();
} else {
this.log?.destroy();
}
super.destroy();
}
}
@ -536,21 +562,12 @@ class ChatPanel extends PSRoomPanel<ChatRoom> {
export class ChatUserList extends preact.Component<{
room: ChatRoom, left?: number, top?: number, minimized?: boolean,
}> {
subscription: PSSubscription | null = null;
override state = {
expanded: false,
};
toggleExpanded = () => {
this.setState({ expanded: !this.state.expanded });
};
override componentDidMount() {
this.subscription = this.props.room.subscribe(msg => {
if (!msg) this.forceUpdate();
});
}
override componentWillUnmount() {
if (this.subscription) this.subscription.unsubscribe();
}
render() {
const room = this.props.room;
let userList = Object.entries(room.users) as [ID, string][];
@ -612,33 +629,11 @@ export class ChatLog extends preact.Component<{
room.log.add(args);
}
}
this.subscription = room.subscribe(tokens => {
if (!tokens) return;
this.props.room.log!.add(tokens);
});
}
this.subscription = room.subscribe(tokens => {
if (!tokens) return;
switch (tokens[0]) {
case 'users':
const usernames = tokens[1].split(',');
const count = parseInt(usernames.shift()!, 10);
this.props.room.setUsers(count, usernames);
return;
case 'join': case 'j': case 'J':
this.props.room.addUser(tokens[1]);
break;
case 'leave': case 'l': case 'L':
this.props.room.removeUser(tokens[1]);
break;
case 'name': case 'n': case 'N':
this.props.room.renameUser(tokens[1], tokens[2]);
break;
case 'c':
if (`${tokens[2]} `.startsWith('/challenge ')) {
this.props.room.updateChallenge(tokens[1], tokens[2].slice(11));
return;
}
break;
}
if (!this.props.noSubscription) this.props.room.log!.add(tokens);
});
this.setControlsJSX(this.props.children);
}
override componentWillUnmount() {

View File

@ -310,6 +310,8 @@ export class MainMenuRoom extends PSRoom {
args: { pmTarget },
}, true);
room = PS.rooms[roomid] as ChatRoom;
} else {
room.updateTarget(pmTarget);
}
room.receiveLine([`c`, user1, message]);
PS.update();
@ -388,6 +390,12 @@ class MainMenuPanel extends PSRoomPanel<MainMenuRoom> {
this.base?.querySelector<HTMLButtonElement>('.formatselect')?.focus();
}
submitSearch = (ev: Event, format: string, team?: Team) => {
if (!PS.user.named) {
PS.join('login' as RoomID, {
parentElem: this.base!.querySelector<HTMLElement>('.big.button'),
});
return;
}
PS.mainmenu.startSearch(format, team);
};
handleDragStart = (e: DragEvent) => {

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />