diff --git a/src/client-main.ts b/src/client-main.ts index 3ae799587..859bdb95b 100644 --- a/src/client-main.ts +++ b/src/client-main.ts @@ -347,7 +347,7 @@ interface RoomOptions { type?: string; location?: PSRoomLocation | null; /** Handled after initialization, outside of the constructor */ - queue?: string[]; + queue?: Args[]; parentElem?: HTMLElement | null; parentRoomid?: RoomID | null; rightPopup?: boolean; @@ -355,11 +355,23 @@ interface RoomOptions { [k: string]: unknown; } -class PSRoom extends PSStreamModel implements RoomOptions { +interface PSNotificationState { + title: string; + body?: string; + /** Used to identify notifications to be dismissed - '' if you only want to autodismiss */ + id: string; + /** normally: automatically dismiss the notification when viewing the room; set this to require manual dismissing */ + noAutoDismiss: boolean; +} + +/** + * As a PSStreamModel, PSRoom can emit `Args` to mean "we received a message", + * and `null` to mean "tell Preact to re-render this room" + */ +class PSRoom extends PSStreamModel implements RoomOptions { id: RoomID; title = ""; type = ''; - notifying: '' | ' notifying' | ' subtle-notifying' = ''; readonly classType: string = ''; location: PSRoomLocation = 'left'; closable = true; @@ -371,6 +383,12 @@ class PSRoom extends PSStreamModel implements RoomOptions { * after sending `/leave`, even before the server responds. */ connected: boolean = false; + /** + * Can this room even be connected to at all? + * `true` = pass messages from the server to subscribers + * `false` = throw an error if we receive messages from the server + */ + readonly canConnect: boolean = false; connectWhenLoggedIn: boolean = false; onParentEvent: ((eventId: 'focus' | 'keydown', e?: Event) => false | void) | null = null; @@ -379,6 +397,9 @@ class PSRoom extends PSStreamModel implements RoomOptions { parentElem: HTMLElement | null = null; rightPopup = false; + notifications: PSNotificationState[] = []; + isSubtleNotifying = false; + // for compatibility with RoomOptions [k: string]: unknown; @@ -394,17 +415,56 @@ class PSRoom extends PSStreamModel implements RoomOptions { if (options.rightPopup) this.rightPopup = true; if (options.connected) this.connected = true; } + notify(options: {title: string, body?: string, noAutoDismiss?: boolean, id?: string}) { + if (options.noAutoDismiss && !options.id) { + throw new Error(`Must specify id for manual dismissing`); + } + this.notifications.push({ + title: options.title, + body: options.body, + id: options.id || '', + noAutoDismiss: options.noAutoDismiss || false, + }); + PS.update(); + } + dismissNotification(id: string) { + this.notifications = this.notifications.filter(notification => notification.id !== id); + PS.update(); + } + autoDismissNotifications() { + this.notifications = this.notifications.filter(notification => notification.noAutoDismiss); + this.isSubtleNotifying = false; + } setDimensions(width: number, height: number) { if (this.width === width && this.height === height) return; this.width = width; this.height = height; - this.update(''); + this.update(null); } connect(): void { throw new Error(`This room is not designed to connect to a server room`); } - receive(message: string): void { - throw new Error(`This room is not designed to receive messages`); + receiveLine(args: Args): void { + switch (args[0]) { + case 'title': { + this.title = args[1]; + PS.update(); + break; + } case 'tempnotify': { + const [, id, title, body, toHighlight] = args; + this.notify({title, body, id}); + break; + } case 'tempnotifyoff': { + const [, id] = args; + this.dismissNotification(id); + break; + } default: { + if (this.canConnect) { + this.update(args); + } else { + throw new Error(`This room is not designed to receive messages`); + } + }} } send(msg: string, direct?: boolean) { const id = this.id === 'lobby' ? '' : this.id; @@ -419,10 +479,10 @@ class PSRoom extends PSStreamModel implements RoomOptions { } class PlaceholderRoom extends PSRoom { - queue = [] as string[]; + queue = [] as Args[]; readonly classType: 'placeholder' = 'placeholder'; - receive(message: string) { - this.queue.push(message); + receiveLine(args: Args) { + this.queue.push(args); } } @@ -642,10 +702,12 @@ const PS = new class extends PSModel { console.log('\u2705 ' + (roomid ? '[' + roomid + '] ' : '') + '%c' + msg, "color: #007700"); let isInit = false; for (const line of msg.split('\n')) { - if (line.startsWith('|init|')) { + const args = BattleTextParser.parseLine(line); + switch (args[0]) { + case 'init': { isInit = true; room = PS.rooms[roomid2]; - const type = line.slice(6); + const [, type] = args; if (!room) { this.addRoom({ id: roomid2, @@ -660,8 +722,7 @@ const PS = new class extends PSModel { } this.update(); continue; - } - if ((line + '|').startsWith('|deinit|')) { + } case 'deinit': { room = PS.rooms[roomid2]; if (room) { room.connected = false; @@ -669,21 +730,20 @@ const PS = new class extends PSModel { } this.update(); continue; - } - if ((line + '|').startsWith('|noinit|')) { + } case 'noinit': { room = PS.rooms[roomid2]; if (room) { room.connected = false; - if ((line + '|').startsWith('|noinit|namerequired|')) { + if (args[1] === 'namerequired') { room.connectWhenLoggedIn = true; } } this.update(); continue; - } - if (room) room.receive(line); + }} + if (room) room.receiveLine(args); } - if (room) room.update(isInit ? `|initdone` : null); + if (room) room.update(isInit ? [`initdone`] : null); } send(fullMsg: string) { const pipeIndex = fullMsg.indexOf('|'); @@ -810,8 +870,8 @@ const PS = new class extends PSModel { if (roomid === '') this.mainmenu = newRoom as MainMenuRoom; if (options.queue) { - for (const line of options.queue) { - room.receive(line); + for (const args of options.queue) { + room.receiveLine(args); } } updated = true; @@ -835,6 +895,7 @@ const PS = new class extends PSModel { } else { return false; } + this.room.autoDismissNotifications(); this.update(); if (this.room.onParentEvent) this.room.onParentEvent('focus', undefined); return true; @@ -956,8 +1017,8 @@ const PS = new class extends PSModel { this.room = room; } if (options.queue) { - for (const line of options.queue) { - room.receive(line); + for (const args of options.queue) { + room.receiveLine(args); } } return room; diff --git a/src/panel-battle.tsx b/src/panel-battle.tsx index d4e1ca84e..e0b54799e 100644 --- a/src/panel-battle.tsx +++ b/src/panel-battle.tsx @@ -25,7 +25,7 @@ class BattlesRoom extends PSRoom { if (format === this.format) return this.refresh(); this.battles = null; this.format = format; - this.update(''); + this.update(null); this.refresh(); } refresh() { @@ -83,14 +83,10 @@ class BattlesPanel extends PSRoomPanel { class BattleRoom extends ChatRoom { readonly classType = 'battle'; - // @ts-ignore assigned in parent constructor - pmTarget: null; - // @ts-ignore assigned in parent constructor - challengeMenuOpen: false; - // @ts-ignore assigned in parent constructor - challengingFormat: null; - // @ts-ignore assigned in parent constructor - challengedFormat: null; + pmTarget!: null; + challengeMenuOpen!: false; + challengingFormat!: null; + challengedFormat!: null; battle: Battle = null!; /** * @return true to prevent line from being sent to server @@ -103,11 +99,11 @@ class BattleRoom extends ChatRoom { switch (cmd) { case 'play': { this.battle.play(); - this.update(''); + this.update(null); return true; } case 'pause': { this.battle.pause(); - this.update(''); + this.update(null); return true; } case 'ffto': case 'fastfowardto': { let turnNum = Number(target); @@ -118,11 +114,11 @@ class BattleRoom extends ChatRoom { turnNum = -1; } if (isNaN(turnNum)) { - this.receive(`|error|/ffto - Invalid turn number: ${target}`); + this.receiveLine([`error`, `/ffto - Invalid turn number: ${target}`]); return true; } this.battle.fastForwardTo(turnNum); - this.update(''); + this.update(null); return true; } case 'switchsides': { this.battle.switchSides(); @@ -172,12 +168,12 @@ class BattlePanel extends PSRoomPanel { battle.play(); super.componentDidMount(); } - receive(line: string) { - if (line === `|initdone`) { + receiveLine(args: Args) { + if (args[0] === `initdone`) { this.props.room.battle.fastForwardTo(-1); return; } - this.props.room.battle.add(line); + this.props.room.battle.add('|' + args.join('|')); } renderControls() { const battle = this.props.room.battle; diff --git a/src/panel-chat.tsx b/src/panel-chat.tsx index 0936ee46b..d1be9cf30 100644 --- a/src/panel-chat.tsx +++ b/src/panel-chat.tsx @@ -9,6 +9,7 @@ class ChatRoom extends PSRoom { readonly classType: 'chat' | 'battle' = 'chat'; users: {[userid: string]: string} = {}; userCount = 0; + readonly canConnect = true; // PM-only properties pmTarget: string | null = null; @@ -77,22 +78,22 @@ class ChatRoom extends PSRoom { return true; } case 'reject': { this.challengedFormat = null; - this.update(''); + this.update(null); return false; }} return false; } openChallenge() { if (!this.pmTarget) { - this.receive(`|error|Can only be used in a PM.`); + this.receiveLine([`error`, `Can only be used in a PM.`]); return; } this.challengeMenuOpen = true; - this.update(''); + this.update(null); } cancelChallenge() { if (!this.pmTarget) { - this.receive(`|error|Can only be used in a PM.`); + this.receiveLine([`error`, `Can only be used in a PM.`]); return; } if (this.challengingFormat) { @@ -102,7 +103,7 @@ class ChatRoom extends PSRoom { } else { this.challengeMenuOpen = false; } - this.update(''); + this.update(null); } send(line: string, direct?: boolean) { this.updateTarget(); @@ -113,9 +114,6 @@ class ChatRoom extends PSRoom { } super.send(line); } - receive(line: string) { - this.update(line); - } setUsers(count: number, usernames: string[]) { this.userCount = count; this.users = {}; @@ -123,13 +121,13 @@ class ChatRoom extends PSRoom { const userid = toID(username); this.users[userid] = username; } - this.update(''); + this.update(null); } addUser(username: string) { const userid = toID(username); if (!(userid in this.users)) this.userCount++; this.users[userid] = username; - this.update(''); + this.update(null); } removeUser(username: string, noUpdate?: boolean) { const userid = toID(username); @@ -137,12 +135,12 @@ class ChatRoom extends PSRoom { this.userCount--; delete this.users[userid]; } - if (!noUpdate) this.update(''); + if (!noUpdate) this.update(null); } renameUser(username: string, oldUsername: string) { this.removeUser(oldUsername, true); this.addUser(username); - this.update(''); + this.update(null); } destroy() { if (this.pmTarget) this.connected = false; @@ -347,7 +345,7 @@ class ChatPanel extends PSRoomPanel { PS.send(`|/challenge ${room.pmTarget}, ${format}`); room.challengeMenuOpen = false; room.challengingFormat = format; - room.update(''); + room.update(null); }; acceptChallenge = (e: Event, format: string, team?: Team) => { const room = this.props.room; @@ -356,7 +354,7 @@ class ChatPanel extends PSRoomPanel { PS.send(`|/utm ${packedTeam}`); this.props.room.send(`/accept`); room.challengedFormat = null; - room.update(''); + room.update(null); }; render() { const room = this.props.room; @@ -460,14 +458,9 @@ class ChatLog extends preact.Component<{ if (!this.props.noSubscription) { this.log = new BattleLog(this.base! as HTMLDivElement); } - this.subscription = this.props.room.subscribe(msg => { - if (!msg) return; - const tokens = BattleTextParser.parseLine(msg); + this.subscription = this.props.room.subscribe(tokens => { + if (!tokens) return; switch (tokens[0]) { - case 'title': - this.props.room.title = tokens[1]; - PS.update(); - return; case 'users': const usernames = tokens[1].split(','); const count = parseInt(usernames.shift()!, 10); diff --git a/src/panel-mainmenu.tsx b/src/panel-mainmenu.tsx index e586ccf71..57efdff1e 100644 --- a/src/panel-mainmenu.tsx +++ b/src/panel-mainmenu.tsx @@ -22,40 +22,46 @@ class MainMenuRoom extends PSRoom { official?: RoomInfo[], pspl?: RoomInfo[], } = {}; - receive(line: string) { - const tokens = BattleTextParser.parseLine(line); - switch (tokens[0]) { - case 'challstr': + receiveLine(args: Args) { + const [cmd] = args; + switch (cmd) { + case 'challstr': { + const [, challstr] = args; PSLoginServer.query({ act: 'upkeep', - challstr: tokens[1], + challstr, }, res => { if (!res) return; if (!res.loggedin) return; this.send(`/trn ${res.username},0,${res.assertion}`); }); return; - case 'updateuser': - PS.user.setName(tokens[1], tokens[2] === '1', tokens[3]); + } case 'updateuser': { + const [, fullName, namedCode, avatar] = args; + PS.user.setName(fullName, namedCode === '1', avatar); return; - case 'updatechallenges': - this.receiveChallenges(tokens[1]); + } case 'updatechallenges': { + const [, challengesBuf] = args; + this.receiveChallenges(challengesBuf); return; - case 'queryresponse': - this.handleQueryResponse(tokens[1] as ID, JSON.parse(tokens[2])); + } case 'queryresponse': { + const [, queryId, responseJSON] = args; + this.handleQueryResponse(queryId as ID, JSON.parse(responseJSON)); return; - case 'pm': - this.handlePM(tokens[1], tokens[2], tokens[3]); + } case 'pm': { + const [, user1, user2, message] = args; + this.handlePM(user1, user2, message); return; - case 'formats': - this.parseFormats(tokens); + } case 'formats': { + this.parseFormats(args); return; - case 'popup': - alert(tokens[1]); + } case 'popup': { + const [, message] = args; + alert(message); return; - } + }} const lobby = PS.rooms['lobby']; - if (lobby) lobby.receive(line); + if (lobby) lobby.receiveLine(args); } receiveChallenges(dataBuf: string) { let json; @@ -78,7 +84,7 @@ class MainMenuRoom extends PSRoom { } room.challengedFormat = json.challengesFrom[targetUserid] || null; room.challengingFormat = json.challengeTo?.to === targetUserid ? json.challengeTo.format : null; - room.update(''); + room.update(null); } } parseFormats(formatsList: string[]) { @@ -217,7 +223,7 @@ class MainMenuRoom extends PSRoom { }, true); room = PS.rooms[roomid]!; } - room.receive(`|c|${user1}|${message}`); + room.receiveLine([`c`, user1, message]); PS.update(); } handleQueryResponse(id: ID, response: any) { @@ -231,12 +237,12 @@ class MainMenuRoom extends PSRoom { Object.assign(userdetails, response); } const userRoom = PS.rooms[`user-${userid}`] as UserRoom; - if (userRoom) userRoom.update(''); + if (userRoom) userRoom.update(null); break; case 'rooms': this.roomsCache = response; const roomsRoom = PS.rooms[`rooms`] as RoomsRoom; - if (roomsRoom) roomsRoom.update(''); + if (roomsRoom) roomsRoom.update(null); break; case 'roomlist': const battlesRoom = PS.rooms[`battles`] as BattlesRoom; @@ -248,7 +254,7 @@ class MainMenuRoom extends PSRoom { battles.push(battleTable[battleid]); } battlesRoom.battles = battles; - battlesRoom.update(''); + battlesRoom.update(null); } } } diff --git a/src/panel-topbar.tsx b/src/panel-topbar.tsx index 89ee78b81..9691f16d1 100644 --- a/src/panel-topbar.tsx +++ b/src/panel-topbar.tsx @@ -14,7 +14,8 @@ class PSHeader extends preact.Component<{style: {}}> { const room = PS.rooms[id]!; const closable = (id === '' || id === 'rooms' ? '' : ' closable'); const cur = PS.isVisible(room) ? ' cur' : ''; - let className = `roomtab button${room.notifying}${closable}${cur}`; + const notifying = room.notifications.length ? ' notifying' : room.isSubtleNotifying ? ' subtle-notifying' : ''; + let className = `roomtab button${notifying}${closable}${cur}`; let icon = null; let title = room.title; let closeButton = null; diff --git a/src/panels.tsx b/src/panels.tsx index 524189e80..60b190fc5 100644 --- a/src/panels.tsx +++ b/src/panels.tsx @@ -97,9 +97,9 @@ class PSRoomPanel extends preact.Component<{room: T}> this.props.room.onParentEvent = (id: string, e?: Event) => { if (id === 'focus') this.focus(); }; - this.subscriptions.push(this.props.room.subscribe(message => { - if (!message) this.forceUpdate(); - else this.receive(message); + this.subscriptions.push(this.props.room.subscribe(args => { + if (!args) this.forceUpdate(); + else this.receiveLine(args); })); if (this.base) { this.props.room.setDimensions(this.base.offsetWidth, this.base.offsetHeight); @@ -117,7 +117,7 @@ class PSRoomPanel extends preact.Component<{room: T}> } this.subscriptions = []; } - receive(message: string) {} + receiveLine(args: Args) {} /** * PS has "fake select menus", buttons that act like