mirror of
https://github.com/smogon/pokemon-showdown-client.git
synced 2026-03-21 17:50:29 -05:00
- Fix various reconnect bugs - Move table styling to battle-log - Fix highlighting bugs - Bump cookie expiration another month Trivial - Fix rounding in build time - Fix left border in vertical tabs dark mode - Improve README wording
1332 lines
45 KiB
TypeScript
1332 lines
45 KiB
TypeScript
/**
|
|
* Chat panel
|
|
*
|
|
* @author Guangcong Luo <guangcongluo@gmail.com>
|
|
* @license AGPLv3
|
|
*/
|
|
|
|
import preact from "../js/lib/preact";
|
|
import type { PSSubscription } from "./client-core";
|
|
import { PS, PSRoom, type RoomOptions, type RoomID, type Team } from "./client-main";
|
|
import { PSView, PSPanelWrapper, PSRoomPanel } from "./panels";
|
|
import { TeamForm } from "./panel-mainmenu";
|
|
import { BattleLog } from "./battle-log";
|
|
import type { Battle } from "./battle";
|
|
import { MiniEdit } from "./miniedit";
|
|
import { Dex, PSUtils, toID, type ID } from "./battle-dex";
|
|
import { BattleTextParser, type Args } from "./battle-text-parser";
|
|
import { PSLoginServer } from "./client-connection";
|
|
import type { BattleRoom } from "./panel-battle";
|
|
import { BattleChoiceBuilder } from "./battle-choices";
|
|
import { ChatTournament, TournamentBox } from "./panel-chat-tournament";
|
|
|
|
declare const formatText: any; // from js/server/chat-formatter.js
|
|
|
|
type Challenge = {
|
|
formatName: string,
|
|
teamFormat: string,
|
|
message?: string,
|
|
acceptButtonLabel?: string,
|
|
rejectButtonLabel?: string,
|
|
};
|
|
|
|
export class ChatRoom extends PSRoom {
|
|
override readonly classType: 'chat' | 'battle' = 'chat';
|
|
/** note: includes offline users! use onlineUsers if you need onlineUsers */
|
|
users: { [userid: string]: string } = {};
|
|
/** not equal to onlineUsers.length because guests exist */
|
|
userCount = 0;
|
|
onlineUsers: [ID, string][] = [];
|
|
override readonly canConnect = true;
|
|
|
|
// PM-only properties
|
|
pmTarget: string | null = null;
|
|
challengeMenuOpen = false;
|
|
initialSlash = false;
|
|
challenging: Challenge | null = null;
|
|
challenged: Challenge | null = null;
|
|
/** n.b. this will be null outside of battle rooms */
|
|
battle: Battle | null = null;
|
|
log: BattleLog | null = null;
|
|
tour: ChatTournament | null = null;
|
|
lastMessage: Args | null = null;
|
|
lastMessageTime: number | null = null;
|
|
|
|
joinLeave: { join: string[], leave: string[], messageId: string } | null = null;
|
|
/** in order from least to most recent */
|
|
userActivity: ID[] = [];
|
|
timeOffset = 0;
|
|
static highlightRegExp: Record<string, RegExp | null> | 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.pmTarget);
|
|
this.connect();
|
|
}
|
|
override connect() {
|
|
if (!this.connected) {
|
|
if (this.pmTarget === null) PS.send(`/join ${this.id}`);
|
|
this.connected = true;
|
|
this.connectWhenLoggedIn = false;
|
|
}
|
|
}
|
|
override receiveLine(args: 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]);
|
|
this.handleJoinLeave("join", args[1], args[0] === "J");
|
|
return true;
|
|
|
|
case 'leave': case 'l': case 'L':
|
|
this.removeUser(args[1]);
|
|
this.handleJoinLeave("leave", args[1], args[0] === "L");
|
|
return true;
|
|
|
|
case 'name': case 'n': case 'N':
|
|
this.renameUser(args[1], args[2]);
|
|
break;
|
|
|
|
case 'tournament': case 'tournaments':
|
|
this.tour ||= new ChatTournament(this);
|
|
this.tour.receiveLine(args);
|
|
return;
|
|
|
|
case 'noinit':
|
|
if (this.battle) {
|
|
// check the Replays database
|
|
(this as any as BattleRoom).loadReplay();
|
|
} else {
|
|
this.receiveLine(['bigerror', 'Room does not exist']);
|
|
}
|
|
return;
|
|
case 'expire':
|
|
this.connected = 'expired';
|
|
this.receiveLine(['', `This room has expired (you can't chat in it anymore)`]);
|
|
return;
|
|
|
|
case 'chat': case 'c':
|
|
if (`${args[2]} `.startsWith('/challenge ')) {
|
|
this.updateChallenge(args[1], args[2].slice(11));
|
|
return;
|
|
}
|
|
// falls through
|
|
case 'c:':
|
|
if (args[0] === 'c:') PS.lastMessageTime = args[1];
|
|
this.lastMessage = args;
|
|
this.joinLeave = null;
|
|
this.markUserActive(args[args[0] === 'c:' ? 2 : 1]);
|
|
if (this.tour) this.tour.joinLeave = null;
|
|
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);
|
|
break;
|
|
}
|
|
super.receiveLine(args);
|
|
}
|
|
override handleReconnect(msg: string): boolean | void {
|
|
if (this.battle) {
|
|
this.battle.reset();
|
|
this.battle.stepQueue = [];
|
|
return false;
|
|
} else {
|
|
let lines = msg.split('\n');
|
|
|
|
// cut off starting lines until we get to PS.lastMessage timestamp
|
|
// then cut off roomintro from the end
|
|
let cutOffStart = 0;
|
|
let cutOffEnd = lines.length;
|
|
const cutOffTime = parseInt(PS.lastMessageTime);
|
|
const cutOffExactLine = this.lastMessage ? '|' + this.lastMessage?.join('|') : '';
|
|
let reconnectMessage = '|raw|<div class="infobox">You reconnected.</div>';
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].startsWith('|users|')) {
|
|
this.add(lines[i]);
|
|
}
|
|
if (lines[i] === cutOffExactLine) {
|
|
cutOffStart = i + 1;
|
|
} else if (lines[i].startsWith(`|c:|`)) {
|
|
const time = parseInt(lines[i].split('|')[2] || '');
|
|
if (time < cutOffTime) cutOffStart = i;
|
|
}
|
|
if (lines[i].startsWith('|raw|<div class="infobox"> You joined ')) {
|
|
reconnectMessage = `|raw|<div class="infobox">You reconnected to ${lines[i].slice(38)}`;
|
|
cutOffEnd = i;
|
|
if (!lines[i - 1]) cutOffEnd = i - 1;
|
|
}
|
|
}
|
|
lines = lines.slice(cutOffStart, cutOffEnd);
|
|
|
|
if (lines.length) {
|
|
this.receiveLine([`raw`, `<div class="infobox">You disconnected.</div>`]);
|
|
for (const line of lines) this.receiveLine(BattleTextParser.parseLine(line));
|
|
this.receiveLine(BattleTextParser.parseLine(reconnectMessage));
|
|
}
|
|
this.update(null);
|
|
return true;
|
|
}
|
|
}
|
|
updateTarget(name?: string | null) {
|
|
const selfWithGroup = `${PS.user.group || ' '}${PS.user.name}`;
|
|
if (this.id === 'dm-') {
|
|
this.pmTarget = selfWithGroup;
|
|
this.setUsers(1, [selfWithGroup]);
|
|
this.title = `Console`;
|
|
} else if (this.id.startsWith('dm-')) {
|
|
const id = this.id.slice(3);
|
|
if (toID(name) !== id) name = null;
|
|
name ||= this.pmTarget || id;
|
|
if (/[A-Za-z0-9]/.test(name.charAt(0))) name = ` ${name}`;
|
|
const nameWithGroup = name;
|
|
name = name.slice(1);
|
|
this.pmTarget = name;
|
|
if (!PS.user.userid) {
|
|
this.setUsers(1, [nameWithGroup]);
|
|
} else {
|
|
this.setUsers(2, [nameWithGroup, selfWithGroup]);
|
|
}
|
|
this.title = `[DM] ${nameWithGroup.trim()}`;
|
|
}
|
|
}
|
|
static getHighlight(message: string, roomid: string) {
|
|
let highlights = PS.prefs.highlights || {};
|
|
if (Array.isArray(highlights)) {
|
|
highlights = { global: highlights };
|
|
// Migrate from the old highlight system
|
|
PS.prefs.set('highlights', highlights);
|
|
}
|
|
if (!PS.prefs.noselfhighlight && PS.user.nameRegExp) {
|
|
if (PS.user.nameRegExp?.test(message)) return true;
|
|
}
|
|
if (!this.highlightRegExp) {
|
|
try {
|
|
this.updateHighlightRegExp(highlights);
|
|
} catch {
|
|
// 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;
|
|
}
|
|
}
|
|
const id = PS.server.id + '#' + roomid;
|
|
const globalHighlightsRegExp = this.highlightRegExp?.['global'];
|
|
const roomHighlightsRegExp = this.highlightRegExp?.[id];
|
|
return (((globalHighlightsRegExp?.test(message)) || (roomHighlightsRegExp?.test(message))));
|
|
}
|
|
static updateHighlightRegExp(highlights: Record<string, string[]>) {
|
|
// Enforce boundary for match sides, if a letter on match side is
|
|
// a word character. For example, regular expression "a" matches
|
|
// "a", but not "abc", while regular expression "!" matches
|
|
// "!" and "!abc".
|
|
this.highlightRegExp = {};
|
|
for (let i in highlights) {
|
|
if (!highlights[i].length) {
|
|
this.highlightRegExp[i] = null;
|
|
continue;
|
|
}
|
|
this.highlightRegExp[i] = new RegExp('(?:\\b|(?!\\w))(?:' + highlights[i].join('|') + ')(?:\\b|(?!\\w))', 'i');
|
|
}
|
|
}
|
|
handleHighlight = (args: Args) => {
|
|
let name;
|
|
let message;
|
|
let serverTime = 0;
|
|
if (args[0] === 'c:') {
|
|
serverTime = parseInt(args[1]);
|
|
name = args[2];
|
|
message = args[3];
|
|
} else {
|
|
name = args[1];
|
|
message = args[2];
|
|
}
|
|
if (toID(name) === PS.user.userid) return false;
|
|
if (message.startsWith(`/raw `)) return false;
|
|
|
|
const lastMessageDates = Dex.prefs('logtimes') || (PS.prefs.set('logtimes', {}), Dex.prefs('logtimes'));
|
|
if (!lastMessageDates[PS.server.id]) lastMessageDates[PS.server.id] = {};
|
|
const lastMessageDate = lastMessageDates[PS.server.id][this.id] || 0;
|
|
// because the time offset to the server can vary slightly, subtract it to not have it affect comparisons between dates
|
|
const time = serverTime - (this.timeOffset || 0);
|
|
if (PS.isVisible(this)) {
|
|
this.lastMessageTime = null;
|
|
lastMessageDates[PS.server.id][this.id] = time;
|
|
PS.prefs.set('logtimes', lastMessageDates);
|
|
} else {
|
|
// To be saved on focus
|
|
const lastMessageTime = this.lastMessageTime || 0;
|
|
if (lastMessageTime < time) this.lastMessageTime = time;
|
|
}
|
|
if (ChatRoom.getHighlight(message, this.id)) {
|
|
const mayNotify = time > lastMessageDate;
|
|
if (mayNotify) this.notify({
|
|
title: `Mentioned by ${name} in ${this.id}`,
|
|
body: `"${message}"`,
|
|
id: 'highlight',
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
override clientCommands = this.parseClientCommands({
|
|
'chall,challenge'(target) {
|
|
if (target) {
|
|
const [targetUser, format] = target.split(',');
|
|
PS.join(`challenge-${toID(targetUser)}` as RoomID);
|
|
return;
|
|
}
|
|
this.openChallenge();
|
|
},
|
|
'cchall,cancelchallenge'(target) {
|
|
this.cancelChallenge();
|
|
},
|
|
'reject'(target) {
|
|
this.challenged = null;
|
|
this.update(null);
|
|
},
|
|
'clear'() {
|
|
this.log?.reset();
|
|
this.update(null);
|
|
},
|
|
'rank,ranking,rating,ladder'(target) {
|
|
let arg = target;
|
|
if (!arg) {
|
|
arg = PS.user.userid;
|
|
}
|
|
if (this.battle && !arg.includes(',')) {
|
|
arg += ", " + this.id.split('-')[1];
|
|
}
|
|
|
|
const targets = arg.split(',');
|
|
let formatTargeting = false;
|
|
const formats: { [key: string]: number } = {};
|
|
const gens: { [key: string]: number } = {};
|
|
for (let i = 1, len = targets.length; i < len; i++) {
|
|
targets[i] = $.trim(targets[i]);
|
|
if (targets[i].length === 4 && targets[i].startsWith('gen')) {
|
|
gens[targets[i]] = 1;
|
|
} else {
|
|
formats[toID(targets[i])] = 1;
|
|
}
|
|
formatTargeting = true;
|
|
}
|
|
|
|
PSLoginServer.query("ladderget", {
|
|
user: targets[0],
|
|
}).then(data => {
|
|
if (!data || !Array.isArray(data)) return this.add(`|error|Error: corrupted ranking data`);
|
|
let buffer = `<div class="ladder"><table><tr><td colspan="9">User: <strong>${toID(targets[0])}</strong></td></tr>`;
|
|
if (!data.length) {
|
|
buffer += '<tr><td colspan="9"><em>This user has not played any ladder games yet.</em></td></tr>';
|
|
buffer += '</table></div>';
|
|
return this.add(`|html|${buffer}`);
|
|
}
|
|
buffer += '<tr><th>Format</th><th><abbr title="Elo rating">Elo</abbr></th><th><abbr title="user\'s percentage chance of winning a random battle (aka GLIXARE)">GXE</abbr></th><th><abbr title="Glicko-1 rating: rating ± deviation">Glicko-1</abbr></th><th>COIL</th><th>W</th><th>L</th><th>Total</th>';
|
|
let suspect = false;
|
|
for (const item of data) {
|
|
if ('suspect' in item) suspect = true;
|
|
}
|
|
if (suspect) buffer += '<th>Suspect reqs possible?</th>';
|
|
buffer += '</tr>';
|
|
const hiddenFormats = [];
|
|
for (const row of data) {
|
|
if (!row) return this.add(`|error|Error: corrupted ranking data`);
|
|
const formatId = toID(row.formatid);
|
|
if (!formatTargeting ||
|
|
formats[formatId] ||
|
|
gens[formatId.slice(0, 4)] ||
|
|
(gens['gen6'] && !formatId.startsWith('gen'))) {
|
|
buffer += '<tr>';
|
|
} else {
|
|
buffer += '<tr class="hidden">';
|
|
hiddenFormats.push(window.BattleLog.escapeFormat(formatId, true));
|
|
}
|
|
|
|
// Validate all the numerical data
|
|
for (const value of [row.elo, row.rpr, row.rprd, row.gxe, row.w, row.l, row.t]) {
|
|
if (typeof value !== 'number' && typeof value !== 'string') {
|
|
return this.add(`|error|Error: corrupted ranking data`);
|
|
}
|
|
}
|
|
|
|
buffer += `<td> ${BattleLog.escapeHTML(BattleLog.formatName(formatId, true))} </td><td><strong>${Math.round(row.elo)}</strong></td>`;
|
|
if (row.rprd > 100) {
|
|
// High rating deviation. Provisional rating.
|
|
buffer += `<td>–</td>`;
|
|
buffer += `<td><span style="color:#888"><em>${Math.round(row.rpr)} <small> ± ${Math.round(row.rprd)} </small></em> <small>(provisional)</small></span></td>`;
|
|
} else {
|
|
buffer += `<td>${Math.trunc(row.gxe)}<small>.${row.gxe.toFixed(1).slice(-1)}%</small></td>`;
|
|
buffer += `<td><em>${Math.round(row.rpr)} <small> ± ${Math.round(row.rprd)}</small></em></td>`;
|
|
}
|
|
const N = parseInt(row.w, 10) + parseInt(row.l, 10) + parseInt(row.t, 10);
|
|
const COIL_B = undefined;
|
|
|
|
// Uncomment this after LadderRoom logic is implemented
|
|
// COIL_B = LadderRoom?.COIL_B[formatId];
|
|
|
|
if (COIL_B) {
|
|
buffer += `<td>${Math.round(40.0 * parseFloat(row.gxe) * 2.0 ** (-COIL_B / N))}</td>`;
|
|
} else {
|
|
buffer += '<td>—</td>';
|
|
}
|
|
buffer += `<td> ${row.w} </td><td> ${row.l} </td><td> ${N} </td>`;
|
|
if (suspect) {
|
|
if (typeof row.suspect === 'undefined') {
|
|
buffer += '<td>—</td>';
|
|
} else {
|
|
buffer += '<td>';
|
|
buffer += (row.suspect ? "Yes" : "No");
|
|
buffer += '</td>';
|
|
}
|
|
}
|
|
buffer += '</tr>';
|
|
}
|
|
if (hiddenFormats.length) {
|
|
if (hiddenFormats.length === data.length) {
|
|
const formatsText = Object.keys(gens).concat(Object.keys(formats)).join(', ');
|
|
buffer += `<tr class="no-matches"><td colspan="8">` +
|
|
BattleLog.html`<em>This user has not played any ladder games that match ${formatsText}.</em></td></tr>`;
|
|
}
|
|
const otherFormats = hiddenFormats.slice(0, 3).join(', ') +
|
|
(hiddenFormats.length > 3 ? ` and ${hiddenFormats.length - 3} other formats` : '');
|
|
buffer += `<tr><td colspan="8"><button name="showOtherFormats">` +
|
|
BattleLog.html`${otherFormats} not shown</button></td></tr>`;
|
|
}
|
|
let userid = toID(targets[0]);
|
|
let registered = PS.user.registered;
|
|
if (registered && PS.user.userid === userid) {
|
|
buffer += `<tr><td colspan="8" style="text-align:right"><a href="//${PS.routes.users}/${userid}">Reset W/L</a></tr></td>`;
|
|
}
|
|
buffer += '</table></div>';
|
|
this.add(`|html|${buffer}`);
|
|
});
|
|
},
|
|
|
|
// battle-specific commands
|
|
// ------------------------
|
|
'play'() {
|
|
if (!this.battle) return this.add('|error|You are not in a battle');
|
|
if (this.battle.atQueueEnd) {
|
|
this.battle.reset();
|
|
}
|
|
this.battle.play();
|
|
this.update(null);
|
|
},
|
|
'pause'() {
|
|
if (!this.battle) return this.add('|error|You are not in a battle');
|
|
this.battle.pause();
|
|
this.update(null);
|
|
},
|
|
'ffto,fastfowardto'(target) {
|
|
if (!this.battle) return this.add('|error|You are not in a battle');
|
|
let turnNum = Number(target);
|
|
if (target.startsWith('+') || turnNum < 0) {
|
|
turnNum += this.battle.turn;
|
|
if (turnNum < 0) turnNum = 0;
|
|
} else if (target === 'end') {
|
|
turnNum = Infinity;
|
|
}
|
|
if (isNaN(turnNum)) {
|
|
this.receiveLine([`error`, `/ffto - Invalid turn number: ${target}`]);
|
|
return;
|
|
}
|
|
this.battle.seekTurn(turnNum);
|
|
this.update(null);
|
|
},
|
|
'switchsides'() {
|
|
if (!this.battle) return this.add('|error|You are not in a battle');
|
|
this.battle.switchViewpoint();
|
|
},
|
|
'cancel,undo'() {
|
|
if (!this.battle) return this.send('/cancelchallenge');
|
|
|
|
const room = this as any as BattleRoom;
|
|
if (!room.choices || !room.request) {
|
|
this.receiveLine([`error`, `/choose - You are not a player in this battle`]);
|
|
return;
|
|
}
|
|
if (room.choices.isDone() || room.choices.isEmpty()) {
|
|
// we _could_ check choices.noCancel, but the server will check anyway
|
|
this.sendDirect('/undo');
|
|
}
|
|
room.choices = new BattleChoiceBuilder(room.request);
|
|
this.update(null);
|
|
},
|
|
'move,switch,team,pass,shift,choose'(target, cmd) {
|
|
if (!this.battle) return this.add('|error|You are not in a battle');
|
|
const room = this as any as BattleRoom;
|
|
if (!room.choices) {
|
|
this.receiveLine([`error`, `/choose - You are not a player in this battle`]);
|
|
return;
|
|
}
|
|
if (cmd !== 'choose') target = `${cmd} ${target}`;
|
|
if (target === 'choose auto' || target === 'choose default') {
|
|
this.sendDirect('/choose default');
|
|
return;
|
|
}
|
|
const possibleError = room.choices.addChoice(target);
|
|
if (possibleError) {
|
|
this.errorReply(possibleError);
|
|
return;
|
|
}
|
|
if (room.choices.isDone()) this.sendDirect(`/choose ${room.choices.toString()}`);
|
|
this.update(null);
|
|
},
|
|
});
|
|
openChallenge() {
|
|
if (!this.pmTarget) {
|
|
this.add(`|error|Can only be used in a PM.`);
|
|
return;
|
|
}
|
|
this.challengeMenuOpen = true;
|
|
this.update(null);
|
|
}
|
|
cancelChallenge() {
|
|
if (!this.pmTarget) {
|
|
this.add(`|error|Can only be used in a PM.`);
|
|
return;
|
|
}
|
|
if (this.challenging) {
|
|
this.sendDirect('/cancelchallenge');
|
|
this.challenging = null;
|
|
this.challengeMenuOpen = true;
|
|
} else {
|
|
this.challengeMenuOpen = false;
|
|
}
|
|
this.update(null);
|
|
}
|
|
parseChallenge(challengeString: string | null): Challenge | null {
|
|
if (!challengeString) return null;
|
|
|
|
let splitChallenge = challengeString.split('|');
|
|
|
|
const challenge = {
|
|
formatName: splitChallenge[0],
|
|
teamFormat: splitChallenge[1] ?? splitChallenge[0],
|
|
message: splitChallenge[2],
|
|
acceptButtonLabel: splitChallenge[3],
|
|
rejectButtonLabel: splitChallenge[4],
|
|
};
|
|
if (!challenge.formatName && !challenge.message) {
|
|
return null;
|
|
}
|
|
return challenge;
|
|
}
|
|
updateChallenge(name: string, challengeString: string) {
|
|
const challenge = this.parseChallenge(challengeString);
|
|
const userid = toID(name);
|
|
|
|
if (userid === PS.user.userid) {
|
|
if (!challenge && !this.challenging) {
|
|
// this is also used for canceling challenges
|
|
this.challenged = null;
|
|
}
|
|
// we are sending the challenge
|
|
this.challenging = challenge;
|
|
} else {
|
|
if (!challenge && !this.challenged) {
|
|
// this is also used for rejecting challenges
|
|
this.challenging = null;
|
|
}
|
|
this.challenged = challenge;
|
|
if (challenge) {
|
|
this.notify({
|
|
title: `Challenge from ${name}`,
|
|
body: `Format: ${BattleLog.formatName(challenge.formatName)}`,
|
|
id: 'challenge',
|
|
});
|
|
// app.playNotificationSound();
|
|
}
|
|
}
|
|
this.update(null);
|
|
}
|
|
markUserActive(name: string) {
|
|
const userid = toID(name);
|
|
const idx = this.userActivity.indexOf(userid);
|
|
this.users[userid] = name;
|
|
if (idx !== -1) {
|
|
this.userActivity.splice(idx, 1);
|
|
}
|
|
this.userActivity.push(userid);
|
|
if (this.userActivity.length > 100) {
|
|
// Prune the list
|
|
this.userActivity.splice(0, 20);
|
|
}
|
|
}
|
|
override sendDirect(line: string) {
|
|
if (this.pmTarget) {
|
|
line = line.split('\n').filter(Boolean).map(row => `/pm ${this.pmTarget!}, ${row}`).join('\n');
|
|
PS.send(line);
|
|
return;
|
|
}
|
|
super.sendDirect(line);
|
|
}
|
|
setUsers(count: number, usernames: string[]) {
|
|
this.userCount = count;
|
|
this.onlineUsers = [];
|
|
for (const username of usernames) {
|
|
const userid = toID(username);
|
|
this.users[userid] = username;
|
|
this.onlineUsers.push([userid, username]);
|
|
}
|
|
this.sortOnlineUsers();
|
|
this.update(null);
|
|
}
|
|
sortOnlineUsers() {
|
|
PSUtils.sortBy(this.onlineUsers, ([id, name]) => (
|
|
[PS.server.getGroup(name.charAt(0)).order, !name.endsWith('@!'), id]
|
|
));
|
|
}
|
|
addUser(username: string) {
|
|
if (!username) return;
|
|
|
|
const userid = toID(username);
|
|
this.users[userid] = username;
|
|
const index = this.onlineUsers.findIndex(([curUserid]) => curUserid === userid);
|
|
if (index >= 0) {
|
|
this.onlineUsers[index] = [userid, username];
|
|
} else {
|
|
this.userCount++;
|
|
this.onlineUsers.push([userid, username]);
|
|
this.sortOnlineUsers();
|
|
}
|
|
this.update(null);
|
|
}
|
|
removeUser(username: string, noUpdate?: boolean) {
|
|
if (!username) return;
|
|
|
|
const userid = toID(username);
|
|
const index = this.onlineUsers.findIndex(([curUserid]) => curUserid === userid);
|
|
if (index >= 0) {
|
|
this.userCount--;
|
|
this.onlineUsers.splice(index, 1);
|
|
if (!noUpdate) this.update(null);
|
|
}
|
|
}
|
|
renameUser(username: string, oldUsername: string) {
|
|
this.removeUser(oldUsername, true);
|
|
this.addUser(username);
|
|
this.update(null);
|
|
}
|
|
|
|
handleJoinLeave(action: 'join' | 'leave', name: string, silent: boolean) {
|
|
if (action === 'join') {
|
|
this.addUser(name);
|
|
} else if (action === 'leave') {
|
|
this.removeUser(name);
|
|
}
|
|
const showjoins = PS.prefs.showjoins?.[PS.server.id];
|
|
if (!(showjoins?.[this.id] ?? showjoins?.['global'] ?? !silent)) return;
|
|
|
|
this.joinLeave ||= {
|
|
join: [],
|
|
leave: [],
|
|
messageId: `joinleave-${Date.now()}`,
|
|
};
|
|
if (action === 'join' && this.joinLeave['leave'].includes(name)) {
|
|
this.joinLeave['leave'].splice(this.joinLeave['leave'].indexOf(name), 1);
|
|
} else if (action === 'leave' && this.joinLeave['join'].includes(name)) {
|
|
this.joinLeave['join'].splice(this.joinLeave['join'].indexOf(name), 1);
|
|
} else {
|
|
this.joinLeave[action].push(name);
|
|
}
|
|
|
|
let message = this.formatJoinLeave(this.joinLeave['join'], 'joined');
|
|
if (this.joinLeave['join'].length && this.joinLeave['leave'].length) message += '; ';
|
|
message += this.formatJoinLeave(this.joinLeave['leave'], 'left');
|
|
|
|
this.add(`|uhtml|${this.joinLeave.messageId}|<small style="color: #555555">${message}</small>`);
|
|
}
|
|
|
|
formatJoinLeave(preList: string[], action: 'joined' | 'left') {
|
|
if (!preList.length) return '';
|
|
|
|
let message = '';
|
|
let list: string[] = [];
|
|
let named: { [key: string]: boolean } = {};
|
|
for (let item of preList) {
|
|
if (!named[item]) list.push(item);
|
|
named[item] = true;
|
|
}
|
|
for (let j = 0; j < list.length; j++) {
|
|
if (j >= 5) {
|
|
message += `, and ${(list.length - 5)} others`;
|
|
break;
|
|
}
|
|
if (j > 0) {
|
|
if (j === 1 && list.length === 2) {
|
|
message += ' and ';
|
|
} else if (j === list.length - 1) {
|
|
message += ', and ';
|
|
} else {
|
|
message += ', ';
|
|
}
|
|
}
|
|
message += BattleLog.escapeHTML(list[j]);
|
|
}
|
|
return `${message} ${action}`;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
export class CopyableURLBox extends preact.Component<{ url: string }> {
|
|
copy = () => {
|
|
const input = this.base!.children[0] as HTMLInputElement;
|
|
input.select();
|
|
document.execCommand('copy');
|
|
};
|
|
override render() {
|
|
return <div>
|
|
<input
|
|
type="text" class="textbox" readOnly size={45} value={this.props.url}
|
|
style="field-sizing:content"
|
|
/> {}
|
|
<button class="button" onClick={this.copy}>Copy</button> {}
|
|
<a href={this.props.url} target="_blank" class="no-panel-intercept">
|
|
<button class="button">Visit</button>
|
|
</a>
|
|
</div>;
|
|
}
|
|
}
|
|
|
|
export class ChatTextEntry extends preact.Component<{
|
|
room: ChatRoom, onMessage: (msg: string, elem: HTMLElement) => void, onKey: (e: KeyboardEvent) => boolean,
|
|
left?: number, tinyLayout?: boolean,
|
|
}> {
|
|
subscription: PSSubscription | null = null;
|
|
textbox: HTMLTextAreaElement = null!;
|
|
miniedit: MiniEdit | null = null;
|
|
history: string[] = [];
|
|
historyIndex = 0;
|
|
tabComplete: {
|
|
candidates: { userid: string, prefixIndex: number }[],
|
|
candidateIndex: number,
|
|
/** the text left of the cursor before tab completing */
|
|
prefix: string,
|
|
/** the text left of the cursor after tab completing */
|
|
cursor: string,
|
|
} | null = null;
|
|
override componentDidMount() {
|
|
this.subscription = PS.user.subscribe(() => {
|
|
this.forceUpdate();
|
|
});
|
|
const textbox = this.base!.children[0].children[1] as HTMLElement;
|
|
if (textbox.tagName === 'TEXTAREA') this.textbox = textbox as HTMLTextAreaElement;
|
|
this.miniedit = new MiniEdit(textbox, {
|
|
setContent: text => {
|
|
textbox.innerHTML = formatText(text, false, false, true) + '\n';
|
|
textbox.classList?.toggle('textbox-empty', !text);
|
|
},
|
|
onKeyDown: this.onKeyDown,
|
|
});
|
|
if (this.props.room.args?.initialSlash) {
|
|
this.props.room.args.initialSlash = false;
|
|
this.setValue('/', 1);
|
|
}
|
|
if (this.base) this.update();
|
|
}
|
|
override componentWillUnmount() {
|
|
if (this.subscription) {
|
|
this.subscription.unsubscribe();
|
|
this.subscription = null;
|
|
}
|
|
}
|
|
update = () => {
|
|
if (!this.miniedit) {
|
|
const textbox = this.textbox;
|
|
textbox.style.height = `12px`;
|
|
const newHeight = Math.min(Math.max(textbox.scrollHeight - 2, 16), 600);
|
|
textbox.style.height = `${newHeight}px`;
|
|
}
|
|
};
|
|
focusIfNoSelection = (e: Event) => {
|
|
if ((e.target as HTMLElement).tagName === 'TEXTAREA') return;
|
|
const selection = window.getSelection()!;
|
|
if (selection.type === 'Range') return;
|
|
const elem = this.base!.children[0].children[1] as HTMLTextAreaElement;
|
|
elem.focus();
|
|
};
|
|
submit() {
|
|
this.props.onMessage(this.getValue(), this.miniedit?.element || this.textbox);
|
|
this.historyPush(this.getValue());
|
|
this.setValue('', 0);
|
|
this.update();
|
|
return true;
|
|
}
|
|
onKeyDown = (e: KeyboardEvent) => {
|
|
if (this.handleKey(e) || this.props.onKey(e)) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
};
|
|
|
|
// Direct manipulation functions
|
|
getValue() {
|
|
return this.miniedit ? this.miniedit.getValue() : this.textbox.value;
|
|
}
|
|
setValue(value: string, start: number, end = start) {
|
|
if (this.miniedit) {
|
|
this.miniedit.setValue(value, { start, end });
|
|
} else {
|
|
this.textbox.value = value;
|
|
this.textbox.setSelectionRange?.(start, end);
|
|
}
|
|
}
|
|
getSelection() {
|
|
const value = this.getValue();
|
|
let { start, end } = this.miniedit ?
|
|
(this.miniedit.getSelection() || { start: value.length, end: value.length }) :
|
|
{ start: this.textbox.selectionStart, end: this.textbox.selectionEnd };
|
|
return { value, start, end };
|
|
}
|
|
setSelection(start: number, end: number) {
|
|
if (this.miniedit) {
|
|
this.miniedit.setSelection({ start, end });
|
|
} else {
|
|
this.textbox.setSelectionRange?.(start, end);
|
|
}
|
|
}
|
|
replaceSelection(text: string) {
|
|
if (this.miniedit) {
|
|
this.miniedit.replaceSelection(text);
|
|
} else {
|
|
const { value, start, end } = this.getSelection();
|
|
const newSelection = start + text.length;
|
|
this.setValue(value.slice(0, start) + text + value.slice(end), newSelection);
|
|
}
|
|
}
|
|
|
|
historyUp(ifSelectionCorrect?: boolean) {
|
|
if (ifSelectionCorrect) {
|
|
const { value, start, end } = this.getSelection();
|
|
if (start !== end) return false; // never traverse history if text is selected
|
|
if (end !== 0) {
|
|
if (end < value.length) return false; // only go up at start or end of line
|
|
}
|
|
}
|
|
|
|
if (this.historyIndex === 0) return false;
|
|
const line = this.getValue();
|
|
if (line !== '') this.history[this.historyIndex] = line;
|
|
const newValue = this.history[--this.historyIndex];
|
|
this.setValue(newValue, newValue.length);
|
|
return true;
|
|
}
|
|
historyDown(ifSelectionCorrect?: boolean) {
|
|
if (ifSelectionCorrect) {
|
|
const { value, start, end } = this.getSelection();
|
|
if (start !== end) return false; // never traverse history if text is selected
|
|
if (end < value.length) return false; // only go down at end of line
|
|
}
|
|
|
|
const line = this.getValue();
|
|
if (line !== '') this.history[this.historyIndex] = line;
|
|
if (this.historyIndex === this.history.length) {
|
|
if (!line) return false;
|
|
this.setValue('', 0);
|
|
} else if (++this.historyIndex === this.history.length) {
|
|
this.setValue('', 0);
|
|
} else {
|
|
const newValue = this.history[this.historyIndex];
|
|
this.setValue(newValue, newValue.length);
|
|
}
|
|
return true;
|
|
}
|
|
historyPush(line: string) {
|
|
const duplicateIndex = this.history.lastIndexOf(line);
|
|
if (duplicateIndex >= 0) this.history.splice(duplicateIndex, 1);
|
|
if (this.history.length > 100) this.history.splice(0, 20);
|
|
this.history.push(line);
|
|
this.historyIndex = this.history.length;
|
|
}
|
|
handleKey(ev: KeyboardEvent) {
|
|
const cmdKey = ((ev.metaKey ? 1 : 0) + (ev.ctrlKey ? 1 : 0) === 1) && !ev.altKey && !ev.shiftKey;
|
|
// const anyModifier = ev.ctrlKey || ev.altKey || ev.metaKey || ev.shiftKey;
|
|
if (ev.keyCode === 13 && !ev.shiftKey) { // Enter key
|
|
return this.submit();
|
|
} else if (ev.keyCode === 13) { // enter
|
|
this.replaceSelection('\n');
|
|
return true;
|
|
} else if (ev.keyCode === 73 && cmdKey) { // Ctrl + I key
|
|
return this.toggleFormatChar('_');
|
|
} else if (ev.keyCode === 66 && cmdKey) { // Ctrl + B key
|
|
return this.toggleFormatChar('*');
|
|
} else if (ev.keyCode === 192 && cmdKey) { // Ctrl + ` key
|
|
return this.toggleFormatChar('`');
|
|
} else if (ev.keyCode === 9 && !ev.ctrlKey) { // Tab key
|
|
const reverse = !!ev.shiftKey; // Shift+Tab reverses direction
|
|
return this.handleTabComplete(reverse);
|
|
} else if (ev.keyCode === 38 && !ev.shiftKey && !ev.altKey) { // Up key
|
|
return this.historyUp(true);
|
|
} else if (ev.keyCode === 40 && !ev.shiftKey && !ev.altKey) { // Down key
|
|
return this.historyDown(true);
|
|
} else if (ev.keyCode === 27) { // esc
|
|
if (this.undoTabComplete()) {
|
|
return true;
|
|
}
|
|
if (PS.room !== PS.panel) { // only close if in mini-room mode
|
|
PS.leave(PS.room.id);
|
|
return true;
|
|
}
|
|
// } else if (e.keyCode === 32 && PS.user.lastPM && ['/reply', '/r', '/R'].includes(this.getValue())) { // '/reply ' is being written
|
|
// const newValue = `/pm ${PS.user.lastPM}, `;
|
|
// this.setValue(newValue, newValue.length);
|
|
// return true;
|
|
}
|
|
return false;
|
|
}
|
|
// TODO - add support for commands tabcomplete
|
|
handleTabComplete(reverse: boolean) {
|
|
// Don't tab complete at the start of the text box.
|
|
let { value, start, end } = this.getSelection();
|
|
if (start !== end || end === 0) return false;
|
|
|
|
const users = this.props.room.users;
|
|
let prefix = value.slice(0, end);
|
|
if (this.tabComplete && prefix === this.tabComplete.cursor) {
|
|
// The user is cycling through the candidate names.
|
|
if (reverse) {
|
|
this.tabComplete.candidateIndex--;
|
|
if (this.tabComplete.candidateIndex < 0) {
|
|
this.tabComplete.candidateIndex = this.tabComplete.candidates.length - 1;
|
|
}
|
|
} else {
|
|
this.tabComplete.candidateIndex++;
|
|
if (this.tabComplete.candidateIndex >= this.tabComplete.candidates.length) {
|
|
this.tabComplete.candidateIndex = 0;
|
|
}
|
|
}
|
|
} else if (!value || reverse) {
|
|
// not tab completing - let them focus things
|
|
return false;
|
|
} else {
|
|
// This is a new tab completion.
|
|
// There needs to be non-whitespace to the left of the cursor.
|
|
// no command prefixes either, we're testing for usernames here.
|
|
prefix = prefix.trim();
|
|
|
|
/** match of the closest word left of the cursor */
|
|
const match1 = /^([\s\S!/]*?)([A-Za-z0-9][^, \n]*)$/.exec(prefix);
|
|
/** match of the closest two words left of the cursor */
|
|
const match2 = /^([\s\S!/]*?)([A-Za-z0-9][^, \n]* [^, ]*)$/.exec(prefix);
|
|
if (!match1 && !match2) return true;
|
|
|
|
const idprefix = (match1 ? toID(match1[2]) : '');
|
|
let spaceprefix = (match2 ? match2[2].replace(/[^A-Za-z0-9 ]+/g, '').toLowerCase() : '');
|
|
const candidates: { userid: string, prefixIndex: number }[] = [];
|
|
if (match2 && (match2[0] === '/' || match2[0] === '!')) spaceprefix = '';
|
|
for (const userid in users) {
|
|
if (spaceprefix && users[userid].slice(1).replace(/[^A-Za-z0-9 ]+/g, '')
|
|
.toLowerCase()
|
|
.startsWith(spaceprefix)) {
|
|
if (match2) candidates.push({ userid, prefixIndex: match2[1].length });
|
|
} else if (idprefix && userid.startsWith(idprefix)) {
|
|
if (match1) candidates.push({ userid, prefixIndex: match1[1].length });
|
|
}
|
|
}
|
|
// Sort by most recent to speak in the chat, or, in the case of a tie,
|
|
// in alphabetical order.
|
|
const userActivity = this.props.room.userActivity;
|
|
candidates.sort((a, b) => {
|
|
if (a.prefixIndex !== b.prefixIndex) {
|
|
// shorter prefix length comes first
|
|
return a.prefixIndex - b.prefixIndex;
|
|
}
|
|
const aIndex = userActivity?.indexOf(a.userid as ID) ?? -1;
|
|
const bIndex = userActivity?.indexOf(b.userid as ID) ?? -1;
|
|
if (aIndex !== bIndex) {
|
|
return bIndex - aIndex; // -1 is fortunately already in the correct order
|
|
}
|
|
return (a.userid < b.userid) ? -1 : 1; // alphabetical order
|
|
});
|
|
|
|
if (!candidates.length) {
|
|
this.tabComplete = null;
|
|
return true;
|
|
}
|
|
this.tabComplete = {
|
|
candidates,
|
|
candidateIndex: 0,
|
|
prefix,
|
|
cursor: prefix,
|
|
};
|
|
}
|
|
// Substitute in the tab-completed name
|
|
const candidate = this.tabComplete.candidates[this.tabComplete.candidateIndex];
|
|
let name = users[candidate.userid];
|
|
if (!name) return true;
|
|
|
|
name = Dex.getShortName(name.slice(1)); // Remove rank and busy characters
|
|
const cursor = this.tabComplete.prefix.slice(0, candidate.prefixIndex) + name;
|
|
this.setValue(cursor + value.slice(end), cursor.length);
|
|
this.tabComplete.cursor = cursor;
|
|
return true;
|
|
}
|
|
undoTabComplete() {
|
|
if (!this.tabComplete) return false;
|
|
const value = this.getValue();
|
|
if (!value.startsWith(this.tabComplete.cursor)) return false;
|
|
|
|
this.setValue(this.tabComplete.prefix + value.slice(this.tabComplete.cursor.length), this.tabComplete.prefix.length);
|
|
this.tabComplete = null;
|
|
return true;
|
|
}
|
|
toggleFormatChar(formatChar: string) {
|
|
let { value, start, end } = this.getSelection();
|
|
|
|
// make sure start and end aren't midway through the syntax
|
|
if (value.charAt(start) === formatChar && value.charAt(start - 1) === formatChar &&
|
|
value.charAt(start - 2) !== formatChar) {
|
|
start++;
|
|
}
|
|
if (value.charAt(end) === formatChar && value.charAt(end - 1) === formatChar &&
|
|
value.charAt(end - 2) !== formatChar) {
|
|
end--;
|
|
}
|
|
|
|
// wrap in doubled format char
|
|
const wrap = formatChar + formatChar;
|
|
value = value.slice(0, start) + wrap + value.slice(start, end) + wrap + value.slice(end);
|
|
start += 2;
|
|
end += 2;
|
|
|
|
// prevent nesting
|
|
const nesting = wrap + wrap;
|
|
if (value.slice(start - 4, start) === nesting) {
|
|
value = value.slice(0, start - 4) + value.slice(start);
|
|
start -= 4;
|
|
end -= 4;
|
|
} else if (start !== end && value.slice(start - 2, start + 2) === nesting) {
|
|
value = value.slice(0, start - 2) + value.slice(start + 2);
|
|
start -= 2;
|
|
end -= 4;
|
|
}
|
|
if (value.slice(end, end + 4) === nesting) {
|
|
value = value.slice(0, end) + value.slice(end + 4);
|
|
} else if (start !== end && value.slice(end - 2, end + 2) === nesting) {
|
|
value = value.slice(0, end - 2) + value.slice(end + 2);
|
|
end -= 2;
|
|
}
|
|
|
|
this.setValue(value, start, end);
|
|
return true;
|
|
}
|
|
override render() {
|
|
const { room } = this.props;
|
|
const OLD_TEXTBOX = false;
|
|
const canTalk = PS.user.named || room.id === 'dm-';
|
|
if (room.connected === 'client-only' && room.id.startsWith('battle-')) {
|
|
return <div
|
|
class="chat-log-add hasuserlist" onClick={this.focusIfNoSelection} style={{ left: this.props.left || 0 }}
|
|
><CopyableURLBox url={`https://psim.us/r/${room.id.slice(7)}`} /></div>;
|
|
}
|
|
return <div
|
|
class="chat-log-add hasuserlist" onClick={this.focusIfNoSelection} style={{ left: this.props.left || 0 }}
|
|
>
|
|
<form class={`chatbox${this.props.tinyLayout ? ' nolabel' : ''}`} style={canTalk ? {} : { display: 'none' }}>
|
|
<label style={`color:${BattleLog.usernameColor(PS.user.userid)}`}>{PS.user.name}:</label>
|
|
{OLD_TEXTBOX ? <textarea
|
|
class={room.connected === true && canTalk ? 'textbox autofocus' : 'textbox disabled'}
|
|
autofocus
|
|
rows={1}
|
|
onInput={this.update}
|
|
onKeyDown={this.onKeyDown}
|
|
style={{ resize: 'none', width: '100%', height: '16px', padding: '2px 3px 1px 3px' }}
|
|
placeholder={PSView.focusPreview(room)}
|
|
/> : <ChatTextBox
|
|
disabled={room.connected !== true || !canTalk}
|
|
placeholder={PSView.focusPreview(room)}
|
|
/>}
|
|
</form>
|
|
{!canTalk && <button data-href="login" class="button autofocus">
|
|
Choose a name before sending messages
|
|
</button>}
|
|
</div>;
|
|
}
|
|
}
|
|
|
|
class ChatTextBox extends preact.Component<{ placeholder: string, disabled?: boolean }> {
|
|
override shouldComponentUpdate(nextProps: any) {
|
|
this.base!.setAttribute("placeholder", nextProps.placeholder);
|
|
this.base!.classList?.toggle('disabled', !!nextProps.disabled);
|
|
this.base!.classList?.toggle('autofocus', !nextProps.disabled);
|
|
return false;
|
|
}
|
|
handleFocus = () => {
|
|
PSView.setTextboxFocused(true);
|
|
};
|
|
handleBlur = () => {
|
|
PSView.setTextboxFocused(false);
|
|
};
|
|
override render() {
|
|
return <pre
|
|
class={`textbox textbox-empty ${this.props.disabled ? ' disabled' : ' autofocus'}`} placeholder={this.props.placeholder}
|
|
onFocus={this.handleFocus} onBlur={this.handleBlur}
|
|
>{'\n'}</pre>;
|
|
}
|
|
}
|
|
|
|
class ChatPanel extends PSRoomPanel<ChatRoom> {
|
|
static readonly id = 'chat';
|
|
static readonly routes = ['dm-*', 'groupchat-*', '*'];
|
|
static readonly Model = ChatRoom;
|
|
static readonly location = 'right';
|
|
static readonly icon = <i class="fa fa-comment-o" aria-hidden></i>;
|
|
override componentDidMount(): void {
|
|
super.componentDidMount();
|
|
this.subscribeTo(PS.user, () => {
|
|
this.props.room.updateTarget();
|
|
});
|
|
}
|
|
send = (text: string, elem: HTMLElement) => {
|
|
this.props.room.send(text, elem);
|
|
};
|
|
onKey = (e: KeyboardEvent) => {
|
|
if (e.keyCode === 33) { // Pg Up key
|
|
const chatLog = this.base!.getElementsByClassName('chat-log')[0] as HTMLDivElement;
|
|
chatLog.scrollTop = chatLog.scrollTop - chatLog.offsetHeight + 60;
|
|
return true;
|
|
} else if (e.keyCode === 34) { // Pg Dn key
|
|
const chatLog = this.base!.getElementsByClassName('chat-log')[0] as HTMLDivElement;
|
|
chatLog.scrollTop = chatLog.scrollTop + chatLog.offsetHeight - 60;
|
|
return true;
|
|
}
|
|
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();
|
|
if (!room.pmTarget) throw new Error("Not a PM room");
|
|
PS.send(`/utm ${packedTeam}`);
|
|
PS.send(`${privacy}/challenge ${room.pmTarget}, ${format}`);
|
|
room.challengeMenuOpen = false;
|
|
room.challenging = {
|
|
formatName: format,
|
|
teamFormat: format,
|
|
};
|
|
room.update(null);
|
|
};
|
|
acceptChallenge = (e: Event, format: string, team?: Team) => {
|
|
const room = this.props.room;
|
|
const packedTeam = team ? team.packedTeam : '';
|
|
if (!room.pmTarget) throw new Error("Not a PM room");
|
|
PS.send(`/utm ${packedTeam}`);
|
|
this.props.room.send(`/accept`);
|
|
room.challenged = null;
|
|
room.update(null);
|
|
};
|
|
|
|
override render() {
|
|
const room = this.props.room;
|
|
const tinyLayout = room.width < 450;
|
|
|
|
const challengeTo = room.challenging ? <div class="challenge">
|
|
<p>Waiting for {room.pmTarget}...</p>
|
|
<TeamForm format={room.challenging.formatName} teamFormat={room.challenging.teamFormat} onSubmit={null}>
|
|
<button data-cmd="/cancelchallenge" class="button">Cancel</button>
|
|
</TeamForm>
|
|
</div> : room.challengeMenuOpen ? <div class="challenge">
|
|
<TeamForm onSubmit={this.makeChallenge}>
|
|
<button type="submit" class="button button-first">
|
|
<strong>Challenge</strong>
|
|
</button><button data-href="battleoptions" class="button button-last" aria-label="Battle options">
|
|
<i class="fa fa-caret-down" aria-hidden></i>
|
|
</button> {}
|
|
<button data-cmd="/cancelchallenge" class="button">Cancel</button>
|
|
</TeamForm>
|
|
</div> : null;
|
|
|
|
const challengeFrom = room.challenged ? <div class="challenge">
|
|
{!!room.challenged.message && <p>{room.challenged.message}</p>}
|
|
<TeamForm format={room.challenged.formatName} teamFormat={room.challenged.teamFormat} onSubmit={this.acceptChallenge}>
|
|
<button type="submit" class={room.challenged.formatName ? `button button-first` : `button`}>
|
|
<strong>{room.challenged.acceptButtonLabel || 'Accept'}</strong>
|
|
</button>
|
|
{room.challenged.formatName && <button data-href="battleoptions" class="button button-last" aria-label="Battle options">
|
|
<i class="fa fa-caret-down" aria-hidden></i>
|
|
</button>} {}
|
|
<button data-cmd="/reject" class="button">{room.challenged.rejectButtonLabel || 'Reject'}</button>
|
|
</TeamForm>
|
|
</div> : null;
|
|
|
|
return <PSPanelWrapper room={room} focusClick fullSize>
|
|
<ChatLog class="chat-log" room={this.props.room} left={tinyLayout ? 0 : 146} top={room.tour?.info.isActive ? 30 : 0}>
|
|
{challengeTo}{challengeFrom}{PS.isOffline && <p class="buttonbar">
|
|
<button class="button" data-cmd="/reconnect">
|
|
<i class="fa fa-plug" aria-hidden></i> <strong>Reconnect</strong>
|
|
</button> {}
|
|
{PS.connection?.reconnectTimer && <small>(Autoreconnect in {Math.round(PS.connection.reconnectDelay / 1000)}s)</small>}
|
|
</p>}
|
|
</ChatLog>
|
|
{room.tour && <TournamentBox tour={room.tour} left={tinyLayout ? 0 : 146} />}
|
|
<ChatTextEntry
|
|
room={this.props.room} onMessage={this.send} onKey={this.onKey} left={tinyLayout ? 0 : 146} tinyLayout={tinyLayout}
|
|
/>
|
|
<ChatUserList room={this.props.room} minimized={tinyLayout} />
|
|
</PSPanelWrapper>;
|
|
}
|
|
}
|
|
|
|
export class ChatUserList extends preact.Component<{
|
|
room: ChatRoom, left?: number, top?: number, minimized?: boolean, static?: boolean,
|
|
}> {
|
|
render() {
|
|
const room = this.props.room;
|
|
const pmTargetid = room.pmTarget ? toID(room.pmTarget) : null;
|
|
return <div
|
|
class={'userlist' + (this.props.minimized ? ' userlist-hidden' : this.props.static ? ' userlist-static' : '')}
|
|
style={{ left: this.props.left || 0, top: this.props.top || 0 }}
|
|
>
|
|
{!this.props.minimized ? (
|
|
<div class="userlist-count"><small>{room.userCount} users</small></div>
|
|
) : room.id === 'dm-' ? (
|
|
<>
|
|
<button class="button button-middle" data-cmd="/help">Commands</button>
|
|
</>
|
|
) : pmTargetid ? (
|
|
<>
|
|
<button class="button button-middle" data-cmd="/challenge">Challenge</button>
|
|
<button class="button button-middle" data-href={`useroptions-${pmTargetid}`}>{'\u2026'}</button>
|
|
</>
|
|
) : (
|
|
<button data-href="userlist" class="button button-middle">{room.userCount} users</button>
|
|
)}
|
|
<ul>
|
|
{room.onlineUsers.map(([userid, name]) => {
|
|
const groupSymbol = name.charAt(0);
|
|
const group = PS.server.groups[groupSymbol] || { type: 'user', order: 0 };
|
|
let color;
|
|
if (name.endsWith('@!')) {
|
|
name = name.slice(0, -2);
|
|
color = '#888888';
|
|
} else {
|
|
color = BattleLog.usernameColor(userid);
|
|
}
|
|
return <li key={userid}><button class="userbutton username">
|
|
<em class={`group${['leadership', 'staff'].includes(group.type!) ? ' staffgroup' : ''}`}>
|
|
{groupSymbol}
|
|
</em>
|
|
{group.type === 'leadership' ? (
|
|
<strong><em style={`color:${color}`}>{name.slice(1)}</em></strong>
|
|
) : group.type === 'staff' ? (
|
|
<strong style={`color:${color} `}>{name.slice(1)}</strong>
|
|
) : (
|
|
<span style={`color:${color}`}>{name.slice(1)}</span>
|
|
)}
|
|
</button></li>;
|
|
})}
|
|
</ul>
|
|
</div>;
|
|
}
|
|
}
|
|
|
|
export class ChatLog extends preact.Component<{
|
|
class: string, room: ChatRoom, children?: preact.ComponentChildren,
|
|
left?: number, top?: number, noSubscription?: boolean,
|
|
}> {
|
|
subscription: PSSubscription | null = null;
|
|
override componentDidMount() {
|
|
const room = this.props.room;
|
|
if (room.log) {
|
|
const elem = room.log.elem;
|
|
this.base!.replaceChild(elem, this.base!.firstChild!);
|
|
elem.className = this.props.class;
|
|
elem.style.left = `${this.props.left || 0}px`;
|
|
elem.style.top = `${this.props.top || 0}px`;
|
|
}
|
|
if (!this.props.noSubscription) {
|
|
room.log ||= new BattleLog(this.base!.firstChild as HTMLDivElement);
|
|
room.log.getHighlight = room.handleHighlight;
|
|
if (room.backlog) {
|
|
const backlog = room.backlog;
|
|
room.backlog = null;
|
|
for (const args of backlog) {
|
|
room.log.add(args, undefined, undefined, PS.prefs.timestamps[room.pmTarget ? 'pms' : 'chatrooms']);
|
|
}
|
|
}
|
|
this.subscription = room.subscribe(tokens => {
|
|
if (!tokens) return;
|
|
this.props.room.log!.add(tokens, undefined, undefined, PS.prefs.timestamps[room.pmTarget ? 'pms' : 'chatrooms']);
|
|
});
|
|
}
|
|
this.setControlsJSX(this.props.children);
|
|
}
|
|
override componentWillUnmount() {
|
|
this.subscription?.unsubscribe();
|
|
}
|
|
override shouldComponentUpdate(props: typeof ChatLog.prototype.props) {
|
|
const elem = this.base!.firstChild as HTMLDivElement;
|
|
if (props.class !== this.props.class) {
|
|
elem.className = props.class;
|
|
}
|
|
if (props.left !== this.props.left) elem.style.left = `${props.left || 0}px`;
|
|
if (props.top !== this.props.top) elem.style.top = `${props.top || 0}px`;
|
|
this.setControlsJSX(props.children);
|
|
this.updateScroll();
|
|
return false;
|
|
}
|
|
setControlsJSX(jsx: preact.ComponentChildren | undefined) {
|
|
const elem = this.base!.firstChild as HTMLDivElement;
|
|
const children = elem.children;
|
|
let controlsElem = children[children.length - 1] as HTMLDivElement | undefined;
|
|
if (controlsElem && controlsElem.className !== 'controls') controlsElem = undefined;
|
|
if (!jsx) {
|
|
if (!controlsElem) return;
|
|
elem.removeChild(controlsElem);
|
|
this.updateScroll();
|
|
return;
|
|
}
|
|
if (!controlsElem) {
|
|
controlsElem = document.createElement('div');
|
|
controlsElem.className = 'controls';
|
|
elem.appendChild(controlsElem);
|
|
}
|
|
// for some reason, the replaceNode feature isn't working?
|
|
if (controlsElem.children[0]) controlsElem.removeChild(controlsElem.children[0]);
|
|
preact.render(<div>{jsx}</div>, controlsElem);
|
|
this.updateScroll();
|
|
}
|
|
updateScroll() {
|
|
this.props.room.log?.updateScroll();
|
|
}
|
|
render() {
|
|
return <div><div
|
|
class={this.props.class} role="log" aria-label="Chat log"
|
|
style={{ left: this.props.left || 0, top: this.props.top || 0 }}
|
|
></div></div>;
|
|
}
|
|
}
|
|
|
|
PS.addRoomType(ChatPanel);
|