/** * Main menu panel * * @author Guangcong Luo * @license AGPLv3 */ import preact from "../js/lib/preact"; import { PSLoginServer } from "./client-connection"; import { Config, PS, PSRoom, type RoomID, type RoomOptions, type Team } from "./client-main"; import { PSIcon, PSPanelWrapper, PSRoomPanel } from "./panels"; import type { BattlesRoom } from "./panel-battle"; import type { ChatRoom } from "./panel-chat"; import type { LadderFormatRoom } from "./panel-ladder"; import type { RoomsRoom } from "./panel-rooms"; import { TeamBox, type SelectType } from "./panel-teamdropdown"; import { Dex, toID, type ID } from "./battle-dex"; import type { Args } from "./battle-text-parser"; import { BattleLog } from "./battle-log"; // optional export type RoomInfo = { title: string, desc?: string, userCount?: number, section?: string, privacy?: 'hidden', spotlight?: string, subRooms?: string[], }; export class MainMenuRoom extends PSRoom { override readonly classType: string = 'mainmenu'; userdetailsCache: { [userid: string]: { userid: ID, name: string, avatar?: string | number, status?: string, group?: string, customgroup?: string, rooms?: { [roomid: string]: { isPrivate?: true, p1?: string, p2?: string } }, }, } = {}; roomsCache: { battleCount?: number, userCount?: number, chat?: RoomInfo[], sectionTitles?: string[], } = {}; searchCountdown: { format: string, packedTeam: string, countdown: number, timer: number } | null = null; /** used to track the moment between "search sent" and "server acknowledged search sent" */ searchSent = false; search: { searching: string[], games: Record | null } = { searching: [], games: null }; disallowSpectators: boolean | null = PS.prefs.disallowspectators; constructor(options: RoomOptions) { super(options); if (this.backlog) { // these aren't set yet, but a lot of things could go wrong if we don't PS.rooms[''] = this; PS.mainmenu = this; for (const args of this.backlog) { this.receiveLine(args); } this.backlog = null; } } adjustPrivacy() { PS.prefs.set('disallowspectators', this.disallowSpectators); if (this.disallowSpectators) return '/noreply /hidenext \n'; return ''; } startSearch = (format: string, team?: Team) => { if (this.searchCountdown) { PS.alert("Wait for this countdown to finish first..."); return; } this.searchCountdown = { format, packedTeam: team?.packedTeam || '', countdown: 3, timer: setInterval(this.doSearchCountdown, 1000), }; this.update(null); }; cancelSearch = () => { if (this.searchCountdown) { clearTimeout(this.searchCountdown.timer); this.searchCountdown = null; this.update(null); return true; } if (this.searchSent || this.search.searching?.length) { this.searchSent = false; PS.send('|/cancelsearch'); this.update(null); return true; } return false; }; doSearchCountdown = () => { if (!this.searchCountdown) return; // ??? race??? this.searchCountdown.countdown--; if (this.searchCountdown.countdown <= 0) { this.doSearch(this.searchCountdown); clearTimeout(this.searchCountdown.timer); this.searchCountdown = null; } this.update(null); }; doSearch = (search: NonNullable) => { this.searchSent = true; const privacy = this.adjustPrivacy(); PS.send(`|/utm ${search.packedTeam}`); PS.send(`|${privacy}/search ${search.format}`); }; override receiveLine(args: Args) { const [cmd] = args; switch (cmd) { case 'challstr': { const [, challstr] = args; PS.user.challstr = challstr; PSLoginServer.query( 'upkeep', { challstr } ).then(res => { if (!res?.username) { PS.user.initializing = false; return; } // | , ; are not valid characters in names res.username = res.username.replace(/[|,;]+/g, ''); if (res.loggedin) { PS.user.registered = { name: res.username, userid: toID(res.username) }; } PS.user.handleAssertion(res.username, res.assertion); }); return; } case 'updateuser': { const [, fullName, namedCode, avatar] = args; const named = namedCode === '1'; if (named) PS.user.initializing = false; PS.user.setName(fullName, named, avatar); PS.teams.loadRemoteTeams(); return; } case 'updatechallenges': { const [, challengesBuf] = args; this.receiveChallenges(challengesBuf); return; } case 'updatesearch': { const [, searchBuf] = args; this.receiveSearch(searchBuf); return; } case 'queryresponse': { const [, queryId, responseJSON] = args; this.handleQueryResponse(queryId as ID, JSON.parse(responseJSON)); return; } 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); return; } case 'popup': { const [, message] = args; PS.alert(message.replace(/\|\|/g, '\n')); return; } } const lobby = PS.rooms['lobby']; if (lobby) lobby.receiveLine(args); } receiveChallenges(dataBuf: string) { let json; try { json = JSON.parse(dataBuf); } catch {} for (const userid in json.challengesFrom) { PS.getPMRoom(toID(userid)); } if (json.challengeTo) { PS.getPMRoom(toID(json.challengeTo.to)); } for (const roomid in PS.rooms) { const room = PS.rooms[roomid] as ChatRoom; if (!room.pmTarget) continue; const targetUserid = toID(room.pmTarget); if (!room.challenged && !(targetUserid in json.challengesFrom) && !room.challenging && json.challengeTo?.to !== targetUserid) { continue; } room.challenged = room.parseChallenge(json.challengesFrom[targetUserid]); room.challenging = json.challengeTo?.to === targetUserid ? room.parseChallenge(json.challengeTo.format) : null; room.update(null); } } receiveSearch(dataBuf: string) { let json; this.searchSent = false; try { json = JSON.parse(dataBuf); } catch {} this.search = json; this.update(null); } parseFormats(formatsList: string[]) { let isSection = false; let section = ''; let column = 0; window.NonBattleGames = { rps: 'Rock Paper Scissors' }; for (let i = 3; i <= 9; i += 2) { window.NonBattleGames[`bestof${i}`] = `Best-of-${i}`; } window.BattleFormats = {}; for (let j = 1; j < formatsList.length; j++) { const entry = formatsList[j]; if (isSection) { section = entry; isSection = false; } else if (entry === ',LL') { PS.teams.usesLocalLadder = true; } else if (entry === '' || (entry.startsWith(',') && !isNaN(Number(entry.slice(1))))) { isSection = true; if (entry) { column = parseInt(entry.slice(1), 10) || 0; } } else { let name = entry; let searchShow = true; let challengeShow = true; let tournamentShow = true; let team: 'preset' | null = null; let teambuilderLevel: number | null = null; let lastCommaIndex = name.lastIndexOf(','); let code = lastCommaIndex >= 0 ? parseInt(name.substr(lastCommaIndex + 1), 16) : NaN; if (!isNaN(code)) { name = name.substr(0, lastCommaIndex); if (code & 1) team = 'preset'; if (!(code & 2)) searchShow = false; if (!(code & 4)) challengeShow = false; if (!(code & 8)) tournamentShow = false; if (code & 16) teambuilderLevel = 50; } else { // Backwards compatibility: late 0.9.0 -> 0.10.0 if (name.substr(name.length - 2) === ',#') { // preset teams team = 'preset'; name = name.substr(0, name.length - 2); } if (name.substr(name.length - 2) === ',,') { // search-only challengeShow = false; name = name.substr(0, name.length - 2); } else if (name.substr(name.length - 1) === ',') { // challenge-only searchShow = false; name = name.substr(0, name.length - 1); } } let id = toID(name); let isTeambuilderFormat = !team && !name.endsWith('Custom Game'); let teambuilderFormat = '' as ID; let teambuilderFormatName = ''; if (isTeambuilderFormat) { teambuilderFormatName = name; if (!id.startsWith('gen')) { teambuilderFormatName = '[Gen 6] ' + name; } let parenPos = teambuilderFormatName.indexOf('('); if (parenPos > 0 && name.endsWith(')')) { // variation of existing tier teambuilderFormatName = teambuilderFormatName.slice(0, parenPos).trim(); } if (teambuilderFormatName !== name) { teambuilderFormat = toID(teambuilderFormatName); if (BattleFormats[teambuilderFormat]) { BattleFormats[teambuilderFormat].isTeambuilderFormat = true; } else { BattleFormats[teambuilderFormat] = { id: teambuilderFormat, name: teambuilderFormatName, team, section, column, rated: false, isTeambuilderFormat: true, effectType: 'Format', }; } isTeambuilderFormat = false; } } if (BattleFormats[id]?.isTeambuilderFormat) { isTeambuilderFormat = true; } // make sure formats aren't out-of-order if (BattleFormats[id]) delete BattleFormats[id]; BattleFormats[id] = { id, name, team, section, column, searchShow, challengeShow, tournamentShow, rated: searchShow && id.substr(4, 7) !== 'unrated', teambuilderLevel, teambuilderFormat, isTeambuilderFormat, effectType: 'Format', }; } } // Match base formats to their variants, if they are unavailable in the server. let multivariantFormats: { [id: string]: 1 } = {}; for (let id in BattleFormats) { let teambuilderFormat = BattleFormats[BattleFormats[id].teambuilderFormat!]; if (!teambuilderFormat || multivariantFormats[teambuilderFormat.id]) continue; if (!teambuilderFormat.searchShow && !teambuilderFormat.challengeShow && !teambuilderFormat.tournamentShow) { // The base format is not available. if (teambuilderFormat.battleFormat) { multivariantFormats[teambuilderFormat.id] = 1; teambuilderFormat.battleFormat = ''; } else { teambuilderFormat.battleFormat = id; } } } PS.teams.update('format'); } handlePM(user1: string, user2: string, message?: string) { const userid1 = toID(user1); const userid2 = toID(user2); const pmTarget = PS.user.userid === userid1 ? user2 : user1; const pmTargetid = PS.user.userid === userid1 ? userid2 : userid1; let roomid = `dm-${pmTargetid}` as RoomID; if (pmTargetid === PS.user.userid) roomid = 'dm-' as RoomID; let room = PS.rooms[roomid] as ChatRoom | undefined; if (!room) { PS.addRoom({ id: roomid, args: { pmTarget }, }, true); room = PS.rooms[roomid] as ChatRoom; } else { room.updateTarget(pmTarget); } if (message) room.receiveLine([`c`, user1, message]); PS.update(); } handleQueryResponse(id: ID, response: any) { switch (id) { case 'userdetails': let userid = response.userid; let userdetails = this.userdetailsCache[userid]; if (!userdetails) { this.userdetailsCache[userid] = response; } else { Object.assign(userdetails, response); } PS.rooms[`user-${userid}`]?.update(null); PS.rooms[`viewuser-${userid}`]?.update(null); PS.rooms[`users`]?.update(null); break; case 'rooms': if (response.pspl) { for (const roomInfo of response.pspl) roomInfo.spotlight = "Spotlight"; response.chat = [...response.pspl, ...response.chat]; response.pspl = null; } if (response.official) { for (const roomInfo of response.official) roomInfo.section = "Official"; response.chat = [...response.official, ...response.chat]; response.official = null; } this.roomsCache = response; const roomsRoom = PS.rooms[`rooms`] as RoomsRoom; if (roomsRoom) roomsRoom.update(null); break; case 'roomlist': const battlesRoom = PS.rooms[`battles`] as BattlesRoom; if (battlesRoom) { const battleTable = response.rooms; const battles = []; for (const battleid in battleTable) { battleTable[battleid].id = battleid; battles.push(battleTable[battleid]); } battlesRoom.battles = battles; battlesRoom.update(null); } break; case 'laddertop': for (const [roomid, ladderRoom] of Object.entries(PS.rooms)) { if (roomid.startsWith('ladder-')) { (ladderRoom as LadderFormatRoom).update(response); } } break; case 'teamupload': if (PS.teams.uploading) { const team = PS.teams.uploading; team.uploaded = { teamid: response.teamid, notLoaded: false, private: response.private, }; PS.rooms[`team-${team.key}`]?.update(null); PS.rooms.teambuilder?.update(null); PS.teams.uploading = null; } break; case 'teamupdate': for (const team of PS.teams.list) { if (team.teamid === response.teamid) { team.uploaded = { teamid: response.teamid, notLoaded: false, private: response.private, }; PS.rooms[`team-${team.key}`]?.update(null); PS.rooms.teambuilder?.update(null); PS.teams.uploading = null; break; } } break; } } } class NewsPanel extends PSRoomPanel { static readonly id = 'news'; static readonly routes = ['news']; static readonly title = 'News'; static readonly location = 'mini-window'; change = (ev: Event) => { const target = ev.currentTarget as HTMLInputElement; if (target.value === '1') { document.cookie = "preactalpha=1; expires=Thu, 1 Jun 2025 12:00:00 UTC; path=/"; } else { document.cookie = "preactalpha=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; } if (target.value === 'leave') { document.location.href = `/`; } }; override render() { const cookieSet = document.cookie.includes('preactalpha=1'); return
This is the Preact client beta test.
Provide feedback in the Dev chatroom.
; } } class MainMenuPanel extends PSRoomPanel { static readonly id = 'mainmenu'; static readonly routes = ['']; static readonly Model = MainMenuRoom; static readonly icon = ; override focus() { this.base?.querySelector('.formatselect')?.focus(); } submitSearch = (ev: Event, format: string, team?: Team) => { if (!PS.user.named) { PS.join('login' as RoomID, { parentElem: this.base!.querySelector('.big.button'), }); return; } PS.mainmenu.startSearch(format, team); }; handleDragStart = (e: DragEvent) => { const room = PS.getRoom(e.currentTarget); if (!room) return; const foreground = (PS.leftPanel.id === room.id || PS.rightPanel?.id === room.id); PS.dragging = { type: 'room', roomid: room.id, foreground }; }; handleDragEnter = (e: DragEvent) => { // console.log('dragenter ' + e.dataTransfer!.dropEffect); e.preventDefault(); if (PS.dragging?.type !== 'room') return; const draggingRoom = PS.dragging.roomid; if (draggingRoom === null) return; const draggedOverRoom = PS.getRoom(e.target as HTMLElement); if (draggingRoom === draggedOverRoom?.id) return; const index = PS.miniRoomList.indexOf(draggedOverRoom?.id as any); if (index >= 0) { PS.dragOnto(PS.rooms[draggingRoom]!, 'mini-window', index); } else if (PS.rooms[draggingRoom]?.location !== 'mini-window') { PS.dragOnto(PS.rooms[draggingRoom]!, 'mini-window', 0); } // dropEffect !== 'none' prevents bounce-back animation in // Chrome/Safari/Opera // if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; }; renderMiniRoom(room: PSRoom) { const RoomType = PS.roomTypes[room.type]; const Panel = RoomType || PSRoomPanel; return ; } handleClickMinimize = (e: MouseEvent) => { if ((e.target as Element)?.getAttribute('data-cmd')) { return; } if (((e.target as Element)?.parentNode as Element)?.getAttribute('data-cmd')) { return; } const room = PS.getRoom(e.currentTarget); if (room) { room.minimized = !room.minimized; this.forceUpdate(); } }; renderMiniRooms() { return PS.miniRoomList.map(roomid => { const room = PS.rooms[roomid]!; const notifying = room.notifications.length ? ' notifying' : room.isSubtleNotifying ? ' subtle-notifying' : ''; return

{room.title}

{this.renderMiniRoom(room)}
; }); } renderGames() { if (!PS.mainmenu.search.games) return null; // This does not use the word "game" because it includes things like help tickets return ; } renderSearchButton() { if (PS.down) { return ; } if (!PS.user.userid || PS.isOffline) { return {PS.isOffline &&

}
; } return

{PS.mainmenu.searchCountdown ? ( <>

) : (PS.mainmenu.searchSent || PS.mainmenu.search.searching.length) ? ( <>

) : ( )}
; } override render() { const onlineButton = ' button' + (PS.isOffline ? ' disabled' : ''); const tinyLayout = this.props.room.width < 620 ? ' tiny-layout' : ''; return
{this.renderMiniRooms()}
; } } export class FormatDropdown extends preact.Component<{ selectType?: SelectType, format?: string, defaultFormat?: string, placeholder?: string, onChange?: JSX.EventHandler, }> { declare base?: HTMLButtonElement; format = ''; change = (e: Event) => { if (!this.base) return; this.format = this.base.value; this.forceUpdate(); if (this.props.onChange) this.props.onChange(e); }; override componentWillMount() { if (this.props.format !== undefined) { this.format = this.props.format; } } render() { this.format ||= this.props.format || this.props.defaultFormat || ''; let [formatName, customRules] = this.format.split('@@@'); if (window.BattleLog) formatName = BattleLog.formatName(formatName); if (this.props.format || PS.mainmenu.searchSent) { return ; } return ; } } class TeamDropdown extends preact.Component<{ format: string }> { teamFormat = ''; teamKey = ''; change = () => { if (!this.base) return; this.teamKey = (this.base as HTMLButtonElement).value; this.forceUpdate(); }; getDefaultTeam(teambuilderFormat: string) { for (const team of PS.teams.list) { if (team.format === teambuilderFormat) return team.key; } return ''; } render() { const teamFormat = PS.teams.teambuilderFormat(this.props.format); const formatData = window.BattleFormats?.[teamFormat]; if (formatData?.team) { return ; } if (teamFormat !== this.teamFormat) { this.teamFormat = teamFormat; this.teamKey = this.getDefaultTeam(teamFormat); } const team = PS.teams.byKey[this.teamKey] || null; return ; } } export class TeamForm extends preact.Component<{ children: preact.ComponentChildren, class?: string, format?: string, teamFormat?: string, hideFormat?: boolean, selectType?: SelectType, onSubmit: ((e: Event, format: string, team?: Team) => void) | null, onValidate?: ((e: Event, format: string, team?: Team) => void) | null, }> { format = ''; changeFormat = (ev: Event) => { this.format = (ev.target as HTMLButtonElement).value; }; submit = (ev: Event, validate?: 'validate') => { ev.preventDefault(); const format = this.format; const teamKey = this.base!.querySelector('button[name=team]')!.value; const team = teamKey ? PS.teams.byKey[teamKey] : undefined; PS.teams.loadTeam(team).then(() => { (validate === 'validate' ? this.props.onValidate : this.props.onSubmit)?.(ev, format, team); }); }; handleClick = (ev: Event) => { let target = ev.target as HTMLButtonElement | null; while (target && target !== this.base) { if (target.tagName === 'BUTTON' && target.name === 'validate') { this.submit(ev, 'validate'); return; } target = target.parentNode as HTMLButtonElement | null; } }; render() { if (window.BattleFormats) { const starredPrefs = PS.prefs.starredformats || {}; // .reverse() because the newest starred format should be the default one const starred = Object.keys(starredPrefs).filter(id => starredPrefs[id] === true).reverse(); if (!this.format) { this.format = `gen${Dex.gen}randombattle`; for (let id of starred) { let format = window.BattleFormats[id]; if (!format) continue; if (this.props.selectType === 'challenge' && format?.challengeShow === false) continue; if (this.props.selectType === 'search' && format?.searchShow === false) continue; if (this.props.selectType === 'teambuilder' && format?.team) continue; this.format = id; break; } } } return
{!this.props.hideFormat &&

}

{this.props.children}

; } } PS.addRoomType(NewsPanel, MainMenuPanel);