pokemon-showdown-client/js/client-chat.js
Guangcong Luo e1b6dd5346 Update Battle class API
The client Battle class API has been pretty old and crusty, so this
updates it to be saner.

The constructor now takes an options object. Any setting you'd want to
initialize with is now a constructor option, instead of needing to call
methods after the constructor.

(Deprecated settings `roomid` and `joinButtons` still need to be set
separately.)

The old callback system is removed. It's replaced with a subscription
system vaguely resembling `PSStreamModel`. Any callbacks only intended
to be used by the warstory generator are removed (anyone who wants to
write their own warstory generator should extend `BattleSceneStub`
instead).

Battles no longer start paused. You can still start them paused by
passing `paused: true` as an option.

Playback state tracking had a bunch of rearrangement:

- `playbackState` no longer exists; state should be directly read from
  `paused`, `atQueueEnd`, `turn`, and `seeking`.

- `turn` is now initialized to `-1`. `-1` now means "we haven't reached
  `|teampreview|` or `|start|` yet". Reaching those sets turn to `0`.

- "Fast forwarding" and "seeking" are now consistently named "seeking".
  - `seeking` tracks seek state; changes from `fastForward`:
    - `null` means not seeking (replaces `0`)
    - `0` means seeking the start (replaces `0.5`)
    - `Infinity` means seeking the end (replaces `-1`)
  - `fastForward` deprecated and replaced with `seeking`
  - `fastForwardTo()` deprecated and replaced with `seekTurn()`

- `resultWaiting` is removed (it's unused)

- The "activity queue" has been renamed the "step queue", which means
  some renamed properties:
  - `activityQueue` to `stepQueue`
  - `activityStep` to `currentStep`
  - `nextActivity()` to `nextStep()`

- new property: `atQueueEnd` to track if animation has caught up to the
  end of the step queue (replaces checking `playbackState`)

- new property/option: `isReplay` - will automatically set `ended` when
  reaching the end of a replay (stopping music and showing a message),
  if the replay was saved before the end of the battle

- both replay players (`replay.pokemonshowdown.com` and downloaded
  files) have been rewritten to use an observer system, instead of the
  previous manual updating

- `reset(true)` has been renamed `resetStep()`
2021-01-23 11:24:05 -05:00

1957 lines
70 KiB
JavaScript

(function ($) {
var ConsoleRoom = this.ConsoleRoom = Room.extend({
type: 'chat',
title: '',
constructor: function () {
if (!this.events) this.events = {};
if (!this.events['click .username']) this.events['click .username'] = 'clickUsername';
if (!this.events['submit form']) this.events['submit form'] = 'submit';
if (!this.events['keydown textarea']) this.events['keydown textarea'] = 'keyDown';
if (!this.events['keyup textarea']) this.events['keyup textarea'] = 'keyUp';
if (!this.events['focus textarea']) this.events['focus textarea'] = 'focusText';
if (!this.events['blur textarea']) this.events['blur textarea'] = 'blurText';
if (!this.events['click .spoiler']) this.events['click .spoiler'] = 'clickSpoiler';
if (!this.events['click .message-pm i']) this.events['click .message-pm i'] = 'openPM';
this.initializeTabComplete();
// create up/down history for this room
this.chatHistory = new ChatHistory();
// this MUST set up this.$chatAdd
Room.apply(this, arguments);
app.user.on('change', this.updateUser, this);
this.updateUser();
},
updateUser: function () {
var name = app.user.get('name');
if (this.expired) {
this.$chatAdd.html(this.expired === true ? 'This room is expired' : BattleLog.sanitizeHTML(this.expired));
this.$chatbox = null;
} else if (!name) {
this.$chatAdd.html('Connecting...');
this.$chatbox = null;
} else if (!app.user.get('named')) {
this.$chatAdd.html('<form><button name="login">Join chat</button></form>');
this.$chatbox = null;
} else {
var color = app.user.get('away') ? 'color:#888;' : BattleLog.hashColor(app.user.get('userid'));
this.$chatAdd.html('<form class="chatbox"><label style="' + color + '">' + BattleLog.escapeHTML(name) + ':</label> <textarea class="textbox" type="text" size="70" autocomplete="off"></textarea></form>');
this.$chatbox = this.$chatAdd.find('textarea');
this.$chatbox.autoResize({
animate: false,
extraSpace: 0
});
if (document.activeElement.tagName.toLowerCase() !== 'textarea' && (this === app.curSideRoom || this === app.curRoom)) {
this.$chatbox.focus();
}
}
},
focus: function (e, focusTextbox) {
var target = e && e.target;
if (target && target.tagName === 'TEXTAREA') {
// this workaround works for iOS 12 but not iOS 13
/* if (window.isiOS) {
// iOS will not bring up a keyboard unless you manually blur and refocus
$(target).blur();
setTimeout(function () {
$(target).focus();
}, 0);
} */
return;
}
if (!this.$chatbox) {
this.$('button[name=login]').focus();
return;
}
if (focusTextbox || $(target).closest('.chat-log-add, .battle-log-add').length) {
this.$chatbox.focus();
return;
}
if (window.isiOS) {
// Preventing the on-screen keyboard leads to other bugs, so we have to
// avoid focusing the textbox altogether. Sorry, Bluetooth keyboard users!
return;
}
// this will prevent a on-screen keyboard from appearing (in Android and iOS,
// and hopefully also Windows and Chrome OS in tablet mode)
this.$chatbox.blur();
this.$chatbox[0].readOnly = true;
this.$chatbox.focus();
var chatbox = this.$chatbox[0];
setTimeout(function () {
chatbox.readOnly = false;
}, 0);
},
focusText: function () {
if (this.$chatbox) {
var rooms = app.roomList.concat(app.sideRoomList);
var roomIndex = rooms.indexOf(this);
var roomLeft = rooms[roomIndex - 1];
var roomRight = rooms[roomIndex + 1];
if (roomLeft || roomRight) {
this.$chatbox.attr('placeholder', " " + (roomLeft ? "\u2190 " + roomLeft.title : '') + (app.arrowKeysUsed ? " | " : " (use arrow keys) ") + (roomRight ? roomRight.title + " \u2192" : ''));
} else {
this.$chatbox.attr('placeholder', "");
}
}
},
blurText: function () {
if (this.$chatbox) {
this.$chatbox.attr('placeholder', "");
}
},
clickSpoiler: function (e) {
$(e.currentTarget).toggleClass('spoiler-shown');
},
login: function () {
app.addPopup(LoginPopup);
},
submit: function (e) {
e.preventDefault();
e.stopPropagation();
var text = this.$chatbox.val();
if (!text) return;
if (!$.trim(text)) {
this.$chatbox.val('');
return;
}
this.tabComplete.reset();
this.chatHistory.push(text);
text = this.parseCommand(text);
if (this.battle && this.battle.ignoreSpects && app.user.get('userid') !== this.battle.p1.id && app.user.get('userid') !== this.battle.p2.id) {
this.add("You can't chat in this battle as you're currently ignoring spectators");
} else if (text.length > 80000) {
app.addPopupMessage("Your message is too long.");
return;
} else if (text) {
this.send(text);
}
this.$chatbox.val('');
this.$chatbox.trigger('keyup'); // force a resize
},
keyUp: function (e) {
// Android Chrome compose keycode
// Android Chrome no longer sends keyCode 13 when Enter is pressed on
// the soft keyboard, resulting in this annoying hack.
// https://bugs.chromium.org/p/chromium/issues/detail?id=118639#c232
if (!e.shiftKey && e.keyCode === 229 && this.$chatbox.val().slice(-1) === '\n') {
this.submit(e);
}
},
keyDown: function (e) {
var cmdKey = (((e.cmdKey || e.metaKey) ? 1 : 0) + (e.ctrlKey ? 1 : 0) === 1) && !e.altKey && !e.shiftKey;
var textbox = e.currentTarget;
if (e.keyCode === 13 && !e.shiftKey) { // Enter key
this.submit(e);
} else if (e.keyCode === 73 && cmdKey) { // Ctrl + I key
if (ConsoleRoom.toggleFormatChar(textbox, '_')) {
e.preventDefault();
e.stopPropagation();
}
} else if (e.keyCode === 66 && cmdKey) { // Ctrl + B key
if (ConsoleRoom.toggleFormatChar(textbox, '*')) {
e.preventDefault();
e.stopPropagation();
}
} else if (e.keyCode === 192 && cmdKey) { // Ctrl + ` key
if (ConsoleRoom.toggleFormatChar(textbox, '`')) {
e.preventDefault();
e.stopPropagation();
}
} else if (e.keyCode === 33) { // Pg Up key
this.$chatFrame.scrollTop(this.$chatFrame.scrollTop() - this.$chatFrame.height() + 60);
} else if (e.keyCode === 34) { // Pg Dn key
this.$chatFrame.scrollTop(this.$chatFrame.scrollTop() + this.$chatFrame.height() - 60);
} else if (e.keyCode === 9 && !e.ctrlKey) { // Tab key
var reverse = !!e.shiftKey; // Shift+Tab reverses direction
if (this.handleTabComplete(this.$chatbox, reverse)) {
e.preventDefault();
e.stopPropagation();
}
} else if (e.keyCode === 38 && !e.shiftKey && !e.altKey) { // Up key
if (this.chatHistoryUp(this.$chatbox, e)) {
e.preventDefault();
e.stopPropagation();
}
} else if (e.keyCode === 40 && !e.shiftKey && !e.altKey) { // Down key
if (this.chatHistoryDown(this.$chatbox, e)) {
e.preventDefault();
e.stopPropagation();
}
} else if (e.keyCode === 27 && !e.shiftKey && !e.altKey) { // Esc key
if (this.undoTabComplete(this.$chatbox)) {
e.preventDefault();
e.stopPropagation();
}
} else if (app.user.lastPM && (textbox.value === '/reply' || textbox.value === '/r' || textbox.value === '/R') && e.keyCode === 32) { // '/reply ' is being written
e.preventDefault();
e.stopPropagation();
var val = '/pm ' + app.user.lastPM + ', ';
textbox.value = val;
textbox.setSelectionRange(val.length, val.length);
}
},
clickUsername: function (e) {
e.stopPropagation();
e.preventDefault();
var position;
if (e.currentTarget.className === 'userbutton username') {
position = 'right';
}
var roomGroup = $(e.currentTarget).data('roomgroup');
var name = $(e.currentTarget).data('name') || $(e.currentTarget).text();
var away = $(e.currentTarget).data('away') || false;
var status = $(e.currentTarget).data('status');
app.addPopup(UserPopup, {roomGroup: roomGroup, name: name, away: away, status: status, sourceEl: e.currentTarget, position: position});
},
openPM: function (e) {
e.preventDefault();
e.stopPropagation();
app.focusRoom('');
app.rooms[''].focusPM($(e.currentTarget).data('name'));
},
clear: function () {
if (this.$chat) this.$chat.html('');
},
// support for buttons that can be sent by the server:
joinRoom: function (room) {
app.joinRoom(room);
},
avatars: function () {
app.addPopup(AvatarsPopup);
},
openSounds: function () {
app.addPopup(SoundsPopup, {type: 'semimodal'});
},
openOptions: function () {
app.addPopup(OptionsPopup, {type: 'semimodal'});
},
// highlight
getHighlight: function (message) {
var highlights = Dex.prefs('highlights') || {};
if (Array.isArray(highlights)) {
highlights = {global: highlights};
// Migrate from the old highlight system
Storage.prefs('highlights', highlights);
}
if (!Dex.prefs('noselfhighlight') && app.user.nameRegExp) {
if (app.user.nameRegExp.test(message)) return true;
}
if (!app.highlightRegExp) {
try {
this.updateHighlightRegExp(highlights);
} catch (e) {
// 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;
}
}
var id = Config.server.id + '#' + this.id;
var globalHighlightsRegExp = app.highlightRegExp['global'];
var roomHighlightsRegExp = app.highlightRegExp[id];
return (((globalHighlightsRegExp && globalHighlightsRegExp.test(message)) || (roomHighlightsRegExp && roomHighlightsRegExp.test(message))));
},
updateHighlightRegExp: function (highlights) {
// 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".
app.highlightRegExp = {};
for (var i in highlights) {
if (!highlights[i].length) {
app.highlightRegExp[i] = null;
continue;
}
app.highlightRegExp[i] = new RegExp('(?:\\b|(?!\\w))(?:' + highlights[i].join('|') + ')(?:\\b|(?!\\w))', 'i');
}
},
// chat history
chatHistory: null,
chatHistoryUp: function ($textbox, e) {
var idx = +$textbox.prop('selectionStart');
var line = $textbox.val();
if (e && !e.ctrlKey && idx !== 0 && idx !== line.length) return false;
if (this.chatHistory.index === 0) return false;
$textbox.val(this.chatHistory.up(line));
return true;
},
chatHistoryDown: function ($textbox, e) {
var idx = +$textbox.prop('selectionStart');
var line = $textbox.val();
if (e && !e.ctrlKey && idx !== 0 && idx !== line.length) return false;
$textbox.val(this.chatHistory.down(line));
return true;
},
// tab completion
initializeTabComplete: function () {
this.tabComplete = {
candidates: null,
index: 0,
prefix: null,
cursor: null,
reset: function () {
this.cursor = null;
}
};
this.userActivity = [];
},
markUserActive: function (userid) {
var idx = this.userActivity.indexOf(userid);
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);
}
},
tabComplete: null,
userActivity: null,
handleTabComplete: function ($textbox, reverse) {
// Don't tab complete at the start of the text box.
var idx = $textbox.prop('selectionStart');
if (idx === 0) return false;
var users = this.users || (app.rooms['lobby'] ? app.rooms['lobby'].users : {});
var text = $textbox.val();
var prefix = text.substr(0, idx);
if (this.tabComplete.cursor !== null && prefix === this.tabComplete.cursor) {
// The user is cycling through the candidate names.
if (reverse) {
this.tabComplete.index--;
} else {
this.tabComplete.index++;
}
if (this.tabComplete.index >= this.tabComplete.candidates.length) this.tabComplete.index = 0;
if (this.tabComplete.index < 0) this.tabComplete.index = this.tabComplete.candidates.length - 1;
} else {
// This is a new tab completion.
// There needs to be non-whitespace to the left of the cursor.
var m1 = /^([\s\S]*?)([A-Za-z0-9][^, \n]*)$/.exec(prefix);
var m2 = /^([\s\S]*?)([A-Za-z0-9][^, \n]* [^, ]*)$/.exec(prefix);
if (!m1 && !m2) return true;
this.tabComplete.prefix = prefix;
var idprefix = (m1 ? toID(m1[2]) : '');
var spaceprefix = (m2 ? m2[2].replace(/[^A-Za-z0-9 ]+/g, '').toLowerCase() : '');
var candidates = []; // array of [candidate userid, prefix length]
// don't include command names in autocomplete
if (m2 && (m2[0] === '/' || m2[0] === '!')) spaceprefix = '';
for (var i in users) {
if (spaceprefix && users[i].name.replace(/[^A-Za-z0-9 ]+/g, '').toLowerCase().substr(0, spaceprefix.length) === spaceprefix) {
candidates.push([i, m2[1].length]);
} else if (idprefix && i.substr(0, idprefix.length) === idprefix) {
candidates.push([i, m1[1].length]);
}
}
// Sort by most recent to speak in the chat, or, in the case of a tie,
// in alphabetical order.
var self = this;
candidates.sort(function (a, b) {
if (a[1] !== b[1]) {
// shorter prefix length comes first
return a[1] - b[1];
}
var aidx = self.userActivity.indexOf(a[0]);
var bidx = self.userActivity.indexOf(b[0]);
if (aidx !== -1) {
if (bidx !== -1) {
return bidx - aidx;
}
return -1; // a comes first
} else if (bidx != -1) {
return 1; // b comes first
}
return (a[0] < b[0]) ? -1 : 1; // alphabetical order
});
this.tabComplete.candidates = candidates;
this.tabComplete.index = 0;
if (!candidates.length) {
this.tabComplete.cursor = null;
return true;
}
}
// Substitute in the tab-completed name.
var candidate = this.tabComplete.candidates[this.tabComplete.index];
var substituteUserId = candidate[0];
var substituteUser = users[substituteUserId];
if (!substituteUser) return true;
var name = substituteUser.name;
name = Dex.getShortName(name);
var fullPrefix = this.tabComplete.prefix.substr(0, candidate[1]) + name;
$textbox.val(fullPrefix + text.substr(idx));
var pos = fullPrefix.length;
$textbox[0].setSelectionRange(pos, pos);
this.tabComplete.cursor = fullPrefix;
return true;
},
undoTabComplete: function ($textbox) {
var cursorPosition = $textbox.prop('selectionEnd');
if (!this.tabComplete.cursor || $textbox.val().substr(0, cursorPosition) !== this.tabComplete.cursor) return false;
$textbox.val(this.tabComplete.prefix + $textbox.val().substr(cursorPosition));
$textbox.prop('selectionEnd', this.tabComplete.prefix.length);
return true;
},
// command parsing
checkBroadcast: function (cmd, text) {
if (text.charAt(0) === '!') {
this.add('|error|The command "!' + cmd + '" cannot be broadcast.');
this.add('|error|Use /' + cmd + ' to use it normally.');
return true;
}
return false;
},
parseCommand: function (text) {
var cmd = '';
var target = '';
var noSpace = false;
if (text.substr(0, 2) !== '//' && text.charAt(0) === '/' || text.charAt(0) === '!') {
var spaceIndex = text.indexOf(' ');
if (spaceIndex > 0) {
cmd = text.substr(1, spaceIndex - 1);
target = text.substr(spaceIndex + 1);
} else {
cmd = text.substr(1);
target = '';
noSpace = true;
}
}
switch (cmd.toLowerCase()) {
case 'chal':
case 'chall':
case 'challenge':
if (this.checkBroadcast(cmd, text)) return false;
var targets = target.split(',');
for (var i = 0; i < targets.length; i++) {
targets[i] = $.trim(targets[i]);
}
var self = this;
var challenge = function (targets) {
target = toID(targets[0]);
self.challengeData = {userid: target, format: targets.length > 1 ? targets.slice(1).join(',') : '', team: ''};
app.on('response:userdetails', self.challengeUserdetails, self);
app.send('/cmd userdetails ' + target);
};
if (!targets[0]) {
app.addPopupPrompt("Who would you like to challenge?", "Challenge user", function (target) {
if (!target) return;
challenge([target]);
});
return false;
}
challenge(targets);
return false;
case 'accept':
if (this.checkBroadcast(cmd, text)) return false;
var userid = toID(target);
if (userid) {
var $challenge = $('.pm-window').filter('div[data-userid="' + userid + '"]').find('button[name="acceptChallenge"]');
if (!$challenge.length) {
this.add("You do not have any pending challenge from '" + toName(target) + "' to accept.");
return false;
}
$challenge[0].click();
return false;
}
var $challenges = $('.challenge').find('button[name=acceptChallenge]');
if (!$challenges.length) {
this.add('You do not have any pending challenges to accept.');
return false;
}
if ($challenges.length > 1) {
this.add('You need to specify a user if you have more than one pending challenge to accept.');
this.parseCommand('/help accept');
return false;
}
$challenges[0].click();
return false;
case 'reject':
if (this.checkBroadcast(cmd, text)) return false;
var userid = toID(target);
if (userid) {
var $challenge = $('.pm-window').filter('div[data-userid="' + userid + '"]').find('button[name="rejectChallenge"]');
if (!$challenge.length) {
this.add("You do not have any pending challenge from '" + toName(target) + "' to reject.");
return false;
}
$challenge[0].click();
return false;
}
var $challenges = $('.challenge').find('button[name="rejectChallenge"]');
if (!$challenges.length) {
this.add('You do not have any pending challenges to reject.');
this.parseCommand('/help reject');
return false;
}
if ($challenges.length > 1) {
this.add('You need to specify a user if you have more than one pending challenge to reject.');
this.parseCommand('/help reject');
return false;
}
$challenges[0].click();
return false;
case 'user':
case 'open':
if (this.checkBroadcast(cmd, text)) return false;
var openUser = function (target) {
app.addPopup(UserPopup, {name: target});
};
target = toName(target);
if (!target) {
app.addPopupPrompt("Username", "Open", function (target) {
if (!target) return;
openUser(target);
});
return false;
}
openUser(target);
return false;
case 'pm':
case 'whisper':
case 'w':
case 'msg':
if (this.checkBroadcast(cmd, text)) return false;
var commaIndex = target.indexOf(',');
if (commaIndex < 0) break;
if (!$.trim(target.slice(commaIndex + 1))) {
app.rooms[''].focusPM(target.slice(0, commaIndex));
return false;
}
break;
case 'debug':
if (this.checkBroadcast(cmd, text)) return false;
if (target === 'extractteams') {
app.addPopup(Popup, {
type: 'modal',
htmlMessage: "Extracted team data:<br /><textarea rows=\"10\" cols=\"60\">" + BattleLog.escapeHTML(JSON.stringify(Storage.teams)) + "</textarea>"
});
} else if (target === 'nw') {
try {
nw.Window.get().showDevTools();
} catch (e) {
this.add('|error|' + e.message);
}
} else {
this.add('|error|Unknown debug command.');
this.add('|error|Are you looking for /showdebug and /hidedebug?');
}
return false;
case 'news':
if (this.checkBroadcast(cmd, text)) return false;
app.rooms[''].addNews();
return false;
case 'autojoin':
case 'cmd':
case 'crq':
case 'query':
if (this.checkBroadcast(cmd, text)) return false;
this.add('This is a PS system command; do not use it.');
return false;
case 'ignore':
if (this.checkBroadcast(cmd, text)) return false;
if (!target) {
this.parseCommand('/help ignore');
return false;
}
if (toUserid(target) === app.user.get('userid')) {
this.add("You are not able to ignore yourself.");
} else if (app.ignore[toUserid(target)]) {
this.add("User '" + toName(target) + "' is already on your ignore list. (Moderator messages will not be ignored.)");
} else {
app.ignore[toUserid(target)] = 1;
this.add("User '" + toName(target) + "' ignored. (Moderator messages will not be ignored.)");
}
return false;
case 'unignore':
if (this.checkBroadcast(cmd, text)) return false;
if (!target) {
this.parseCommand('/help unignore');
return false;
}
if (!app.ignore[toUserid(target)]) {
this.add("User '" + toName(target) + "' isn't on your ignore list.");
} else {
delete app.ignore[toUserid(target)];
this.add("User '" + toName(target) + "' no longer ignored.");
}
return false;
case 'ignorelist':
if (this.checkBroadcast(cmd, text)) return false;
var ignoreList = Object.keys(app.ignore);
if (ignoreList.length === 0) {
this.add('You are currently not ignoring anyone.');
} else {
this.add("You are currently ignoring: " + ignoreList.join(', '));
}
return false;
case 'clear':
if (this.checkBroadcast(cmd, text)) return false;
if (this.clear) {
this.clear();
} else {
this.add('||This room can\'t be cleared');
}
return false;
case 'clearpms':
if (this.checkBroadcast(cmd, text)) return false;
var $pms = $('.pm-window');
if (!$pms.length) {
this.add('You do not have any PM windows open.');
return false;
}
$pms.each(function () {
var userid = $(this).data('userid');
if (!userid) {
var newsId = $(this).data('newsid');
if (newsId) {
$.cookie('showdown_readnews', '' + newsId, {expires: 365});
}
$(this).remove();
return;
}
app.rooms[''].closePM(userid);
$(this).find('.inner').empty();
});
this.add("All PM windows cleared and closed.");
return false;
case 'nick':
if (this.checkBroadcast(cmd, text)) return false;
if ($.trim(target)) {
app.user.rename(target);
} else {
app.addPopup(LoginPopup);
}
return false;
case 'logout':
if (this.checkBroadcast(cmd, text)) return false;
app.user.logout();
return false;
case 'showdebug':
if (this.checkBroadcast(cmd, text)) return false;
this.add('Debug battle messages: ON');
Storage.prefs('showdebug', true);
var debugStyle = $('#debugstyle').get(0);
var onCSS = '.debug {display: block;}';
if (!debugStyle) {
$('head').append('<style id="debugstyle">' + onCSS + '</style>');
} else {
debugStyle.innerHTML = onCSS;
}
return false;
case 'hidedebug':
if (this.checkBroadcast(cmd, text)) return false;
this.add('Debug battle messages: HIDDEN');
Storage.prefs('showdebug', false);
var debugStyle = $('#debugstyle').get(0);
var offCSS = '.debug {display: none;}';
if (!debugStyle) {
$('head').append('<style id="debugstyle">' + offCSS + '</style>');
} else {
debugStyle.innerHTML = offCSS;
}
return false;
case 'showjoins':
if (this.checkBroadcast(cmd, text)) return false;
var showjoins = Dex.prefs('showjoins') || {};
var serverShowjoins = showjoins[Config.server.id] || {};
if (target) {
var room = toID(target);
if (serverShowjoins['global']) {
delete serverShowjoins[room];
} else {
serverShowjoins[room] = 1;
}
this.add('Join/leave messages on room ' + room + ': ALWAYS ON');
} else {
serverShowjoins = {global: 1};
this.add('Join/leave messages: ALWAYS ON');
}
showjoins[Config.server.id] = serverShowjoins;
Storage.prefs('showjoins', showjoins);
return false;
case 'hidejoins':
if (this.checkBroadcast(cmd, text)) return false;
var showjoins = Dex.prefs('showjoins') || {};
var serverShowjoins = showjoins[Config.server.id] || {};
if (target) {
var room = toID(target);
if (!serverShowjoins['global']) {
delete serverShowjoins[room];
} else {
serverShowjoins[room] = 0;
}
this.add('Join/leave messages on room ' + room + ': AUTOMATIC');
} else {
serverShowjoins = {global: 0};
this.add('Join/leave messages: AUTOMATIC');
}
showjoins[Config.server.id] = serverShowjoins;
Storage.prefs('showjoins', showjoins);
return false;
case 'showbattles':
if (this.checkBroadcast(cmd, text)) return false;
this.add('Battle messages: ON');
Storage.prefs('showbattles', true);
return false;
case 'hidebattles':
if (this.checkBroadcast(cmd, text)) return false;
this.add('Battle messages: HIDDEN');
Storage.prefs('showbattles', false);
return false;
case 'unpackhidden':
if (this.checkBroadcast(cmd, text)) return false;
this.add('Locked/banned users\' chat messages: ON');
Storage.prefs('nounlink', true);
return false;
case 'packhidden':
if (this.checkBroadcast(cmd, text)) return false;
this.add('Locked/banned users\' chat messages: HIDDEN');
Storage.prefs('nounlink', false);
return false;
case 'timestamps':
if (this.checkBroadcast(cmd, text)) return false;
var targets = target.split(',');
if ((['all', 'lobby', 'pms'].indexOf(targets[0]) === -1) || targets.length < 2 ||
(['off', 'minutes', 'seconds'].indexOf(targets[1] = targets[1].trim()) === -1)) {
this.add('Error: Invalid /timestamps command');
this.parseCommand('/help timestamps'); // show help
return false;
}
var timestamps = Dex.prefs('timestamps') || {};
if (typeof timestamps === 'string') {
// The previous has a timestamps preference from the previous
// regime. We can't set properties of a string, so set it to
// an empty object.
timestamps = {};
}
switch (targets[0]) {
case 'all':
timestamps.lobby = targets[1];
timestamps.pms = targets[1];
break;
case 'lobby':
timestamps.lobby = targets[1];
break;
case 'pms':
timestamps.pms = targets[1];
break;
}
this.add("Timestamps preference set to: '" + targets[1] + "' for '" + targets[0] + "'.");
Storage.prefs('timestamps', timestamps);
return false;
case 'hl':
case 'highlight':
if (this.checkBroadcast(cmd, text)) return false;
var highlights = Dex.prefs('highlights') || {};
if (target.includes(' ')) {
var targets = target.split(' ');
var subCmd = targets[0];
targets = targets.slice(1).join(' ').match(/([^,]+?({\d*,\d*})?)+/g);
// trim the targets to be safe
for (var i = 0, len = targets.length; i < len; i++) {
targets[i] = targets[i].replace(/\n/g, '').trim();
}
switch (subCmd) {
case 'add': case 'roomadd':
var key = subCmd === 'roomadd' ? (Config.server.id + '#' + this.id) : 'global';
var highlightList = highlights[key] || [];
for (var i = 0, len = targets.length; i < len; i++) {
if (!targets[i]) continue;
if (/[\\^$*+?()|{}[\]]/.test(targets[i])) {
// Catch any errors thrown by newly added regular expressions so they don't break the entire highlight list
try {
new RegExp(targets[i]);
} catch (e) {
return this.add('|error|' + (e.message.substr(0, 28) === 'Invalid regular expression: ' ? e.message : 'Invalid regular expression: /' + targets[i] + '/: ' + e.message));
}
}
if (highlightList.includes(targets[i])) {
return this.add('|error|' + targets[i] + ' is already on your highlights list.');
}
}
highlights[key] = highlightList.concat(targets);
this.add("Now highlighting on " + (key === 'global' ? "(everywhere): " : "(in " + key + "): ") + highlights[key].join(', '));
// We update the regex
this.updateHighlightRegExp(highlights);
break;
case 'delete': case 'roomdelete':
var key = subCmd === 'roomdelete' ? (Config.server.id + '#' + this.id) : 'global';
var highlightList = highlights[key] || [];
var newHls = [];
for (var i = 0, len = highlightList.length; i < len; i++) {
if (targets.indexOf(highlightList[i]) === -1) {
newHls.push(highlightList[i]);
}
}
highlights[key] = newHls;
this.add("Now highlighting on " + (key === 'global' ? "(everywhere): " : "(in " + key + "): ") + highlights[key].join(', '));
// We update the regex
this.updateHighlightRegExp(highlights);
break;
default:
if (this.checkBroadcast(cmd, text)) return false;
// Wrong command
this.add('|error|Invalid /highlight command.');
this.parseCommand('/help highlight'); // show help
return false;
}
Storage.prefs('highlights', highlights);
} else {
if (this.checkBroadcast(cmd, text)) return false;
if (['clear', 'roomclear', 'clearall'].includes(target)) {
var key = (target === 'roomclear' ? (Config.server.id + '#' + this.id) : (target === 'clearall' ? '' : 'global'));
if (key) {
highlights[key] = [];
this.add("All highlights (" + (key === 'global' ? "everywhere" : "in " + key) + ") cleared.");
this.updateHighlightRegExp(highlightList);
} else {
Storage.prefs('highlights', false);
this.add("All highlights (in all rooms and globally) cleared.");
this.updateHighlightRegExp({});
}
} else if (['show', 'list', 'roomshow', 'roomlist'].includes(target)) {
// Shows a list of the current highlighting words
var key = target.startsWith('room') ? (Config.server.id + '#' + this.id) : 'global';
if (highlights[key] && highlights[key].length > 0) {
this.add("Current highlight list " + (key === 'global' ? "(everywhere): " : "(in " + key + "): ") + highlights[key].join(", "));
} else {
this.add('Your highlight list' + (key === 'global' ? '' : ' in ' + key) + ' is empty.');
}
} else {
// Wrong command
this.add('|error|Invalid /highlight command.');
this.parseCommand('/help highlight'); // show help
return false;
}
}
return false;
case 'rank':
case 'ranking':
case 'rating':
case 'ladder':
if (this.checkBroadcast(cmd, text)) return false;
if (app.localLadder) return text;
if (!target) {
target = app.user.get('userid');
}
if (this.battle && !target.includes(',')) {
target += ", " + this.id.split('-')[1];
}
var targets = target.split(',');
var formatTargeting = false;
var formats = {};
var gens = {};
for (var i = 1, len = targets.length; i < len; i++) {
targets[i] = $.trim(targets[i]);
if (targets[i].length === 4 && targets[i].substr(0, 3) === 'gen') {
gens[targets[i]] = 1;
} else {
formats[toID(targets[i])] = 1;
}
formatTargeting = true;
}
var self = this;
$.get(app.user.getActionPHP(), {
act: 'ladderget',
user: targets[0]
}, Storage.safeJSON(function (data) {
if (!data || !$.isArray(data)) return self.add('|raw|Error: corrupted ranking data');
var buffer = '<div class="ladder"><table><tr><td colspan="8">User: <strong>' + toName(targets[0]) + '</strong></td></tr>';
if (!data.length) {
buffer += '<tr><td colspan="8"><em>This user has not played any ladder games yet.</em></td></tr>';
buffer += '</table></div>';
return self.add('|raw|' + 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>W</th><th>L</th><th>Total</th></tr>';
var hiddenFormats = [];
for (var i = 0; i < data.length; i++) {
var row = data[i];
if (!row) return self.add('|raw|Error: corrupted ranking data');
var formatId = toID(row.formatid);
if (!formatTargeting || formats[formatId] || gens[formatId.slice(0, 4)] || (gens['gen6'] && formatId.substr(0, 3) !== 'gen')) {
buffer += '<tr>';
} else {
buffer += '<tr class="hidden">';
hiddenFormats.push(BattleLog.escapeFormat(formatId));
}
// Validate all the numerical data
var values = [row.elo, row.rpr, row.rprd, row.gxe, row.w, row.l, row.t];
for (var j = 0; j < values.length; j++) {
if (typeof values[j] !== 'number' && typeof values[j] !== 'string' || isNaN(values[j])) return self.add('|raw|Error: corrupted ranking data');
}
buffer += '<td>' + BattleLog.escapeFormat(formatId) + '</td><td><strong>' + Math.round(row.elo) + '</strong></td>';
if (row.rprd > 100) {
// High rating deviation. Provisional rating.
buffer += '<td>&ndash;</td>';
buffer += '<td><span><em>' + Math.round(row.rpr) + '<small> &#177; ' + Math.round(row.rprd) + '</small></em> <small>(provisional)</small></span></td>';
} else {
var gxe = Math.round(row.gxe * 10);
buffer += '<td>' + Math.floor(gxe / 10) + '<small>.' + (gxe % 10) + '%</small></td>';
buffer += '<td><em>' + Math.round(row.rpr) + '<small> &#177; ' + Math.round(row.rprd) + '</small></em></td>';
}
var N = parseInt(row.w, 10) + parseInt(row.l, 10) + parseInt(row.t, 10);
buffer += '<td>' + row.w + '</td><td>' + row.l + '</td><td>' + N + '</td></tr>';
}
if (hiddenFormats.length) {
if (hiddenFormats.length === data.length) {
buffer += '<tr class="no-matches"><td colspan="8"><em>This user has not played any ladder games that match "' + BattleLog.escapeHTML(Object.keys(gens).concat(Object.keys(formats)).join(', ')) + '".</em></td></tr>';
}
buffer += '<tr><td colspan="8"><button name="showOtherFormats">' + hiddenFormats.slice(0, 3).join(', ') + (hiddenFormats.length > 3 ? ' and ' + (hiddenFormats.length - 3) + ' other formats' : '') + ' not shown</button></td></tr>';
}
var userid = toID(targets[0]);
var registered = app.user.get('registered');
if (registered && registered.userid === userid) {
buffer += '<tr><td colspan="8" style="text-align:right"><a href="//' + Config.routes.users + '/' + userid + '">Reset W/L</a></tr></td>';
}
buffer += '</table></div>';
self.add('|raw|' + buffer);
}), 'text');
return false;
case 'buttonban':
if (this.checkBroadcast(cmd, text)) return false;
var self = this;
app.addPopupPrompt("Why do you wish to ban this user?", "Ban user", function (reason) {
self.send('/ban ' + toName(target) + ', ' + (reason || ''));
});
return false;
case 'buttonmute':
if (this.checkBroadcast(cmd, text)) return false;
var self = this;
app.addPopupPrompt("Why do you wish to mute this user?", "Mute user", function (reason) {
self.send('/mute ' + toName(target) + ', ' + (reason || ''));
});
return false;
case 'buttonunmute':
if (this.checkBroadcast(cmd, text)) return false;
this.send('/unmute ' + target);
return false;
case 'buttonkick':
case 'buttonwarn':
if (this.checkBroadcast(cmd, text)) return false;
var self = this;
app.addPopupPrompt("Why do you wish to warn this user?", "Warn user", function (reason) {
self.send('/warn ' + toName(target) + ', ' + (reason || ''));
});
return false;
case 'joim':
case 'join':
case 'j':
if (this.checkBroadcast(cmd, text)) return false;
if (noSpace) return text;
if (app.rooms[target]) {
app.focusRoom(target);
return false;
}
var roomid = toID(target);
if (app.rooms[roomid]) {
app.focusRoom(roomid);
return false;
}
return text; // Send the /join command through to the server.
case 'part':
case 'leave':
if (this.checkBroadcast(cmd, text)) return false;
if (this.requestLeave && !this.requestLeave()) return false;
return text;
case 'avatar':
if (this.checkBroadcast(cmd, text)) return false;
var parts = target.split(',');
var avatar = parts[0].toLowerCase().replace(/[^a-z0-9-]+/g, '');
// Replace avatar number with name before sending it to the server, only the client knows what to do with the numbers
if (window.BattleAvatarNumbers && Object.prototype.hasOwnProperty.call(window.BattleAvatarNumbers, avatar)) {
avatar = window.BattleAvatarNumbers[avatar];
}
Storage.prefs('avatar', avatar);
return '/avatar ' + avatar; // Send the command through to the server.
case 'afd':
if (this.checkBroadcast(cmd, text)) return false;
var cleanedTarget = toID(target);
if (cleanedTarget === 'off' || cleanedTarget === 'disable') {
Config.server.afd = false;
if (typeof BattleTextNotAFD !== 'undefined') BattleText = BattleTextNotAFD;
this.add('April Fools\' day mode disabled.');
} else {
Config.server.afd = true;
if (typeof BattleTextAFD !== 'undefined') BattleText = BattleTextAFD;
this.add('April Fools\' day mode enabled.');
}
for (var roomid in app.rooms) {
var battle = app.rooms[roomid] && app.rooms[roomid].battle;
if (!battle) continue;
battle.resetToCurrentTurn();
}
return false;
// documentation of client commands
case 'help':
case 'h':
if (this.checkBroadcast(cmd, text)) return false;
switch (toID(target)) {
case 'chal':
case 'chall':
case 'challenge':
this.add('/challenge - Open a prompt to challenge a user to a battle.');
this.add('/challenge [user] - Challenge the user [user] to a battle.');
this.add('/challenge [user], [format] - Challenge the user [user] to a battle in the specified [format].');
this.add('/challenge [user], [format] @@@ [rules] - Challenge the user [user] to a battle with custom rules.');
this.add('[rules] can be a comma-separated list of: [added rule], ![removed rule], -[banned thing], *[restricted thing], +[unbanned/unrestricted thing]');
this.add('/battlerules - Detailed information on what can go in [rules].');
return false;
case 'accept':
this.add('/accept - Accept a challenge if only one is pending.');
this.add('/accept [user] - Accept a challenge from the specified user.');
return false;
case 'reject':
this.add('/reject - Reject a challenge if only one is pending.');
this.add('/reject [user] - Reject a challenge from the specified user.');
return false;
case 'user':
case 'open':
this.add('/user [user] - Open a popup containing the user [user]\'s avatar, name, rank, and chatroom list.');
return false;
case 'news':
this.add('/news - Opens a popup containing the news.');
return false;
case 'ignore':
case 'unignore':
this.add('/ignore [user] - Ignore all messages from the user [user].');
this.add('/unignore [user] - Remove the user [user] from your ignore list.');
this.add('/ignorelist - List all the users that you currently ignore.');
this.add('Note that staff messages cannot be ignored.');
return false;
case 'nick':
this.add('/nick [new username] - Change your username.');
return false;
case 'clear':
this.add('/clear - Clear the room\'s chat log.');
return false;
case 'showdebug':
case 'hidedebug':
this.add('/showdebug - Receive debug messages from battle events.');
this.add('/hidedebug - Ignore debug messages from battle events.');
return false;
case 'showjoins':
case 'hidejoins':
this.add('/showjoins [room] - Receive users\' join/leave messages. Optionally for only specified room.');
this.add('/hidejoins [room] - Ignore users\' join/leave messages. Optionally for only specified room.');
return false;
case 'showbattles':
case 'hidebattles':
this.add('/showbattles - Receive links to new battles in Lobby.');
this.add('/hidebattles - Ignore links to new battles in Lobby.');
return false;
case 'unpackhidden':
case 'packhidden':
this.add('/unpackhidden - Suppress hiding locked or banned users\' chat messages after the fact.');
this.add('/packhidden - Hide locked or banned users\' chat messages after the fact.');
this.add('Hidden messages from a user can be restored by clicking the button underneath their lock/ban reason.');
return false;
case 'timestamps':
this.add('Set your timestamps preference:');
this.add('/timestamps [all|lobby|pms], [minutes|seconds|off]');
this.add('all - Change all timestamps preferences, lobby - Change only lobby chat preferences, pms - Change only PM preferences.');
this.add('off - Set timestamps off, minutes - Show timestamps of the form [hh:mm], seconds - Show timestamps of the form [hh:mm:ss].');
return false;
case 'highlight':
case 'hl':
this.add('Set up highlights:');
this.add('/highlight add [word 1], [word 2], [...] - Add the provided list of words to your highlight list.');
this.add('/highlight roomadd [word 1], [word 2], [...] - Add the provided list of words to the highlight list of whichever room you used the command in.');
this.add('/highlight list - List all words that currently highlight you.');
this.add('/highlight roomlist - List all words that currently highlight you in whichever room you used the command in.');
this.add('/highlight delete [word 1], [word 2], [...] - Delete the provided list of words from your entire highlight list.');
this.add('/highlight roomdelete [word 1], [word 2], [...] - Delete the provided list of words from the highlight list of whichever room you used the command in.');
this.add('/highlight clear - Clear your global highlight list.');
this.add('/highlight roomclear - Clear the highlight list of whichever room you used the command in.');
this.add('/highlight clearall - Clear your entire highlight list (all rooms and globally).');
return false;
case 'rank':
case 'ranking':
case 'rating':
case 'ladder':
this.add('/rating - Get your own rating.');
this.add('/rating [username] - Get user [username]\'s rating.');
return false;
case 'afd':
this.add('/afd - Enable April Fools\' Day sprites.');
this.add('/afd disable - Disable April Fools\' Day sprites.');
return false;
}
}
return text;
},
challengeData: {},
challengeUserdetails: function (data) {
app.off('response:userdetails', this.challengeUserdetails);
if (!data) return;
if (data.rooms === false) {
this.add('This player does not exist or is not online.');
return;
}
app.focusRoom('');
// if foe has changed name, challengeData.userid will be wrong, so defer to data
var name = data.name || data.userid;
if (/^[a-z0-9]/i.test(name)) name = ' ' + name;
app.rooms[''].challenge(name, this.challengeData.format, this.challengeData.team);
},
showOtherFormats: function (d, target) {
var autoscroll = (this.$chatFrame.scrollTop() + 60 >= this.$chat.height() - this.$chatFrame.height());
var $target = $(target);
var $table = $target.closest('table');
$table.find('tr.hidden').show();
$table.find('tr.no-matches').remove();
$target.closest('tr').remove();
if (autoscroll) {
this.$chatFrame.scrollTop(this.$chat.height());
}
},
destroy: function (alreadyLeft) {
app.user.off('change', this.updateUser, this);
Room.prototype.destroy.call(this, alreadyLeft);
}
}, {
toggleFormatChar: function (textbox, formatChar) {
if (!textbox.setSelectionRange) return false;
var value = textbox.value;
var start = textbox.selectionStart;
var end = textbox.selectionEnd;
// 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
var wrap = formatChar + formatChar;
value = value.substr(0, start) + wrap + value.substr(start, end - start) + wrap + value.substr(end);
start += 2;
end += 2;
// prevent nesting
var nesting = wrap + wrap;
if (value.substr(start - 4, 4) === nesting) {
value = value.substr(0, start - 4) + value.substr(start);
start -= 4;
end -= 4;
} else if (start !== end && value.substr(start - 2, 4) === nesting) {
value = value.substr(0, start - 2) + value.substr(start + 2);
start -= 2;
end -= 4;
}
if (value.substr(end, 4) === nesting) {
value = value.substr(0, end) + value.substr(end + 4);
} else if (start !== end && value.substr(end - 2, 4) === nesting) {
value = value.substr(0, end - 2) + value.substr(end + 2);
end -= 2;
}
textbox.value = value;
textbox.setSelectionRange(start, end);
return true;
}
});
var ChatRoom = this.ChatRoom = ConsoleRoom.extend({
minWidth: 320,
minMainWidth: 580,
maxWidth: 1024,
isSideRoom: true,
initialize: function () {
var buf = '<div class="tournament-wrapper"></div><div class="chat-log"><div class="inner message-log" role="log"></div></div></div><div class="chat-log-add">Connecting...</div><ul class="userlist"></ul>';
this.$el.addClass('ps-room-light').html(buf);
this.$chatAdd = this.$('.chat-log-add');
this.$chatFrame = this.$('.chat-log');
this.$chat = this.$('.inner');
this.$chatbox = null;
this.$tournamentWrapper = this.$('.tournament-wrapper');
this.tournamentBox = null;
this.users = {};
this.userCount = {};
this.$joinLeave = null;
this.joinLeave = {
'join': [],
'leave': []
};
this.$userList = this.$('.userlist');
this.userList = new UserList({
el: this.$userList,
room: this
});
},
updateLayout: function () {
if (this.$el.width() >= 570) {
this.userList.show();
this.$chatFrame.addClass('hasuserlist');
this.$chatAdd.addClass('hasuserlist');
this.$tournamentWrapper.addClass('hasuserlist');
} else {
this.userList.hide();
this.$chatFrame.removeClass('hasuserlist');
this.$chatAdd.removeClass('hasuserlist');
this.$tournamentWrapper.removeClass('hasuserlist');
}
this.$chatFrame.scrollTop(this.$chat.height());
if (this.tournamentBox) this.tournamentBox.updateLayout();
},
show: function () {
Room.prototype.show.apply(this, arguments);
this.updateLayout();
},
join: function () {
app.send('/join ' + this.id);
},
leave: function () {
app.send('/noreply /leave ' + this.id);
app.updateAutojoin();
},
requestLeave: function (e) {
if (app.rooms[''].games && app.rooms[''].games[this.id]) {
app.addPopup(ForfeitPopup, {room: this, sourceEl: e && e.currentTarget, gameType: (this.id.substring(0, 5) === 'help-' ? 'help' : 'game')});
return false;
}
return true;
},
receive: function (data) {
this.add(data);
},
getUserGroup: function (userid) {
return (app.rooms[this.id].users[userid] || {group: ' '}).group;
},
add: function (log) {
if (typeof log === 'string') log = log.split('\n');
var autoscroll = false;
if (this.$chatFrame.scrollTop() + 60 >= this.$chat.height() - this.$chatFrame.height()) {
autoscroll = true;
}
var userlist = '';
for (var i = 0; i < log.length; i++) {
if (log[i].substr(0, 7) === '|users|') {
userlist = log[i];
} else {
this.addRow(log[i]);
}
}
if (userlist) this.addRow(userlist);
if (autoscroll) {
this.$chatFrame.scrollTop(this.$chat.height());
}
var $children = this.$chat.children();
if ($children.length > 900) {
$children.slice(0, 100).remove();
}
},
addPM: function (user, message, pm) {
var autoscroll = false;
if (this.$chatFrame.scrollTop() + 60 >= this.$chat.height() - this.$chatFrame.height()) {
autoscroll = true;
}
if (!(message.substr(0, 4) === '/raw' || message.substr(0, 5) === '/html' || message.substr(0, 6) === '/uhtml' || message.substr(0, 12) === '/uhtmlchange')) this.addChat(user, message, pm);
if (autoscroll) {
this.$chatFrame.scrollTop(this.$chat.height());
}
if (!app.focused) app.playNotificationSound();
},
addRow: function (line) {
var name, name2, silent;
if (line && typeof line === 'string') {
if (line.charAt(0) !== '|') line = '||' + line;
var row = line.substr(1).split('|');
switch (row[0]) {
case 'init':
// ignore (handled elsewhere)
break;
case 'title':
this.title = row[1];
app.roomTitleChanged(this);
app.topbar.updateTabbar();
break;
case 'c':
case 'chat':
if (/[a-zA-Z0-9]/.test(row[1].charAt(0))) row[1] = ' ' + row[1];
this.addChat(row[1], row.slice(2).join('|'));
break;
case ':':
this.timeOffset = ~~(Date.now() / 1000) - (parseInt(row[1], 10) || 0);
break;
case 'c:':
if (/[a-zA-Z0-9]/.test(row[2].charAt(0))) row[2] = ' ' + row[2];
var msgTime = this.timeOffset + (parseInt(row[1], 10) || 0);
this.addChat(row[2], row.slice(3).join('|'), false, msgTime);
break;
case 'tc':
if (/[a-zA-Z0-9]/.test(row[2].charAt(0))) row[2] = ' ' + row[2];
var msgTime = row[1] ? ~~(Date.now() / 1000) - (parseInt(row[1], 10) || 0) : 0;
this.addChat(row[2], row.slice(3).join('|'), false, msgTime);
break;
case 'b':
case 'B':
var id = row[1];
name = row[2];
name2 = row[3];
silent = (row[0] === 'B');
var matches = ChatRoom.parseBattleID(id);
if (!matches) {
return; // bogus room ID could be used to inject JavaScript
}
var format = BattleLog.escapeFormat(matches[1]);
if (silent && !Dex.prefs('showbattles')) return;
this.addJoinLeave();
var battletype = 'Battle';
if (format) {
battletype = format + ' battle';
if (format === 'Random Battle') battletype = 'Random Battle';
}
this.$chat.append('<div class="notice"><a href="' + app.root + id + '" class="ilink">' + battletype + ' started between <strong style="' + BattleLog.hashColor(toUserid(name)) + '">' + BattleLog.escapeHTML(name) + '</strong> and <strong style="' + BattleLog.hashColor(toUserid(name2)) + '">' + BattleLog.escapeHTML(name2) + '</strong>.</a></div>');
break;
case 'j':
case 'join':
case 'J':
this.addJoinLeave('join', row[1], null, row[0] === 'J');
break;
case 'l':
case 'leave':
case 'L':
this.addJoinLeave('leave', row[1], null, row[0] === 'L');
break;
case 'n':
case 'name':
case 'N':
this.addJoinLeave('rename', row[1], row[2], true);
break;
case 'users':
this.parseUserList(row[1]);
break;
case 'usercount':
if (this.id === 'lobby') {
this.userCount.globalUsers = parseInt(row[1], 10);
this.userList.updateUserCount();
}
break;
case 'formats':
// deprecated; please send formats to the global room
app.parseFormats(row);
break;
case 'raw':
case 'html':
this.$chat.append('<div class="notice">' + BattleLog.sanitizeHTML(row.slice(1).join('|')) + '</div>');
break;
case 'notify':
if (row[3] && !this.getHighlight(row[3])) return;
app.playNotificationSound();
this.notifyOnce(row[1], row[2], 'highlight');
break;
case 'tempnotify':
var notifyOnce = row[4] !== '!';
if (!notifyOnce) row[4] = '';
if (row[4] && !this.getHighlight(row[4])) return;
if (!this.notifications) app.playNotificationSound();
this.notify(row[2], row[3], row[1], notifyOnce);
break;
case 'tempnotifyoff':
this.closeNotification(row[1]);
break;
case 'error':
this.$chat.append('<div class="notice message-error">' + BattleLog.parseMessage(row.slice(1).join('|'), true) + '</div>');
break;
case 'uhtml':
case 'uhtmlchange':
var $elements = this.$chat.find('div.uhtml-' + toID(row[1]));
var html = row.slice(2).join('|');
if (!html) {
$elements.remove();
} else if (!$elements.length) {
if (row[0] === 'uhtmlchange') {
this.$chat.prepend('<div class="notice uhtml-' + toID(row[1]) + '">' + BattleLog.sanitizeHTML(html) + '</div>');
} else {
this.$chat.append('<div class="notice uhtml-' + toID(row[1]) + '">' + BattleLog.sanitizeHTML(html) + '</div>');
}
} else if (row[0] === 'uhtmlchange') {
$elements.html(BattleLog.sanitizeHTML(html));
} else {
$elements.remove();
this.$chat.append('<div class="notice uhtml-' + toID(row[1]) + '">' + BattleLog.sanitizeHTML(html) + '</div>');
}
break;
case 'unlink':
// |unlink| is deprecated in favor of |hidelines|
// note: this message has global effects, but it's handled here
// so that it can be included in the scrollback buffer.
if (Dex.prefs('nounlink')) return;
var user = toID(row[2]) || toID(row[1]);
var $messages = $('.chatmessage-' + user);
if (!$messages.length) break;
$messages.find('a').contents().unwrap();
if (row[2]) {
// there used to be a condition for
// row[1] === 'roomhide'
// but it's now always applied
$messages = this.$chat.find('.chatmessage-' + user);
if (!$messages.length) break;
var lineCount = parseInt(row[3], 10) || 0;
if (lineCount) $messages = $messages.slice(-lineCount);
$messages.hide().addClass('revealed').find('button').parent().remove();
this.$chat.children().last().append(' <button name="toggleMessages" value="' + user + '" class="subtle"><small>(' + $messages.length + ' line' + ($messages.length > 1 ? 's' : '') + ' from ' + user + ' hidden)</small></button>');
}
break;
case 'hidelines':
if (Dex.prefs('nounlink')) return;
var user = toID(row[2]);
var $messages = $('.chatmessage-' + user);
if (!$messages.length) break;
$messages.find('a').contents().unwrap();
if (row[1] !== 'unlink') {
$messages = this.$chat.find('.chatmessage-' + user);
if (!$messages.length) break;
var lineCount = parseInt(row[3], 10) || 0;
if (lineCount) $messages = $messages.slice(-lineCount);
$messages.hide().addClass('revealed').find('button').parent().remove();
var staffGroups = Object.keys(Config.groups).filter(function (group) {
return ['staff', 'leadership'].includes(Config.groups[group].type);
});
if (row[1] === 'hide' || staffGroups.includes(this.getUserGroup(app.user.get('userid')))) {
this.$chat.children().last().append(' <button name="toggleMessages" value="' + user + '" class="subtle"><small>(' + $messages.length + ' line' + ($messages.length > 1 ? 's' : '') + ' from ' + user + ' hidden)</small></button>');
}
}
break;
case 'tournament':
case 'tournaments':
if (Dex.prefs('tournaments') === 'hide') {
if (row[1] === 'create') {
this.$chat.append('<div class="notice">' + BattleLog.escapeFormat(row[2]) + ' ' + BattleLog.escapeHTML(row[3]) + ' tournament created (and hidden because you have tournaments disabled).</div>');
} else if (row[1] === 'start') {
this.$chat.append('<div class="notice">Tournament started.</div>');
} else if (row[1] === 'forceend') {
this.$chat.append('<div class="notice">Tournament force-ended.</div>');
} else if (row[1] === 'end') {
this.$chat.append('<div class="notice">Tournament ended.</div>');
}
break;
}
if (!this.tournamentBox) this.tournamentBox = new TournamentBox(this, this.$tournamentWrapper);
if (!this.tournamentBox.parseMessage(row.slice(1), row[0] === 'tournaments')) break;
// fallthrough in case of unparsed message
case '':
this.$chat.append('<div class="notice">' + BattleLog.escapeHTML(row.slice(1).join('|')) + '</div>');
break;
default:
this.$chat.append('<div class="notice"><code>|' + BattleLog.escapeHTML(row.join('|')) + '</code></div>');
break;
}
}
},
toggleMessages: function (user, button) {
var $messages = this.$('.chatmessage-' + user + '.revealed');
var $button = $(button);
if (!$messages.is(':hidden')) {
$messages.hide();
$button.html('<small>(' + ($messages.length) + ' line' + ($messages.length !== 1 ? 's' : '') + ' from ' + user + ' hidden)</small>');
} else {
$button.html('<small>(Hide ' + ($messages.length) + ' line' + ($messages.length !== 1 ? 's' : '') + ' from ' + user + ')</small>');
$messages.show();
}
},
tournamentButton: function (val, button) {
if (this.tournamentBox) this.tournamentBox[$(button).data('type')](val, button);
},
parseUserList: function (userList) {
this.userCount = {};
this.users = {};
var commaIndex = userList.indexOf(',');
if (commaIndex >= 0) {
this.userCount.users = parseInt(userList.substr(0, commaIndex), 10);
var users = userList.substr(commaIndex + 1).split(',');
for (var i = 0, len = users.length; i < len; i++) {
if (users[i]) {
var user = BattleTextParser.parseNameParts(users[i]);
this.users[toUserid(user.name)] = user;
}
}
} else {
this.userCount.users = parseInt(userList, 10);
this.userCount.guests = this.userCount.users;
}
this.userList.construct();
},
addJoinLeave: function (action, name, oldid, silent) {
if (!action) {
this.$joinLeave = null;
this.joinLeave = {
'join': [],
'leave': []
};
return;
}
var user = BattleTextParser.parseNameParts(name);
var userid = toUserid(user.name);
if (action === 'join') {
if (oldid) delete this.users[toUserid(oldid)];
if (!this.users[userid]) this.userCount.users++;
this.users[userid] = user;
this.userList.add(userid);
this.userList.updateUserCount();
this.userList.updateNoUsersOnline();
} else if (action === 'leave') {
if (this.users[userid]) this.userCount.users--;
delete this.users[userid];
this.userList.remove(userid);
this.userList.updateUserCount();
this.userList.updateNoUsersOnline();
} else if (action === 'rename') {
if (oldid) delete this.users[toUserid(oldid)];
this.users[userid] = user;
this.userList.remove(oldid);
this.userList.add(userid);
return;
}
var allShowjoins = Dex.prefs('showjoins') || {};
var showjoins = allShowjoins[Config.server.id];
if (silent && (!showjoins || (!showjoins['global'] && !showjoins[this.id]) || showjoins[this.id] === 0)) {
return;
}
if (!this.$joinLeave) {
this.$chat.append('<div class="message"><small>Loading...</small></div>');
this.$joinLeave = this.$chat.children().last();
}
var formattedUser = user.group + user.name;
if (action === 'join' && this.joinLeave['leave'].includes(formattedUser)) {
this.joinLeave['leave'].splice(this.joinLeave['leave'].indexOf(formattedUser), 1);
} else {
this.joinLeave[action].push(formattedUser);
}
var message = '';
if (this.joinLeave['join'].length) {
message += this.displayJoinLeaves(this.joinLeave['join'], 'joined');
}
if (this.joinLeave['leave'].length) {
if (this.joinLeave['join'].length) message += '; ';
message += this.displayJoinLeaves(this.joinLeave['leave'], 'left') + '<br />';
}
this.$joinLeave.html('<small style="color: #555555">' + message + '</small>');
},
displayJoinLeaves: function (preList, action) {
var message = '';
var list = [];
var named = {};
for (var j = 0; j < preList.length; j++) {
if (!named[preList[j]]) list.push(preList[j]);
named[preList[j]] = true;
}
for (var 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;
},
addChat: function (name, message, pm, msgTime) {
var userid = toUserid(name);
var speakerHasAuth = " +\u2606".indexOf(name.charAt(0)) < 0;
var user = (this.users && this.users[app.user.get('userid')]) || {};
var readerHasAuth = !" +\u2606\u203D!".includes(user.group || ' ');
if (app.ignore[userid] && !speakerHasAuth && !readerHasAuth) return;
// Add this user to the list of people who have spoken recently.
this.markUserActive(userid);
this.$joinLeave = null;
this.joinLeave = {
'join': [],
'leave': []
};
if (pm) {
var pmuserid = toUserid(pm);
var oName = pmuserid === app.user.get('userid') ? name : pm;
var clickableName = '<span class="username" data-name="' + BattleLog.escapeHTML(name) + '">' + BattleLog.escapeHTML(name.substr(1)) + '</span>';
this.$chat.append(
'<div class="chat chatmessage-' + toID(name) + '">' + ChatRoom.getTimestamp('lobby', msgTime) +
'<strong style="' + BattleLog.hashColor(userid) + '">' + clickableName + ':</strong>' +
'<span class="message-pm"><i class="pmnote" data-name="' + BattleLog.escapeHTML(oName) + '">(Private to ' + BattleLog.escapeHTML(pm) + ')</i> ' + BattleLog.parseMessage(message) + '</span>' +
'</div>'
);
return; // PMs independently notify in the main menu; no need to make them notify again with `inchatpm`.
}
var lastMessageDates = Dex.prefs('logtimes') || (Storage.prefs('logtimes', {}), Dex.prefs('logtimes'));
if (!lastMessageDates[Config.server.id]) lastMessageDates[Config.server.id] = {};
var lastMessageDate = lastMessageDates[Config.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
var serverMsgTime = msgTime - (this.timeOffset || 0);
var mayNotify = serverMsgTime > lastMessageDate && userid !== app.user.get('userid');
if (app.focused && (this === app.curSideRoom || this === app.curRoom)) {
this.lastMessageDate = 0;
lastMessageDates[Config.server.id][this.id] = serverMsgTime;
Storage.prefs.save();
} else {
// To be saved on focus
this.lastMessageDate = Math.max(this.lastMessageDate || 0, serverMsgTime);
}
var isHighlighted = userid !== app.user.get('userid') && this.getHighlight(message);
var parsedMessage = MainMenuRoom.parseChatMessage(message, name, ChatRoom.getTimestamp('chat', msgTime), isHighlighted, this.$chat, true);
if (typeof parsedMessage === 'object' && 'noNotify' in parsedMessage) {
mayNotify = mayNotify && !parsedMessage.noNotify;
parsedMessage = parsedMessage.message;
}
if (!$.isArray(parsedMessage)) parsedMessage = [parsedMessage];
for (var i = 0; i < parsedMessage.length; i++) {
if (!parsedMessage[i]) continue;
this.$chat.append(parsedMessage[i]);
}
if (mayNotify && isHighlighted) {
app.playNotificationSound();
var $lastMessage = this.$chat.children().last();
var notifyTitle = "Mentioned by " + name + (this.id === 'lobby' ? '' : " in " + this.title);
var notifyText = $lastMessage.html().indexOf('<span class="spoiler">') >= 0 ? '(spoiler)' : $lastMessage.children().last().text();
this.notifyOnce(notifyTitle, "\"" + notifyText + "\"", 'highlight');
} else if (mayNotify && this.id.substr(0, 5) === 'help-') {
this.notifyOnce("Help message from " + name, "\"" + message + "\"", 'pm');
} else if (mayNotify && name !== '~') { // |c:|~| prefixes a system message
this.subtleNotifyOnce();
}
if (message.slice(0, 4) === '/me ' || message.slice(0, 5) === '/mee') {
Storage.logChat(this.id, '* ' + name + (message.slice(0, 4) === '/me ' ? ' ' : '') + message);
} else if (message.slice(0, 5) === '/log ') {
Storage.logChat(this.id, '' + message.slice(5));
} else {
Storage.logChat(this.id, '' + name + ': ' + message);
}
},
destroy: function (alreadyLeft) {
if (this.tournamentBox) {
app.user.off('saveteams', this.tournamentBox.updateTeams, this.tournamentBox);
}
ConsoleRoom.prototype.destroy.call(this, alreadyLeft);
}
}, {
getTimestamp: function (section, msgTime) {
var pref = Dex.prefs('timestamps') || {};
var sectionPref = ((section === 'pms') ? pref.pms : pref.lobby) || 'off';
if ((sectionPref === 'off') || (sectionPref === undefined)) return '';
var date = (msgTime && !isNaN(msgTime) ? new Date(msgTime * 1000) : new Date());
var components = [date.getHours(), date.getMinutes()];
if (sectionPref === 'seconds') {
components.push(date.getSeconds());
}
return '<small>[' + components.map(
function (x) { return (x < 10) ? '0' + x : x; }
).join(':') + '] </small>';
},
parseBattleID: function (id) {
if (id.lastIndexOf('-') > 6) {
return id.match(/^battle\-([a-z0-9]*)\-?[0-9]*$/);
}
return id.match(/^battle\-([a-z0-9]*[a-z])[0-9]*$/);
}
});
// user list
var UserList = this.UserList = Backbone.View.extend({
initialize: function (options) {
this.room = options.room;
},
events: {
'click .userlist-count': 'toggleUserlist'
},
construct: function () {
var plural = this.room.userCount.users === 1 ? ' user' : ' users';
var buf = '';
var usersString = "" + (this.room.userCount.users || '0') + plural;
buf += '<li class="userlist-count" id="' + this.room.id + '-userlist-users" style="text-align:center;padding:2px 0">';
buf += '<small id="' + this.room.id + '-usercount-users">' + usersString + '</small></li>';
var users = [];
if (this.room.users) {
var self = this;
users = Object.keys(this.room.users).sort(function (a, b) {
return self.comparator(a, b);
});
}
for (var i = 0; i < users.length; i++) {
var userid = users[i];
buf += this.constructItem(userid);
}
if (!users.length) {
buf += this.getNoNamedUsersOnline();
}
if (this.room.userCount.guests) {
buf += '<li id="' + this.room.id + '-userlist-guests" style="text-align:center;padding:2px 0"><small>(<span id="' + this.room.id + '-usercount-guests">' + this.room.userCount.guests + '</span> guest' + (this.room.userCount.guests == 1 ? '' : 's') + ')</small></li>';
}
this.$el.html(buf);
},
toggleUserlist: function (e) {
e.preventDefault();
e.stopPropagation();
if (this.$el.hasClass('userlist-minimized')) {
this.$el.removeClass('userlist-minimized');
this.$el.addClass('userlist-maximized');
} else if (this.$el.hasClass('userlist-maximized')) {
this.$el.removeClass('userlist-maximized');
this.$el.addClass('userlist-minimized');
}
},
show: function () {
this.$el.removeClass('userlist-minimized');
this.$el.removeClass('userlist-maximized');
},
hide: function () {
this.$el.scrollTop(0);
this.$el.removeClass('userlist-maximized');
this.$el.addClass('userlist-minimized');
},
updateUserCount: function () {
var users = Math.max(this.room.userCount.users || 0, this.room.userCount.globalUsers || 0);
$('#' + this.room.id + '-usercount-users').html('' + users + (users === 1 ? ' user' : ' users'));
},
add: function (userid) {
$('#' + this.room.id + '-userlist-user-' + userid).remove();
var users = this.$el.children();
// Determine where to insert the user using a binary search.
var left = 0;
var right = users.length - 1;
while (right >= left) {
var mid = Math.floor((right - left) / 2 + left);
var cmp = this.elemComparator(users[mid], userid);
if (cmp < 0) {
left = mid + 1;
} else if (cmp > 0) {
right = mid - 1;
} else {
// The user is already in the list.
return;
}
}
$(this.constructItem(userid)).insertAfter($(users[right]));
},
remove: function (userid) {
$('#' + this.room.id + '-userlist-user-' + userid).remove();
},
constructItem: function (userid) {
var user = this.room.users[userid];
var text = '';
// Sanitising the `userid` here is probably unnecessary, because
// IDs can't contain anything dangerous.
text += '<li' + (this.room.userForm === userid ? ' class="cur"' : '') + ' id="' + this.room.id + '-userlist-user-' + BattleLog.escapeHTML(userid) + '">';
text += '<button class="userbutton username" data-roomgroup="' + BattleLog.escapeHTML(user.group) + '" data-name="' + BattleLog.escapeHTML(user.name) + '"';
text += (user.away ? ' data-away=true' : '') + (user.status ? ' data-status="' + BattleLog.escapeHTML(user.status) + '"' : '') + '>';
var group = user.group;
var details = Config.groups[group] || {type: 'user'};
var color = user.away ? 'color:#888;' : BattleLog.hashColor(userid);
text += '<em class="group' + (details.group === 2 ? ' staffgroup' : '') + '">' + BattleLog.escapeHTML(group) + '</em>';
if (details.type === 'leadership') {
text += '<strong><em style="' + color + '">' + BattleLog.escapeHTML(user.name) + '</em></strong>';
} else if (details.type === 'staff') {
text += '<strong style="' + color + '">' + BattleLog.escapeHTML(user.name) + '</strong>';
} else {
text += '<span style="' + color + '">' + BattleLog.escapeHTML(user.name) + '</span>';
}
text += '</button>';
text += '</li>';
return text;
},
elemComparator: function (elem, userid) {
// look at the part of the `id` after the roomid
var id = elem.id.substr(this.room.id.length + 1);
switch (id) {
case 'userlist-users':
return -1; // `elem` comes first
case 'userlist-empty':
case 'userlist-unregistered':
case 'userlist-guests':
return 1; // `userid` comes first
}
// extract the portion of the `id` after 'userlist-user-'
var elemuserid = id.substr(14);
return this.comparator(elemuserid, userid);
},
comparator: function (a, b) {
if (a === b) return 0;
var aUser = this.room.users[a];
var bUser = this.room.users[b];
var aRank = (
Config.groups[aUser ? aUser.group : Config.defaultGroup || ' '] ||
{order: (Config.defaultOrder || 10006.5)}
).order;
var bRank = (
Config.groups[bUser ? bUser.group : Config.defaultGroup || ' '] ||
{order: (Config.defaultOrder || 10006.5)}
).order;
if (aRank !== bRank) return aRank - bRank;
if (aUser.away !== bUser.away) return aUser.away - bUser.away;
return (a > b ? 1 : -1);
},
getNoNamedUsersOnline: function () {
return '<li id="' + this.room.id + '-userlist-empty">Only guests</li>';
},
updateNoUsersOnline: function () {
var elem = $('#' + this.room.id + '-userlist-empty');
if ($("[id^=" + this.room.id + "-userlist-user-]").length === 0) {
if (elem.length === 0) {
var guests = $('#' + this.room.id + '-userlist-guests');
if (guests.length === 0) {
this.$el.append($(this.getNoNamedUsersOnline()));
} else {
guests.before($(this.getNoNamedUsersOnline()));
}
}
} else {
elem.remove();
}
}
});
}).call(this, jQuery);
function ChatHistory() {
this.lines = [];
this.index = 0;
}
ChatHistory.prototype.push = function (line) {
var duplicate = this.lines.indexOf(line);
if (duplicate >= 0) this.lines.splice(duplicate, 1);
if (this.lines.length > 100) this.lines.splice(0, 20);
this.lines.push(line);
this.index = this.lines.length;
};
ChatHistory.prototype.up = function (line) { // Ensure index !== 0 first!
if (line !== '') this.lines[this.index] = line;
return this.lines[--this.index];
};
ChatHistory.prototype.down = function (line) {
if (line !== '') this.lines[this.index] = line;
if (this.index === this.lines.length) return '';
if (++this.index === this.lines.length) return '';
return this.lines[this.index];
};