From b1fc3b2d9e9f113595543c82f5cac17bd6b626b3 Mon Sep 17 00:00:00 2001 From: Aurastic <33085835+ISenseAura@users.noreply.github.com> Date: Wed, 28 May 2025 14:14:01 +0530 Subject: [PATCH] Preact: Support desktop notifications (#2441) --------- Co-authored-by: Guangcong Luo --- play.pokemonshowdown.com/src/battle-log.ts | 2 +- play.pokemonshowdown.com/src/client-main.ts | 55 ++++++++++++++++++- play.pokemonshowdown.com/src/panel-battle.tsx | 17 ++++++ play.pokemonshowdown.com/src/panel-chat.tsx | 13 ++++- .../src/panel-mainmenu.tsx | 1 + play.pokemonshowdown.com/src/panel-topbar.tsx | 6 ++ play.pokemonshowdown.com/style/client2.css | 17 +++++- 7 files changed, 106 insertions(+), 5 deletions(-) diff --git a/play.pokemonshowdown.com/src/battle-log.ts b/play.pokemonshowdown.com/src/battle-log.ts index da690097d..0a624c2b6 100644 --- a/play.pokemonshowdown.com/src/battle-log.ts +++ b/play.pokemonshowdown.com/src/battle-log.ts @@ -1304,7 +1304,7 @@ export class BattleLog { this.changeUhtml(parts[0], htmlSrc, cmd === 'uhtml'); return ['', '']; case 'raw': - return ['chat', BattleLog.sanitizeHTML(target)]; + return ['chat', BattleLog.sanitizeHTML(target), true]; case 'nonotify': return ['chat', BattleLog.sanitizeHTML(target), true]; default: diff --git a/play.pokemonshowdown.com/src/client-main.ts b/play.pokemonshowdown.com/src/client-main.ts index 0be0017a4..b44c387b4 100644 --- a/play.pokemonshowdown.com/src/client-main.ts +++ b/play.pokemonshowdown.com/src/client-main.ts @@ -899,6 +899,7 @@ interface PSNotificationState { id: string; /** normally: automatically dismiss the notification when viewing the room; set this to require manual dismissing */ noAutoDismiss: boolean; + notification?: Notification | null; } type ClientCommands = { @@ -1003,7 +1004,24 @@ export class PSRoom extends PSStreamModel implements RoomOptions { return null; } notify(options: { title: string, body?: string, noAutoDismiss?: boolean, id?: string }) { - if (PS.isVisible(this)) return; + let desktopNotification: Notification | null = null; + const roomIsFocused = document.hasFocus?.() && PS.isVisible(this); + if (roomIsFocused && !options.noAutoDismiss) return; + if (!roomIsFocused) { + PS.playNotificationSound(); + try { + desktopNotification = new Notification(options.title, { body: options.body }); + if (desktopNotification) { + desktopNotification.onclick = () => { + window.focus(); + PS.focusRoom(this.id); + }; + if (PS.prefs.temporarynotifications) { + setTimeout(() => { desktopNotification?.close(); }, 5000); + } + } + } catch {} + } if (options.noAutoDismiss && !options.id) { throw new Error(`Must specify id for manual dismissing`); } @@ -1015,6 +1033,7 @@ export class PSRoom extends PSStreamModel implements RoomOptions { body: options.body, id: options.id || '', noAutoDismiss: options.noAutoDismiss || false, + notification: desktopNotification, }); PS.update(); } @@ -1024,7 +1043,13 @@ export class PSRoom extends PSStreamModel implements RoomOptions { PS.update(); } dismissNotification(id: string) { - this.notifications = this.notifications.filter(notification => notification.id !== id); + const index = this.notifications.findIndex(n => n.id === id); + if (index !== -1) { + try { + this.notifications[index].notification?.close(); + } catch {} + this.notifications.splice(index, 1); + } PS.update(); } autoDismissNotifications() { @@ -1866,6 +1891,11 @@ export const PS = new class extends PSModel { } } + // for old versions of Safari + if (window.webkitNotification) { + window.Notification ||= window.webkitNotification; + } + this.updateLayout(); window.addEventListener('resize', () => { // super.update() skips another updateLayout() call @@ -2331,10 +2361,12 @@ export const PS = new class extends PSModel { addRoom(options: RoomOptions, noFocus = false) { // support hardcoded PM room-IDs if (options.id.startsWith('challenge-')) { + this.requestNotifications(); options.id = `dm-${options.id.slice(10)}` as RoomID; options.args = { challengeMenuOpen: true }; } if (options.id.startsWith('dm-')) { + this.requestNotifications(); if (options.id.length >= 5 && options.id.endsWith('--')) { options.id = options.id.slice(0, -2) as RoomID; options.args = { initialSlash: true }; @@ -2628,4 +2660,23 @@ export const PS = new class extends PSModel { this.prefs.set('autojoin', autojoin); } } + requestNotifications() { + try { + if (window.webkitNotifications?.requestPermission) { + // Notification.requestPermission crashes Chrome 23: + // https://code.google.com/p/chromium/issues/detail?id=139594 + // In lieu of a way to detect Chrome 23, we'll just use the old + // requestPermission API, which works to request permissions for + // the new Notification spec anyway. + window.webkitNotifications.requestPermission(); + } else if (window.Notification) { + Notification.requestPermission?.(permission => {}); + } + } catch {} + } + playNotificationSound() { + if (window.BattleSound && !this.prefs.mute) { + window.BattleSound.playSound('audio/notification.wav', this.prefs.notifvolume); + } + } }; diff --git a/play.pokemonshowdown.com/src/panel-battle.tsx b/play.pokemonshowdown.com/src/panel-battle.tsx index 42f1139fa..8b6f7740d 100644 --- a/play.pokemonshowdown.com/src/panel-battle.tsx +++ b/play.pokemonshowdown.com/src/panel-battle.tsx @@ -366,8 +366,25 @@ class BattlePanel extends PSRoomPanel { room.request = request; room.choices = new BattleChoiceBuilder(request); + this.notifyRequest(); room.update(null); } + notifyRequest() { + const room = this.props.room; + let oName = room.battle.farSide.name; + if (oName) oName = " against " + oName; + switch (room.request?.requestType) { + case 'move': + room.notify({ title: "Your move!", body: "Move in your battle" + oName }); + break; + case 'switch': + room.notify({ title: "Your switch!", body: "Switch in your battle" + oName }); + break; + case 'team': + room.notify({ title: "Team preview!", body: "Choose your team order in your battle" + oName }); + break; + } + } renderControls() { const room = this.props.room; if (!room.battle) return null; diff --git a/play.pokemonshowdown.com/src/panel-chat.tsx b/play.pokemonshowdown.com/src/panel-chat.tsx index d08e555aa..477175715 100644 --- a/play.pokemonshowdown.com/src/panel-chat.tsx +++ b/play.pokemonshowdown.com/src/panel-chat.tsx @@ -125,7 +125,17 @@ export class ChatRoom extends PSRoom { this.joinLeave = null; this.markUserActive(args[args[0] === 'c:' ? 2 : 1]); if (this.tour) this.tour.joinLeave = null; - this.subtleNotify(); + if (this.id.startsWith("dm-")) { + const fromUser = args[args[0] === 'c:' ? 2 : 1]; + if (toID(fromUser) === PS.user.userid) break; + const message = args[args[0] === 'c:' ? 3 : 2]; + this.notify({ + title: `${this.title}`, + body: message, + }); + } else { + this.subtleNotify(); + } break; case ':': this.timeOffset = Math.trunc(Date.now() / 1000) - (parseInt(args[1], 10) || 0); @@ -1110,6 +1120,7 @@ class ChatPanel extends PSRoomPanel { return false; }; makeChallenge = (e: Event, format: string, team?: Team) => { + PS.requestNotifications(); const room = this.props.room; const packedTeam = team ? team.packedTeam : ''; const privacy = PS.mainmenu.adjustPrivacy(); diff --git a/play.pokemonshowdown.com/src/panel-mainmenu.tsx b/play.pokemonshowdown.com/src/panel-mainmenu.tsx index 2d6749078..517e3cbd2 100644 --- a/play.pokemonshowdown.com/src/panel-mainmenu.tsx +++ b/play.pokemonshowdown.com/src/panel-mainmenu.tsx @@ -65,6 +65,7 @@ export class MainMenuRoom extends PSRoom { return ''; } startSearch = (format: string, team?: Team) => { + PS.requestNotifications(); if (this.searchCountdown) { PS.alert("Wait for this countdown to finish first..."); return; diff --git a/play.pokemonshowdown.com/src/panel-topbar.tsx b/play.pokemonshowdown.com/src/panel-topbar.tsx index ab262c684..dca2ba993 100644 --- a/play.pokemonshowdown.com/src/panel-topbar.tsx +++ b/play.pokemonshowdown.com/src/panel-topbar.tsx @@ -324,6 +324,11 @@ export class PSMiniHeader extends preact.Component { override render() { if (PS.leftPanelWidth !== null) return null; + let notificationsCount = 0; + for (const roomid of PS.leftRoomList) { + const miniNotifications = PS.rooms[roomid]?.notifications; + if (miniNotifications?.length) notificationsCount++; + } const { icon, title } = PSHeader.roomInfo(PS.panel); const userColor = window.BattleLog && `color:${BattleLog.usernameColor(PS.user.userid)}`; const showMenuButton = PSView.narrowMode; @@ -334,6 +339,7 @@ export class PSMiniHeader extends preact.Component { null ) : window.scrollX ? ( ) : ( diff --git a/play.pokemonshowdown.com/style/client2.css b/play.pokemonshowdown.com/style/client2.css index d9d91ddd6..55632cdec 100644 --- a/play.pokemonshowdown.com/style/client2.css +++ b/play.pokemonshowdown.com/style/client2.css @@ -1494,7 +1494,7 @@ pre.textbox.textbox-empty[placeholder]:before { } .userlist-hidden { width: auto; - border-right: 0; + border-right: 0; bottom: auto; overflow: visible; } @@ -2451,3 +2451,18 @@ pre.textbox.textbox-empty[placeholder]:before { .dark .chat.mine { background: rgba(255,255,255,0.05); } + +.notification-badge { + position: absolute; + top: 2px; + left: 13px; + width: 15px; + height: 15px; + background: #e9790d; + color: white; + font-size: 9px; + font-weight: bold; + line-height: 14px; + text-align: center; + border-radius: 50%; +}