pi-plus-45x23 / AoPS Master Script

// ==UserScript==
// @name AoPS Master Script
// @description A master script for the Art of Problem Solving website
// @author pi_Plus_45x23
// @version 1.4.1
// @encoding utf-8
// @license MIT; https://opensource.org/licenses/MIT
// @icon https://assets.artofproblemsolving.com/images/apple-touch-icon.png
// @homepage https://github.com/pi-plus-45x23/aops-master-script
// @supportURL https://github.com/pi-plus-45x23/aops-master-script/issues/new
// @updateURL https://openuserjs.org/meta/pi-plus-45x23/AoPS_Master_Script.meta.js
// @downloadURL https://openuserjs.org/src/scripts/pi-plus-45x23/AoPS_Master_Script.user.js
// @match *://artofproblemsolving.com/*
// @exclude *://artofproblemsolving.com/*/ajax.php*
// @exclude *://artofproblemsolving.com/*/*.js*
// @exclude *://artofproblemsolving.com/schoolhouse/unabletoconnect.php*
// @exclude *://artofproblemsolving.com/m/schoolhouse/ajax-text-input.php*
// @grant unsafeWindow
// @grant window.focus
// @grant GM.addStyle
// @grant GM_addStyle
// @grant GM.getValue
// @grant GM_getValue
// @grant GM.setValue
// @grant GM_setValue
// @grant GM.getResourceText
// @grant GM_getResourceText
// @grant GM.info
// @grant GM_info
// @grant GM.notification
// @grant GM_notification
// @resource jqUiCss https://code.jquery.com/ui/1.12.1/themes/smoothness/jquery-ui.css
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// @require https://code.jquery.com/ui/1.12.1/jquery-ui.min.js
// @require https://cdn.rawgit.com/eligrey/FileSaver.js/e9d941381475b5df8b7d7691013401e171014e89/FileSaver.min.js
// @require https://pastebin.com/raw/UCN2ihRr
// @require https://rawgit.com/Frug/js-bbcode-parser/master/bbcode-parser.js
// @require https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.4/socket.io.js
// @require https://gist.githubusercontent.com/tilmanpotthof/8549286/raw/f1c0e68547e2f03af1ae84720ec524ad4e54e40c/intercept-function.js
// ==/UserScript==
/* jshint
     browser: true,
     jquery: true,
     node: true,
     esnext: false,
     esversion: 6,
     eqeqeq: true,
     indent: 2,
     latedef: true,
     newcap: true,
     quotmark: single,
     strict: true,
     undef: true,
     eqnull: true
*/
/* globals unsafeWindow, GM, saveAs, BBCodeParser, io, interceptFunction, moment, AoPS */

(() => {
  'use strict';

  // Notifications are weird in Firefox
  if (/Firefox/.test(navigator.userAgent)) {
    if (Notification.permission === 'default') {
      Notification.requestPermission();
    }

    GM.notify = (details) => {
      const notif = new Notification(
        details.title,

        {
          body: details.text,
          icon: details.image,
        },
      ); // jshint ignore: line

      setTimeout(notif.close.bind(notif), details.timeout);
      notif.onclick = details.onclick;
    };
  } else {
    GM.notify = GM.notification;
  }

  // Helper functions
  const { getValue, setValue } = (() => {
    let valueCache;

    /* jshint ignore: start */
    const getCache = async () => {
      if (typeof valueCache === 'undefined') {
        // Cache value if we haven't yet
        valueCache = await GM.getValue(String(AoPS.session.user_id), {});
      }
    };

    return {
      async getValue(name, dflt) {
        await getCache();

        if (Reflect.has(valueCache, name)) return valueCache[name];

        return dflt;
      },

      async setValue(name, value) {
        await getCache();
        valueCache[name] = value;
        await GM.setValue(String(AoPS.session.user_id), valueCache);
      },
    };
    /* jshint ignore: end */
  })();

  // Removes annoying AoPS Academy/AoPS/Beast Academy menu at top of page
  $('#header .bluebar .site-links').remove();
  $('#header .bluebar').append($('#header .sizer').clone(true));

  GM.addStyle(`
    @media (max-width: 768px) {
      #header .bluebar .sizer {
        display: none;
      }
    }

    @media (min-width: 768px) {
      #header .action-wrapper .sizer {
        display: none;
      }
    }
  `);

  // Fix search bar
  $('#search-site').on(
    'keyup',

    (e) => {
      if (e.keyCode === 13) {
        const value = $('#search-site').val().trim();

        if (value) {
          $('#form-site-search-field').val(value);
          $('#form-site-search').submit();
        }
      }
    },
  ); // jshint ignore: line

  $('#search-clicker').on(
    'click',

    () => {
      const value = $('#search-site').val().trim();

      if (value) {
        $('#form-site-search-field').val(value);
        $('#form-site-search').submit();
      }
    },
  ); // jshint ignore: line

  let UserPrivateWindow;

  if (location.pathname === '/') {
    // Front page hotkeys
    $(document).on(
      'keypress',

      (e) => {
        if ($(e.target).closest('input, select, textarea').length === 0 && !e.ctrlKey && !e.altKey) {
          switch (e.which - 96) {
            case 1:
              // A
              location.href = '/alcumus';
              break;
            case 2:
              // B
              location.href = '/store';
              break;
            case 3:
              // C
              location.href = '/community';
              break;
            case 6:
              // F
              location.href = '/ftw';
              break;
            case 13:
              // M
              location.href = '/mathcounts_trainer';
              break;
            case 16:
              // P
              location.href = '/polymath';
              break;
            case 17:
              // Q
              location.href = '/classroom';
              break;
            case 18:
              // R
              location.href = '/reaper';
              break;
            case 19:
              // S
              location.href = '/school';
              break;
            case 23:
              // W
              location.href = '/wiki/index.php?title=Main_Page';
          }
        }
      },
    ); // jshint ignore: line
  } else if (/^\/(|m\/)community/.test(location.pathname)) {
    /* globals Backbone */

    // Community vars
    const { Community } = AoPS;
    const { Constants, Utils, Views } = Community;

    // Timestamps should be always absolute
    Utils.makePrettyTime = Utils.makePrettyTimeStatic;

    // FAQ Addition
    Views.FAQ = class FAQViews extends Views.FAQ {
      render() {
        this.$el.cmtyLoadFromFile({
          filename: `${AoPS.bootstrap_data.datastore_path}cms/community/lang_en/help.html`,

          onFinish: () => {
            this.$('.cmty-help-container').append($(`
              <div class="grey-panel closed" data-key="start">
                <div class="toggle" />
                <div class="header">
                  <h3>AoPS Master Script</h3>
                </div>
                <div class="content">
                  <div class="cmty-faq-item">
                    <div class="cmty-faq-question">
                      How do I change my community homepage?
                    </div>
                    <div class="cmty-faq-answer">
                      Go to any folder (a category that holds forums and collections) and click on the <span class="aops-font">3</span> button. From there, you can set your community homepage.
                    </div>
                  </div>
                  <div class="cmty-faq-item">
                    <div class="cmty-faq-question">
                      How do I enable or disable the guestblocker?
                    </div>
                    <div class="cmty-faq-answer">
                      Go to any folder and click on the <span class="aops-font">3</span> button. If you have admin privileges, then you will be able to toggle the "Guestblocker" setting. Otherwise, you'll still be able to see the guestblock status. This type of guestblocker is NOT bypassable and will 100% block all guests from your forum/blog.
                    </div>
                  </div>
                  <div class="cmty-faq-item">
                    <div class="cmty-faq-question">
                      How do I use the hotkeys?
                    </div>
                    <div class="cmty-faq-answer">
                      This script enables certain hotkeys at the Art of Problem Solving homepage. A full list of the hotkeys are as follows:
                      <br />
                      <br />
                      <strong>A</strong>: <a href="/alcumus">Alcumus</a>
                      <br />
                      <strong>B</strong>: <a href="/store">Bookstore</a>
                      <br />
                      <strong>C</strong>: <a href="/community">Community</a>
                      <br />
                      <strong>F</strong>: <a href="/ftw">For the Win!</a>
                      <br />
                      <strong>M</strong>: <a href="/mathcounts_trainer">MATHCOUNTS Trainer</a>
                      <br />
                      <strong>P</strong>: <a href="/polymath">AoPS CrowdMath</a>
                      <br />
                      <strong>Q</strong>: <a href="/classroom">Classroom</a>
                      <br />
                      <strong>R</strong>: <a href="/reaper">Reaper</a>
                      <br />
                      <strong>S</strong>: <a href="/school">Online School</a>
                      <br />
                      <strong>W</strong>: <a href="/wiki/index.php?title=Main_Page">AoPS Wiki</a>
                    </div>
                  </div>
                </div>
              </div>
            `));

            this.$loader.detach();
            this.$('.grey-panel .header')
              .add('.grey-panel .toggle')
              .on(
                'click',

                function click() {
                  $(this).parent().toggleClass('closed');
                },
              ); // jshint ignore: line
          },
        });
      }
    };

    // General info block
    Views.CategoryAdminBlockGeneralInfo = class GenInfo extends Views.CategoryAdminBlockGeneralInfo {
      /* jshint ignore: start */
      async initialize() {
        super.initialize();

        // Event triggers
        this.events = Object.assign(
          {},

          super.events,

          {
            'change select[name="cmty-cat-admin-community-homepage-setting"]':
              'onChangeHomepageSetting',
          },
        );

        if (
          this.model.get('category_type').startsWith('folder')
          && this.model.get('category_id') !== 0
        ) {
          const defaultMasterCategory = AoPS.bootstrap_data.my_profile.base_view === 'main'
            ? Constants.master_category_id
            : Constants.portal_category_id;
          const masterCategoryId = await getValue('homepage', defaultMasterCategory);
          const homepage = this.model.get('category_id') === masterCategoryId;

          const $form = $(`
            <div class="form-group">
              <div>
                <label>
                  Community Homepage
                </label>
              </div>
              <div>
                <select name="cmty-cat-admin-community-homepage-setting">
                  <option value="1"${homepage ? ' selected' : ''}>Yes</option>
                  <option value="0"${!homepage ? ' selected' : ''}>No</option>
                </select>
              </div>
            </div>
          `);

          this.$el.find('.form').append($form);
        }
      }
      /* jshint ignore: end */

      onChangeHomepageSetting() {
        const newSetting = this.$('select[name="cmty-cat-admin-community-homepage-setting"]').val();

        if (newSetting === '1') {
          setValue('homepage', this.model.get('category_id')).then(() => location.reload());
        } else {
          setValue(
            'homepage',
            AoPS.bootstrap_data.my_profile.base_view === 'main'
              ? Constants.master_category_id // jshint ignore:line
              : Constants.portal_category_id,
          ).then(() => location.reload()); // jshint ignore:line
        }
      }
    };

    Views.CategoryAdminBlockPermissions = class PermView extends Views.CategoryAdminBlockPermissions {
      initialize() {
        super.initialize();
        this.events = Object.assign(
          {},

          super.events,

          {
            'change select[name="cmty-cat-admin-guestblock"]':
              'onChangeGuestblock',
          },
        ); // jshint ignore:line
      }

      render(...args) {
        super.render(...args);
        const guestblocker = !!this.model.attributes.users.find(user => user.role === 'deny' && user.user_id === 1);

        if (
          this.model.getPermission('c_can_lock_category')
          && this.model.get('is_public') && !this.is_new // jshint ignore:line
        ) {
          this.$el.find('.form').append($(`
            <div class="form-group">
              <div>
                <label>
                  Guestblocker
                </label>
              </div>
              <div>
                <select name="cmty-cat-admin-guestblock">
                  <option value="1"${guestblocker ? ' selected' : ''}>Yes</option>
                  <option value="0"${!guestblocker ? ' selected' : ''}>No</option>
                </select>
              </div>
            </div>
          `));
        } else if (this.model.get('is_public') && !this.is_new) {
          this.$el.find('.form').append($(`
            <div class="form-group">
              <div>
                <label>
                  Guestblocker
                </label>
              </div>
              <div>
                ${guestblocker ? 'Yes' : 'No'}
              </div>
            </div>
          `));
        }
      }

      fetchRenderData(...args) {
        const data = super.fetchRenderData(...args);

        // View ALL user boxes
        data.user_boxes = data.user_boxes.map((userBox) => {
          const newBox = Object.assign({}, userBox, { show_only_if_can_edit: false });
          return newBox;
        });

        return data;
      }

      onChangeGuestblock() {
        const newSetting = this.$('select[name="cmty-cat-admin-guestblock"]').val();

        if (newSetting === '1') {
          Utils.cmty_ajax.add({
            a: 'add_category_user',

            params: {
              category_id: this.model.get('category_id'),
              user_id: 1,
              role: 'deny',
            },

            main_handler() {},
          });
        } else {
          Utils.cmty_ajax.add({
            a: 'remove_category_user',

            params: {
              category_id: this.model.get('category_id'),
              user_id: 1,
            },

            main_handler() {},
          });
        }
      }
    };

    if (!Reflect.has(AoPS, 'Blog')) {
      Community.Router = class Router extends Community.Router {
        constructFolder(category) {
          let pageClass;

          if (category === this.models.master_category) {
            this.constructAoPSMasterCollection();
            return;
          }

          if (category === this.models.portal_category) {
            this.buildPortal();
            return;
          }

          pageClass = 'cmty-page-folder';

          /* jshint ignore: start */
          const buildPage = async () => {
            this.myPage.hideLoader();
            const isCustom = category.get('category_id') === await getValue('homepage', 0);

            if (isCustom) {
              this.startMainPage(true);
            } else {
              this.buildCoreCommunityBreadcrumbs(category);
              this.setTitle(_.unescape(category.get('category_name')));
            }

            this.myPage.showElement({
              id: `cmty-category-${category.get('category_id')}-top`,

              constructor() {
                return new (isCustom ? Views.HeadlessFolder : Views.Folder)({
                  model: category,
                });
              },
            });
          };
          /* jshint ignore: end */

          this.startNonTopicsPage({
            reset_breadcrumbs: false,
          });

          if (category.get('category_type') === 'bookmark_forums') {
            this.constructMyAopsTop();
            pageClass += ' cmty-page-my-aops cmty-page-my-bookmarks';
          }

          if (category.get('category_type') === 'bookmark_users') {
            pageClass += '  cmty-page-my-aops  cmty-page-my-bookmarks';
            this.constructMyAopsTop();
          }

          this.myPage.setClass(pageClass);
          this.setWindowResizeAction(false);
          buildPage(); // jshint ignore: line
        }

        startMainPage(custom = false) {
          this.startNonTopicsPage();
          document.title = 'AoPS Community';

          if (!custom) this.myPage.showLoader();

          Utils.cmty_ajax.cancelAll({ cancel_type: 'master' });
          this.myPage.setClass('cmty-page-folder');
          this.setWindowResizeAction(false);
          this.myPage.hideBreadcrumbs();

          this.myPage.showElement({
            id: 'cmty-main-page-top',

            constructor: () => {
              return new Views.AoPSCollectionTop({
                model: this.models.master,
              });
            },

            location: 'subheader',
          });
        }

        /* jshint ignore: start */
        async constructBaseView() {
          const defaultMasterCategory = this.models.master.get('base_view') === 'main'
          ? Constants.master_category_id
          : Constants.portal_category_id;
          const masterCategoryId = await getValue('homepage', defaultMasterCategory);

          if (masterCategoryId !== defaultMasterCategory) {
            this.parseEncodedUrl(`c${masterCategoryId}`);
          } else if (this.models.master.get('base_view') === 'main') {
            this.constructAoPSMasterCollection();
          } else {
            this.buildPortal();
          }
        }
        /* jshint ignore: end */
      };

      /**
       * I think this will fix the problems with the community homepage
       * sometimes not loading correctly
       * However, this fix causes weird glitches like some forums loading tags instead of topics
       * and topics not being selected properly in said forums.
       * ^Fixed :D
       */
      if (Backbone.History.started) {
        // Re-render page if history already started
        Backbone.history.stop();
        $('#main-column-standard').html('');
        $('#subheader').html('');

        const app = new Community.Router({
          master: AoPS.Community.MasterModel,
        });

        $('#main-column-standard').prepend(app.myPage.el);

        Backbone.history.start({
          pushState: true,
          root: 'community',
        });
      }
    }
  } else if (/^\/reaper\/reaper\.php/.test(location.pathname)) {
    const reaper = Object.keys(unsafeWindow).find(obj => unsafeWindow[obj] && Reflect.has(unsafeWindow[obj], 'reap'));

    if (typeof reaper !== 'undefined') {
      Object.keys(reaper).find((method) => {
        if (Reflect.has(reaper[method], 'io')) {
          reaper.socket = reaper[method];
          return true;
        }

        return false;
      });

      // Reaper desktop notifications
      reaper.socket.on(
        'reap',
        (name, delta) => {
          if (!document.hasFocus()) {
            GM.notify({
              text: `${name} reaped for ${reaper.prettySeconds(delta)}.`,
              title: 'reaper',
              image: 'https://assets.artofproblemsolving.com/images/apple-touch-icon.png',
              timeout: 7000,

              onclick() {
                window.focus();
              },
            });
          }
        },
      ); // jshint ignore: line
    }
  } else if (/^\/schoolhouse\//.test(location.pathname)) {
    /* globals
         dhtmlx,
         dhtmlxEvent,
         Classroom,
         socket,
         MathJax,
         emitter,
         ContextMenu,
         Message,
         User,
         App,
         ScrollPanel: true,
         InputPanel: true,
         UserlistPanel: true,
         ClassroomWindow: true,
         PrivateWindow,
         Mod,
    */

    const { Flyout, Modal, alert } = unsafeWindow;

    // Constants and functions
    const scriptObj = {
      version: GM.info.script.version,
      idle: 10 * 60 * 1000,
      join: 4 * 60 * 1000,
      limit: (700 * (3 / 5)) * 60 * 60 * 24,
      ids: {},
      jqUiCssSrc: GM.getResourceText('jqUiCss'),

      helpPage: $(`
        <div className="helpPage">
          <div>
            <h3>List of Commands Part 1</h3>
            <table style="width: 100%;">
              <tr>
                <td>/deleteall [username]</td>
                <td>
                  Deletes all messages by [username]
                  <br />
                  <b>Alias:</b> /dall
                </td>
              </tr>
              <tr>
                <td>/disconnect</td>
                <td>Disconnects from classroom</td>
              </tr>
              <tr>
                <td>/gwhisper [username] [message]</td>
                <td>
                  Sends whisper to all rooms [username] is in
                  <br />
                  <b>Alias:</b> /gw
                </td>
              </tr>
              <tr>
                <td>/join [room]</td>
                <td>Joins [room]</td>
              </tr>
              <tr>
                <td>/leave [room]</td>
                <td>Leaves [room], defaults to current room if [room] is not specified</td>
              </tr>
              <tr>
                <td>/mastersave</td>
                <td>
                  Saves transcripts for all classroom sessions
                  <br />
                  <b>Alias:</b> /ms, /msave
                </td>
              </tr>
            </table>
          </div>
          <div>
            <h3>List of Commands Part 2</h3>
            <table style="width: 100%;">
              <tr>
                <td>/mute [username]</td>
                <td>Toggles mute for [username]</td>
              </tr>
              <tr>
                <td>/muted</td>
                <td>Shows list of muted users</td>
              </tr>
              <tr>
                <td>/save</td>
                <td>Saves and downloads the transcript of the classroom</td>
              </tr>
              <tr>
                <td>/unmute</td>
                <td>Unmutes everybody</td>
              </tr>
              <tr>
                <td>/whisper [username] [message]</td>
                <td>
                  Whispers [message] to [username]
                  <br />
                  <b>Alias:</b> /w
                </td>
              </tr>
            </table>
          </div>
          <div>
            <h3>Userlist</h3>
            <img src="https://i.imgur.com/fb45W9G.png" alt="userlist" />
            <br />
            A <span style="color: #080;">green user</span> indicates a user who has joined in the last four minutes, a <span style="background-color: #ffa;">yellow background</span> indicates a user who has been inactive for at least ten minutes, and a <span style="font-style: italic; color: #aaa;">grey user</span> indicates an invisible user. All muted users show up as <span style="font-style: italic; font-family: georgia, serif;">gagged</span>. Clicking on a username will open a menu displaying a list of actions on that user.
          </div>
          <div>
            <h3>Local mutes, stickies, and deletion</h3>
            This classroom UI is extremely customizable. You can selectively mute users and sticky and delete messages at a local level. Additionally, messages deleted by a moderator will be highlighted.

            <h3>Automute</h3>
            Automute automatically mutes anybody who posts a message starting with "last."
            To enable it, go to Options and check "Automute."
            This feature is disabled by default.
          </div>
          <div>
            <h3>Notifications</h3>
            If a message is posted to the classroom and you are on a different tab, a notification will appear to alert you. To disable, go to Options and uncheck &quot;Notifications&quot;.
            <br />
            This script will also notify you if you have been mentioned (someone included @[your username] in their message)

            <h3>Quoting</h3>
            This script also adds a custom [quote] BBCode tag to the AoPS Schoolhouse. It is used in the same way as in the AoPS Message Boards. To quote someone's post in the classroom, click on the "Quote" button that comes up when you hover over their message. However, to prevent spam, it is impossible to nest more than two quotes.
          </div>
          <div>
            <h3>User whispers</h3>
            This script enables non-moderator users to send whispers and private messages to other users of this script. Initially, this took the form of a simple automatic encryption/decryption script, but was changed due a complaint from AoPSSheriff that the program was exclusionary. Thus, all whispers are sent through a 3rd party server made by me that simply relays the messages it receives. The server supports HTTPS by default, so the connection to the server itself is encrypted. However, in order to verify your identity, this script sends your session ID to the server. The server relays this to AoPS's server and does NOT log or store this information at all. Additionally, the server keeps NO message log and will not violate your privacy. You can view the source code <a href="https://github.com/pi-plus-45x23/aops-whisper-server/">here</a>.
          </div>
          <div>
            <h3>Username autocomplete</h3>
            This script adds a mention (@[username]) system to the existing username autocompletion. Upon typing the beginning of a username, a dropdown menu will appear listing all autocomplete options.

            <h3>Miscellaneous</h3>
            The classroom loads all messages in the current day via infinite scroll.
            <br />
            The part of the URL after <a href="http://artofproblemsolving.com/schoolhouse/room/">http://artofproblemsolving.com/schoolhouse/room/</a> is interpreted literally.
            For example, going to <a href="http://artofproblemsolving.com/schoolhouse/room/1155F">http://artofproblemsolving.com/schoolhouse/room/1155F</a> will take you to room 1155F.
          </div>
        </div>
      `),

      updateMutes() {
        Object.values(App.windows).forEach((win) => {
          if (win instanceof ClassroomWindow) {
            /* jshint ignore: start */
            Object.values(this.ids).forEach(async (id) => {
              const user = win.getUser(id);

              if (user) {
                const isMuted = (await getValue('muted', [])).indexOf(user.name) !== -1;

                // Only add if strictly necessary
                if (user.muted !== isMuted) win.addUser(user);
              }
            });
            /* jshint ignore: end */
          }
        });
      },

      getId(username) {
        const { ids } = this;
        return ids[Object.keys(ids).find(name => name.toLowerCase() === username.toLowerCase()) || ''] || 1;
      },

      getAvatar: (() => {
        const avatars = {};
        let promise = Promise.resolve();

        return (username, callback) => {
          if (username === '') {
            // Execute callback after Promises are finished
            promise = promise.then(callback);
            return;
          }

          promise = promise
            .then(() => {
              if (avatars[username]) {
                return avatars[username];
              }

              return $.when($.post(
                '/m/community/ajax.php',

                {
                  a: 'fetch_user_profile',
                  user_identifier: username,
                  aops_logged_in: true,
                  aops_user_id: AoPS.session.user_id,
                  aops_session_id: AoPS.session.id,
                },
              )) // jshint ignore: line
                .then((data) => {
                  avatars[username] = data.error_code
                    ? 'http://avatar.artofproblemsolving.com/no_avatar.png' // jshint ignore: line
                    : `http:${data.response.user_data.avatar}`;
                  return avatars[username];
                }); // jshint ignore: line
            })
            .then(callback);
        };
      })(),

      notification(title, username, message, roomId) {
        this.getAvatar(
          username,

          (avatar) => {
            GM.notify({
              title,
              text: Classroom.utils.htmlToBBCode(message),
              image: avatar,
              timeout: 4000,

              onclick() {
                window.focus();
                const win = App.getWindow(roomId);

                if (win) {
                  win.bringToTop();
                }
              },
            }); // jshint ignore: line
          },
        ); // jshint ignore: line
      },

      getPublicMessageActions(msg, type) {
        // Yay jQuery!
        const $wrapper = $('<div />').addClass('actions');

        const $timestamp = $('<span />').addClass('action').text(moment(msg.time).format('HH:mm:ss'));
        $wrapper.append($timestamp);

        if (!$($.parseHTML(msg.message)).hasClass('chessboard')) {
          // No quoting chessboards
          const $quote = $('<span />').addClass('action quote').attr('id', msg.room_id).text('Quote');
          $wrapper.append($quote);
        }

        if (type === 'sticky') {
          const $remove = $('<span />').addClass('action delete-sticky').text('Remove');
          $wrapper.append($remove);
        } else {
          const $delete = $('<span />').addClass('action delete').text('Delete');
          $wrapper.append($delete);

          const $sticky = $('<span />').addClass('action sticky').text('Sticky');
          $wrapper.append($sticky);
        }

        return $wrapper;
      },

      getWhisperActions(msg) {
        return $(`
          <div class="actions">
            <span class="action">${moment(msg.time).format('HH:mm:ss')}</span>
            <span class="action quote-whisper" id=${msg.room_id}>Quote</span>
            <span class="action delete-whisper">Delete</span>
          </div>
        `);
      },

      appendOptionMenuItems(items) {
        items.push({
          id: 'automute',
          text: 'Automute',
          img: Classroom.properties.automute ? App.checkmark : '',
        });

        items.push({
          id: 'notifications',
          text: 'Notifications',
          img: Classroom.properties.notifications ? App.checkmark : '',
        });
      },

      /* jshint ignore: start */
      async appendUserContextMenu(target) {
        const menu = {};
        const user = this.getUser(target);
        const win = this.getWindow(target);

        if (!user) return menu;

        /**
         * Unfortunately we have to mutate params
         * because the existing API makes us >:-(
         */
        if (!Classroom.utils.isModerator()) {
          if (
            user
            && win
            && win.whisper
            && Number(win.whisper.user_id) === user.user_id
          ) {
            menu.Unwhisper = this.unwhisper;
          } else {
            menu.Whisper = this.whisper;

            if (target.plus) {
              menu['Whisper Plus'] = this.whisperPlus;
            }
          }

          menu['Open Private Chat'] = this.privateChat;

          if (target.plus) {
            menu['Open Private Chat Plus'] = this.privateChatPlus;
          }
        }

        if ((await getValue('muted', [])).indexOf(user.name) !== -1) {
          menu.Unmute = this.unmute;
        } else if (user.name !== AoPS.session.username) {
          menu.Mute = this.mute;
        }

        return menu;
      },
      /* jshint ignore: end */

      whisper(target) {
        const user = scriptObj.getUser(target);

        if (user) {
          const win = scriptObj.getWindow(target);

          if (win) {
            win.setInputMessage(`Whispering to ${user.name} ... (ESC to abort)`);
            win.focus();
            win.whisper = user;
          }
        }
      },

      whisperPlus(target) {
        const user = scriptObj.getUser(target);

        if (user) {
          const win = scriptObj.getWindow(target);

          if (win) {
            win.setInputMessage(`Whispering to ${user.name} ... (ESC to abort)`);
            win.focus();
            win.whisper = user;
            win.whisper.plus = target.plus;
          }
        }
      },

      unwhisper(target) {
        const user = scriptObj.getUser(target);

        if (user) {
          const win = scriptObj.getWindow(target);

          if (win) {
            win.callEvent('onClearWhisper');
            win.focus();
          }
        }
      },

      privateChat(target) {
        const user = scriptObj.getUser(target);

        if (user) {
          Classroom.socketio.emit('private start', user.name, user.room_id, '');
        }
      },

      privateChatPlus(target) {
        const user = scriptObj.getUser(target);

        if (user) {
          Classroom.socketio.emit('private start', user.name, user.room_id, target.plus);
        }
      },

      mute(target) {
        const user = scriptObj.getUser(target);

        /* jshint ignore: start */
        const ok = async function onOkButton() {
          const muted = await getValue('muted', []);
          const timeouts = await getValue('timeouts', {});
          const minutes = Number($('#modal-minutes').val());

          if (Classroom.utils.isPositiveInteger(minutes)) {
            this.close();
            muted.push(user.name);
            timeouts[user.name] = new Date().getTime() + (minutes * 60 * 1000);
            await setValue('muted', muted);
            await setValue('timeouts', timeouts);
            scriptObj.updateMutes();

            setTimeout(
              async () => {
                const muted = await getValue('muted', []);
                const timeouts = await getValue('timeouts', {});

                if (muted.indexOf(user.name) !== -1) {
                  muted.splice(muted.indexOf(user.name), 1);
                }

                if (timeouts[user.name]) {
                  delete timeouts[user.name];
                }

                await setValue('muted', muted);
                await setValue('timeouts', timeouts);

                if (user) scriptObj.updateMutes();
              },

              timeouts[user.name] - new Date().getTime(),
            );
          } else if (minutes === 0) {
            this.close();
            muted.push(user.name);
            await setValue('muted', muted);
            scriptObj.updateMutes();
          } else {
            this.close();
            alert('Invalid number of minutes specified. Please use a nonnegative integer.');
          }
        };
        /* jshint ignore: end */

        if (user) {
          const html = [
            'For how many minutes should the user be muted? Leave at zero for a permanent mute.',
            '<input id="modal-minutes" style="width:98%;padding:2px 4px" placeholder="Minutes to mute" value="0">',
          ];

          const modal = new Modal({
            title: 'Mute',
            content: html.join('<br /><br />'),
            maxWidth: 300,

            onOpen() {
              const element = $('#modal-minutes')[0];
              element.focus();

              element.addEventListener(
                'keyup',

                (e) => {
                  if (e.which === 13) {
                    ok.call(modal); // jshint ignore: line
                  }
                },
              ); // jshint ignore: line
            },

            buttons: {
              OK: ok, // jshint ignore: line

              Cancel() {
                this.close();
              },
            },
          });

          modal.open();
        }
      },

      /* jshint ignore: start */
      async unmute(target) {
        const muted = await getValue('muted', []);
        const timeouts = await getValue('timeouts', {});
        const user = scriptObj.getUser(target);

        if (user) {
          muted.splice(muted.indexOf(user.name), 1);

          if (timeouts[user.name]) delete timeouts[user.name];

          await setValue('muted', muted);
          await setValue('timeouts', timeouts);
          scriptObj.updateMutes();
        }
      },
      /* jshint ignore: end */

      stickyMessage(target) {
        const dom = Classroom.utils.getDomParent(target.parentNode, 'public-message-container');

        if (dom && dom.message) {
          if (Classroom.utils.isModeratorOfRoom(dom.message.room_id)) return;

          App.getWindow(dom.message.room_id).addSticky(dom.message);
        }
      },

      unstickyMessage(target) {
        const dom = Classroom.utils.getDomParent(target.parentNode, 'public-message-container');

        if (dom && dom.message) {
          if (Classroom.utils.isModeratorOfRoom(dom.message.room_id)) return;

          App.getWindow(dom.message.room_id).removeSticky(dom.message.id);
        }
      },

      quoteMessage(target) {
        const dom = Classroom.utils.getDomParent(target.parentNode, 'public-message-container');

        if (dom && dom.message) {
          const { message } = dom;
          const win = App.getWindow(message.room_id);
          win.appendInput(`[quote=${message.username}]${Classroom.utils.htmlToBBCode(message.message, message.latex)}[/quote] `);

          // Fix for empty message
          win.clearInputMessage();
          win.focus();
        }
      },

      deleteMessage(target) {
        const dom = Classroom.utils.getDomParent(target.parentNode, 'public-message-container');

        if (dom && dom.message) {
          // We still need to locally delete even when we're a mod, since the server delete will just highlight the message
          App.getWindow(dom.message.room_id).removeMessage(dom.message.id);
        }
      },

      quoteWhisper(target) {
        const dom = Classroom.utils.getDomParent(target.parentNode, 'whisper-message-container');

        if (dom && dom.message) {
          const message = dom.message;
          const win = App.getWindow(message.room_id);

          // Plus casework
          const from = message.type === 'whisper-to' ? AoPS.session.username : message.username;
          const to = message.type === 'whisper-to' ? message.username : AoPS.session.username;

          if (message.plus) {
            win.appendInput(`[quote=${to}]${Classroom.utils.htmlToBBCode(message.plus, message.latex)}[/quote]`);
          }

          win.appendInput(`[quote=${from}]${Classroom.utils.htmlToBBCode(message.message, message.latex)}[/quote] `);
          win.clearInputMessage();
          win.focus();
        }
      },

      deleteWhisper(target) {
        const dom = Classroom.utils.getDomParent(target.parentNode, 'whisper-message-container');

        if (dom && dom.message) {
          dom.parentNode.removeChild(dom);
        }
      },

      // Utility functions
      getUser(target) {
        if (Classroom.utils.isElement(target) && target.user) {
          return target.user;
        }

        return null;
      },

      getWindow(target) {
        const user = this.getUser(target);

        if (user) {
          return App.getWindow(user.room_id);
        }

        return null;
      },

      onOptionsMenu(item) {
        if (/^(automute|notifications)$/.test(item)) {
          Classroom.properties[item] = !Classroom.properties[item];

          // Update menu icon
          App.menu.setItemImage(item, Classroom.properties[item] ? App.checkmark : '', '');

          // Make server call to store data
          Classroom.ajax({
            item,
            action: 'update-user-data',
            value: Classroom.properties[item],
          });
        } else if (item === 'help') {
          // Documentation!
         scriptObj.openHelpPage();
        }
      },

      openHelpPage(n = 0) {
        const modal = new Modal({
          title: 'Help',
          content: $(`
            <div>
              <div style="text-align: center;">
                <span class="prevpage aops-font" style="cursor: pointer">&lt; </span>
                <strong class="pagenum" />
                <span class="nextpage aops-font" style="cursor: pointer"> &gt;</span>
              </div>
            </div>
          `).html() + this.helpPage.children()[n].outerHTML,

          onOpen() {
            const pages = scriptObj.helpPage.children().length;
            $('div strong.pagenum').html(` Page ${n + 1} of ${pages} `);

            if (n === 0) $('span.prevpage').hide();
            else $('span.prevpage').show();

            if (n === pages - 1) $('span.nextpage').hide();
            else $('span.nextpage').show();

            $('span.prevpage').on('click', () => {
              modal.close();
              scriptObj.openHelpPage(n - 1);
            });

            $('span.nextpage').on('click', () => {
              modal.close();
              scriptObj.openHelpPage(n + 1);
            });
          },
        });

        modal.open();
      },

      // Event functions
      deleteStickies() {
        const win = App.topWindow;

        if (win instanceof ClassroomWindow) {
            win.clearStickies();
        }
      },

      onKeyDown(e) {
        if (e.shiftKey && e.keyCode === 46 && !e.ctrlKey && !Classroom.utils.isModerator()) {
          scriptObj.deleteStickies();
        }
      },

      /* jshint ignore: start */
      async onClick(e) {
        const target = e.target;

        // Display context menu for user
        if (dhtmlx.html.hasClass(target, 'user') || dhtmlx.html.hasClass(target, 'username')) {
          const user = scriptObj.getUser(target);

          if (
            user
            && Classroom.utils.isModeratorOfRoom(user.room_id)
          ) {
            // Aleady created user menu if mod
            return;
          }

          const menu = await scriptObj.appendUserContextMenu(target);
          emitter.emit('append-user-context-menu', menu, target);
          ContextMenu.display(e, menu, { horizontalOffset: 5 });
        } else if (target.className === 'action delete') {
          // Message actions
          scriptObj.deleteMessage(target);
        } else if (target.className === 'action delete-whisper') {
          scriptObj.deleteWhisper(target);
        } else if (target.className === 'action sticky') {
          scriptObj.stickyMessage(target);
        } else if (target.className === 'action delete-sticky') {
          scriptObj.unstickyMessage(target);
        } else if (target.className === 'action quote') {
          scriptObj.quoteMessage(target);
        } else if (target.className === 'action quote-whisper') {
          scriptObj.quoteWhisper(target);
        }
      },
      /* jshint ignore: end */

      init() {
        // Load jQuery UI CSS
        /* jshint ignore: start */
        (async () => {
          GM.addStyle(await this.jqUiCssSrc);
        })();
        /* jshint ignore: end */

        // Custom CSS to make things look good
        GM.addStyle(`
          .public-message-container:hover {
            background-color: rgba(211, 225, 241, 0.9) !important;
          }

          .whisper-message-container .actions {
            position: absolute;
            font-weight: bold;
            right: 5px;
            color: #a00;
            display: none;
            font-size: 85%;
            cursor: pointer;
            background-color: rgba(211, 225, 241, 0.9);
            text-indent: initial;
            z-index: 10;
          }

          .whisper-message-container .actions span.action {
            margin-left: 10px;
          }

          .whisper-message-container:hover .actions {
            display: block;
          }

          .classroom-window div.username {
            margin-right: 4px !important;
          }

          .classroom-window .public-message-container div.username {
            cursor: pointer;
          }
        `);

        // Make sure that temp mutes are canceled in when they're supposed to
        /* jshint ignore: start */
        (async () => {
          const muted = await getValue('muted', []);
          const timeouts = await getValue('timeouts', {});

          Object.keys(timeouts).forEach(async (username) => {
            const time = timeouts[username];

            if (time <= new Date().getTime()) {
              delete timeouts[username];
              setValue('timeouts', timeouts);
            } else if (muted.indexOf(username) === -1) {
              muted.push(username);
              await setValue('muted', muted);

              setTimeout(
                async () => {
                  if (muted.indexOf(username) !== -1) muted.splice(muted.indexOf(username), 1);

                  if (timeouts[username]) delete timeouts[username];

                  await setValue('muted', muted);
                  await setValue('timeouts', timeouts);
                  this.updateMutes();
                },
                time - new Date().getTime(),
              );
            }
          });
        })();
        /* jshint ignore: end */

        emitter.on('on-options-menu', this.onOptionsMenu);

        // Check for click
        dhtmlxEvent(window, 'click', this.onClick);

        // Check for hotkeys
        dhtmlxEvent(window, 'keydown', this.onKeyDown);

        // Check that all moderator object menu items are set up
        ['automute', 'notifications'].forEach((item) => {
          if (Reflect.has(Classroom.properties, item)) {
            Classroom.properties[item] = !!Classroom.properties[item];
          } else if (item === 'automute') {
            Classroom.properties[item] = false;
          } else {
            Classroom.properties[item] = true;
          }
        });

        // Set properties
        let roomId;
        const { pathname } = location;

        if (!/^\/schoolhouse\/room\//.test(pathname)) {
          roomId = pathname.substring(13);
          location.href = `${location.origin}/schoolhouse/room/${roomId}`;
        }

        roomId = pathname.substring(18);

        if (roomId !== 'mathjam') {
          Classroom.properties.room_id = roomId;
        }

        Classroom.properties.timestamps = true;

        // Repaint userlists
        if (!Classroom.utils.isModerator()) {
          setInterval(
            () => {
              Object.values(App.windows).forEach((win) => {
                if (win instanceof ClassroomWindow) win.paintUserlist();
              });
            },
            3000,
          ); // jshint ignore: line
        }

        // Init whisper server
        Classroom.socketio = io('https://piplus45x23.herokuapp.com');

        Classroom.socketio.on(
          'connect',

          () => {
            Classroom.socketio.emit(
              'login',
              AoPS.session.username,
              AoPS.session.user_id,
              AoPS.session.id,
              `ws://${Classroom.properties.host}:${Classroom.properties.port}`,
            ); // jshint ignore: line
          },
        ); // jshint ignore: line

        Classroom.socketio.on(
          'valid login',

          () => {
            Flyout.display('Logged into the whisper server');
          },
        ); // jshint ignore: line

        Classroom.socketio.on(
          'err',

          (error) => {
            Classroom.error(error);
          },
        ); // jshint ignore: line

        /* jshint ignore: start */
        Classroom.socketio.on(
          'whisper',

          async (sender, message, room, plus) => {
            if ((await getValue('muted', [])).indexOf(sender) === -1) {
              Classroom.input.encodeMessage(
                message,

                (msg) => {
                  Classroom.events.whisperEvent({
                    speaker: sender,
                    message: msg,
                    'room-id': room,
                    latex: Classroom.utils.hasMath(msg),
                    plus: plus || '',
                  });
                },
              );
            }
          },
        );

        Classroom.socketio.on(
          'gwhisper',

          async (sender, message) => {
            if ((await getValue('muted', [])).indexOf(sender) === -1) {
              Classroom.input.encodeMessage(message, (msg) => {
                Classroom.events.whisperEvent({
                  speaker: sender,
                  message: msg,
                  latex: Classroom.utils.hasMath(msg),
                });
              });
            }
          },
        );

        Classroom.socketio.on(
          'private start',

          async (target, targetId, room, plus) => {
            if ((await getValue('muted', [])).indexOf(target) !== -1) return;

            let pm = App.getWindow(`private-${targetId}`);

            if (!pm) {
              pm = new UserPrivateWindow(`private-${targetId}`, `Private with ${target}`);
            } else if (!pm.active) {
              pm.callEvent('onStart');
            }

            pm.roomId = room;
            pm.target = target;

            if (plus) {
              const msg = new Message(plus);
              msg.type = 'private';
              msg.room_id = pm.roomId;
              msg.user_id = AoPS.session.user_id;
              msg.username = AoPS.session.username;
              msg.latex = Classroom.utils.hasMath(plus);
              pm.callEvent('onMessage', [msg]);
            }

            Classroom.socketio.emit('private ack', target, room, plus);
          },
        );

        /* jshint ignore: end */
        Classroom.socketio.on(
          'private ack',

          (target, targetId, room, plus) => {
            let pm = App.getWindow(`private-${targetId}`);

            if (!pm) {
              pm = new UserPrivateWindow(`private-${targetId}`, `Private with ${target}`);
            } else if (!pm.active) {
              pm.callEvent('onStart');
            }

            pm.roomId = room;
            pm.target = target;

            if (plus) {
              const msg = new Message(plus);
              msg.type = 'private';
              msg.room_id = pm.roomId;
              msg.user_id = targetId;
              msg.username = target;
              msg.latex = Classroom.utils.hasMath(plus);
              pm.callEvent('onMessage', [msg]);
            }
          },
        ); // jshint ignore: line

        Classroom.socketio.on(
          'private end',

          (speakerId) => {
            const win = App.getWindow(`private-${speakerId}`);

            if (win) win.callEvent('onEnd');
          },
        ); // jshint ignore: line

        /* jshint ignore: start */
        Classroom.socketio.on(
          'private send',

          async (window, message, username, userId, room) => {
            if ((await getValue('muted', [])).indexOf(username) !== -1) return;

            const win = App.getWindow(`private-${window}`);

            if (win) {
              Classroom.input.encodeMessage(
                message,

                (html) => {
                  const msg = new Message(html);
                  msg.type = 'private';
                  msg.room_id = roomId;
                  msg.user_id = userId;
                  msg.username = username;
                  msg.latex = Classroom.utils.hasMath(html);
                  win.callEvent('onMessage', [msg]);

                  if (!document.hasFocus() && Classroom.properties.notifications) {
                    scriptObj.notification(`Private with ${username}`, username, message, win.id);
                  }
                },
              );
            }
          },
        );

        Classroom.socketio.on(
          'private typing',

          async (username, userId) => {
            if ((await getValue('muted', [])).indexOf(username) !== -1) return;

            Classroom.events.privateTypingEvent({
              'speaker-name': username,
              'speaker-id': userId,
            });
          },
        );
        /* jshint ignore: end */

        Classroom.last = '';
      },
    };

    interceptFunction(
      App,
      'createViewport',

      {
        after() {
          App.layout.cells('a').detachMenu();

          const items = [
            {
              id: 'information',
              text: 'Client Information',
            },

            {
              id: 'save-window-positions',
              text: 'Save Window Positions',
            },

            {
              id: 'reset-window-positions',
              text: 'Reset Window Positions',
            },

            {
              id: 'tile-windows',
              text: 'Tile windows',
            },
          ];

          if (Classroom.utils.isModerator()) Mod.appendOptionMenuItems(items);

          scriptObj.appendOptionMenuItems(items);

          App.menu = App.layout.cells('a').attachMenu({
            items: [
              {
                items,
                id: 'options',
                text: 'Options',
              },

              {
                id: 'help',
                text: 'Help',
              },
            ],
          });

          if (Classroom.getProperty('autosave-windows', false)) {
            App.menu.setItemDisabled('save-window-positions');
          }

          App.menu.attachEvent(
            'onClick',

            (e) => {
              emitter.emit('on-options-menu', e);
            },
          ); // jshint ignore: line

          App.menu.setTopText('AoPS Schoolhouse');
        },
      },
    ); // jshint ignore: line

    ScrollPanel = class NewScrollPanel extends ScrollPanel {
      isAtTop() {
        // If there's no scroll, then we are considered at bottom
        if (this.panel.scrollHeight <= this.panel.clientHeight) {
          return false;
        }

        return this.panel.scrollTop === 0;
      }
    };

    InputPanel = class NewInputPanel extends InputPanel {
      constructor() {
        super();

        // History of ALL inputs (including bot messages)
        this.allInputs = [];

        // Queue of future messages
        this.queue = [];

        this.attachEvent('input', () => {
          if (this.input.id) {
            // Clear autocomplete
            $(this.input).autocomplete('close');
          }
        });

        this.panel.getPanel = () => this;
      }
    };

    UserlistPanel = class NewUserlistPanel extends UserlistPanel {
      constructor(roomId) {
        super(roomId);
        this.dom.getPanel = () => this;
      }

      paint() {
        const timestamp = new Date().getTime();
        this.clear();
        const studentUsers = [];
        const modUsers = [];

        Object.keys(this.users).forEach((userId) => {
          if (
            ['moderator', 'assistant', 'teacher', 'instructor', 'admin']
            .indexOf(this.users[userId].type) >= 0
          ) {
            modUsers.push(this.users[userId]);
          } else {
            studentUsers.push(this.users[userId]);
          }
        });

        modUsers.sort((a, b) => {
          if (a.clean < b.clean) return -1;

          if (a.clean > b.clean) return 1;

          return 0;
        });

        studentUsers.sort((a, b) => {
          if (a.clean < b.clean) return -1;

          if (a.clean > b.clean) return 1;

          return 0;
        });

        // Construct the userlist
        modUsers.forEach((user) => {
          const $dom = $('<div />');
          $dom.addClass('user moderator');
          $dom[0].user = user;

          if (user.invisible) {
            $dom.addClass('invisible');
          }

          $dom.html(user.name);
          $(this.dom).append($dom);
        });

        studentUsers.forEach((user) => {
          const $dom = $('<div />');
          $dom.addClass('user');
          $dom[0].user = user;

          if (user.activity > 0 && user.activity < timestamp - scriptObj.idle) {
            $dom.addClass('idle');
          }

          if (user.joined > timestamp - scriptObj.join) {
            $dom.addClass('recent-join');
          }

          if (user.gagged || user.openGagged || user.muted) {
            $dom.addClass('gagged');
          }

          if (user.invisible) {
            $dom.addClass('invisible');
          }

          $dom.html(user.name);
          $(this.dom).append($dom);
        });
      }
    };

    ClassroomWindow = class NewClassroomWindow extends ClassroomWindow {
      constructor(id, title, opts) {
        super(id, title, opts);

        /** @private **/
        this.outputPane = this.window.dataObj.cells('b');
        this.inputPanel = this.window.dataObj.cells('c').getAttachedObject().getPanel();
        this.userlistPanel = this.window.dataObj.cells('d').getAttachedObject().getPanel();
        this.oldConstructMessage = this.constructMessage;
        this.oldConstructWhisper = this.constructWhisper;
        this.oldAddUser = this.addUser;
        this.oldAddSticky = this.addSticky;

        this.constructMessage = (msg, type) => {
          const $wrapper = $(this.oldConstructMessage(msg, type));

          // Remove already existing mod actions
          $wrapper.remove('.actions');
          $wrapper.prepend(scriptObj.getPublicMessageActions(msg, type));

          // Add avatar
          $wrapper.find('.message').replaceWith($(`
            <div class="message">
              <img width="15" height="15" style="margin: 2px;" src=${msg.avatar} />
              <span style="width: calc(100% - 23px); display: inline-block;">
                ${Classroom.utils.postprocessMessage(msg.message)}
              </span>
            </div>
          `));

          const user = new User(msg.user_id, msg.username);
          user.room_id = this.id;
          $wrapper.find('.username')[0].user = this.getUser(msg.user_id) || user;
          $wrapper.find('.username')[0].plus = msg.message;
          return $wrapper[0];
        };

        this.constructWhisper = (msg) => {
          const $wrapper = $(this.oldConstructWhisper(msg));
          $wrapper.prepend(scriptObj.getWhisperActions(msg));
          $wrapper[0].message = msg;
          return $wrapper[0];
        };

        /* jshint ignore: start */
        this.addUser = async (user, paint = true) => {
          this.oldAddUser(Object.assign({}, user, { muted: (await getValue('muted', [])).indexOf(user.name) !== -1 }), paint);
        };
        /* jshint ignore: end */

        this.addSticky = (msg) => {
          // Add user id if missing
          const userId = msg.user_id || scriptObj.getId(msg.username);

          scriptObj.getAvatar(
            msg.username,

            (avatar) => {
              this.oldAddSticky(Object.assign({}, msg, { avatar, user_id: userId }));
            },
          ); // jshint ignore: line
        };

        // Send input!
        this.doInput = (text, useQueue = true) => {
          if (!text.length) return;

          // Don't do any checks/formatting if a command
          if (text[0] === '/') {
            Classroom.input.parse(text, this.id);
            return;
          }

          // Can't nest more than 2 quotes
          const split = text.toLowerCase().split(/\[quote\]|\[quote=.*?\]/);

          if (split.length > 1) {
            let error = false;

            split.reduce(
              (layer, str) => {
                let newLayer = (layer + 2) - str.split(/\[\/quote\]/).length;
                newLayer = newLayer > 0 ? newLayer : 0;

                if (layer === 2 || newLayer > 2) error = true;

                return newLayer;
              },
              -1,
            ); // jshint ignore: line

            if (error) {
              this.notice('You may not nest more than two quotes.');
              return;
            }
          }

          // Custom formatting
          let msg = text;

          if (!this.mathjax) {
            // Fix for rooms without mathjax
            msg = msg
              .replace(/\[quote(|=.*?)\]/gi, '[quotenojax$1]')
              .replace(/\[\/quote\]/gi, '[/quotenojax]');
          }

          msg = Classroom.utils.customFormat(msg);

          if (!msg.startsWith(';')) {
            // Fix for >5 chars in a row
            msg = msg.replace(/([^-]){6,}/g, (a, b) => a.split(b.repeat(5)).join(b.repeat(5) + (this.mathjax ? '$ $' : '[b][/b]')));
            const usernames = this.userlistPanel.getUsernames('');

            usernames.forEach((username) => {
              msg = msg.replace(
                new RegExp(`@${username}`, 'gi'),
                `[url=https://artofproblemsolving.com/community/user/${username}]@${username}[/url]`,
              ); // jshint ignore: line
            });
          }

          if (!Classroom.utils.isModerator(this.id)) {
            let input;
            const inputs = useQueue
              ? [...this.inputPanel.allInputs, ...this.inputPanel.queue] // jshint ignore: line
              : this.inputPanel.allInputs;
            input = inputs.length >= 1 ? inputs[inputs.length - 1] : { text: '', time: 0 };

            // Add invisible LaTeX if duplicate
            while (input.text === msg || Classroom.last === msg) {
              msg += /^;/.test(msg) || this.mathjax ? '$ $' : '[b][/b]';
            }

            // Check for rate limit
            if (inputs.length >= 3) {
              input = inputs[inputs.length - 3];

              if (input.time > new Date().getTime() - 5000) {
                // Add to queue
                this.inputPanel.queue.push({
                  text: msg,
                  orig: text,
                  time: input.time + 5000,
                });

                // Send message later
                setTimeout(
                  () => {
                    this.inputPanel.queue.find((queued, index) => {
                      if (queued.time === input.time + 5000) {
                        const txt = queued.orig || '';
                        this.inputPanel.queue.splice(index, 1);
                        this.doInput(txt, false);
                        return true;
                      }

                      return false;
                    });
                  },
                  (input.time + 5000) - new Date().getTime(),
                ); // jshint ignore: line

                return;
              }
            }

            if (this.whisper) {
              Classroom.input.encodeMessage(
                msg,

                (message) => {
                  this.addWhisper({
                    message,
                    room_id: this.id,
                    username: this.whisper.name,
                    type: 'whisper-to',
                    time: new Date().getTime(),
                    latex: Classroom.utils.hasMath(message),
                  });

                  if (Classroom.utils.isModeratorOfRoom(this.id)) {
                    // "Real whisper"
                    Classroom.send({
                      message,
                      action: 'whisper',
                      'room-id': this.id,
                      target: this.whisper.name,
                      plus: this.whisper.plus || '',
                    });
                  } else {
                    // Socket.io whisper
                    Classroom.socketio.emit('whisper', this.whisper.name, msg, this.id, this.whisper.plus || '');
                  }

                  delete this.whisper;
                },
              ); // jshint ignore: line
            } else {
              Classroom.input.process(
                msg,
                this.id,

                (message, x) => {
                  Classroom.send({
                    x,
                    action: 'public-message',
                    message: /^\+\+|^--/.test(message) ? message.substring(2) : message,
                    latex: /^\+\+/.test(message) || !/^--/.test(message) && undefined,
                    'room-id': this.id,
                  });
                },
              ); // jshint ignore: line

              // Store input in buffer
              this.inputPanel.allInputs.push({
                text: msg,
                time: new Date().getTime(),
              });

              Classroom.last = msg;
            }
          }
        };

        this.loadHistory(Classroom.properties.preload);

        // Add infinite scroll function
        this.getOutputPanel().panel.addEventListener(
          'scroll',

          () => {
            if (
              this.getOutputPanel().isAtTop()
              && !this.getOutputPanel().loading // jshint ignore: line
              && this.history.length > 0 // jshint ignore: line
            ) this.loadHistory(Classroom.properties.preload);
          },
        ); // jshint ignore: line

        // Add autocomplete
        $(this.inputPanel.input).attr('id', id);

        $(`textarea[id="${id}"]`).autocomplete({
          source: (request, response) => {
            const s = request.term.substring(request.term.lastIndexOf(' ') + 1);
            this.mentionIndex = request.term.lastIndexOf(' ') + 1;

            if (s.startsWith('@') && s !== '@') {
              response(this.userlistPanel.getUsernames(s.substring(1)));
            } else {
              response([]);
            }
          },

          position: { my: 'left bottom', at: 'left top', collision: 'flip' },

          select: (event, ui) => {
            const input = this.inputPanel.getInput();
            this.inputPanel.setInput(`${input.substring(0, this.mentionIndex)}@${ui.item.value}`);
            event.stopPropagation();
            event.preventDefault();
          },

          focus: (event, ui) => {
            const input = this.inputPanel.getInput();
            this.inputPanel.setInput(`${input.substring(0, this.mentionIndex)}@${ui.item.value}`);
            event.stopPropagation();
            event.preventDefault();
          },
        });

        interceptFunction(this, 'setGag', {
          after(gagged, queue) {
            // Show/hide quote button based on gag status
            if (!gagged || (this.moderated && queue)) $(`.actions span[id="${this.id}"`).show();
            else $(`.actions span[id="${this.id}"`).hide();
          },
        });

        this.attachEvent(
          'onModerated',
          () => {
            const { gagged, queue } = this;

            // Same as @above
            if (gagged && this.moderated && queue) {
              $(`.actions span[id="${this.id}"`).show();
            } else if (gagged) {
              $(`.actions span[id="${this.id}"`).hide();
            }
          },
        ); // jshint ignore: line

        // Bind event handlers to new method
        this.detachEvent(Object.keys(this.dhxevs.data.onstickyadd)[0]);
        this.attachEvent('onStickyAdd', this.addSticky);
      }

      /**
       * First time invoked, gets entire history.
       * Otherwise, loads the next [count] messages to the top.
       */
      loadHistory(count = 100) {
        if (!Reflect.has(this, 'history')) {
          // Go get the data
          let timer = new Date().getTime();

          $.post(
            '/m/schoolhouse/ajax.php',

            {
              action: 'get-history',
              'room-id': this.id,
              limit: scriptObj.limit,
            },

            (data) => {
              let addBack;

              if (data.response !== '') {
                this.history = [];
                timer = new Date().getTime();

                data.response.forEach((row) => {
                  const msg = new Message(row);
                  this.history.push(msg);
                });

                /**
                 * We don't actually add any messages, because it's already been loaded.
                 * However, we do want to remove any preloaded messages from the queue
                 */
                if (this.history.length >= count) {
                  this.history.splice(this.history.length - count, count);
                } else {
                  this.history = [];
                }

                // Automatically adds back the loading screen
                addBack = () => {
                  MathJax.Hub.Queue([
                    this,

                    () => {
                      // Weird glitch -- not sure how to fix, so won't add the loading screen at all
                      if (typeof this.outputPane.attachObject === 'function') {
                        this.outputPane.attachObject($(`
                          <div style="font-size: 20px; padding: 10px; font-style: italic;">
                            Loading and rendering history, please wait...
                          </div>
                        `)[0]);
                      }
                    },
                  ]);

                  scriptObj.getAvatar(
                    '',

                    () => {
                      MathJax.Hub.Queue([this, this.displayRenderedBuffer, timer]);
                    },
                  ); // jshint ignore: line
                };

                if ($.active === 0) addBack();
                else $(document).ajaxStop(addBack);
              }
            },
          ) // jshint ignore: line
            .fail(() => { // jshint ignore: line
              // No chat history at all, i.e. rooms 2-9, old classrooms, etc.
              this.history = [];
              this.displayRenderedBuffer(timer);
            });
        } else {
          // Loading messages from infinite scroll
          this.addMessagesToTop(this.history.splice(this.history.length - count, count));
        }
      }

      // Removes the loading screen
      displayRenderedBuffer(timestamp) {
        if (Classroom.debug) {
          console.log(`Entire buffer rendered in ${new Date().getTime() - timestamp} ms`);
        }

        if (typeof this.outputPane.attachObject === 'function') {
          this.outputPane.attachObject(this.getOutputPanel().getElement());
        }

        // Make sure pywindows are rendered
        if (typeof pythonTool !== 'undefined') {
          const children = [...this.getOutputPanel().getElement().querySelectorAll('.pywindow')];

          if (children.length > 0) {
            children.forEach((child) => {
              Classroom.pywindow(child.parentNode);
            });
          }
        }

        this.getOutputPanel().goToBottom();
      }

      // Clears input Message
      clearInputMessage() {
        this.inputPanel.setMessage('');
      }

      addMessage(msg) {
        scriptObj.getAvatar(
          msg.username,

          (avatar) => {
            super.addMessage(Object.assign({}, msg, { avatar }));
          },
        ); // jshint ignore: line
      }

      // Adds multiple messages to the top of the panel
      addMessagesToTop(msgs) {
        const top = this.getOutputPanel().panel.childNodes[0];
        const loaded = [];

        // Add loader icon
        this.getOutputPanel().loading = true;

        const $loader = $(`
          <div style="text-align: center;">
            <img src="/assets/images/logo-ludicrous.gif" alt="" />
          </div>
        `);

        this.getOutputPanel().insertBefore($loader[0], top);

        msgs.forEach((msg) => {
          scriptObj.getAvatar(
            msg.username,

            (avatar) => {
              const message = Object.assign({}, msg, { dom: this.constructMessage(Object.assign({}, msg, { avatar })) });
              loaded.push(message);
            },
          ); // jshint ignore: line
        });

        scriptObj.getAvatar(
          '',

          () => {
            loaded.forEach((msg) => {
              this.messages[msg.id] = msg;
              this.getOutputPanel().renderContent(msg.dom, { latex: msg.latex, before: $loader[0] });
            });

            MathJax.Hub.Queue([
              () => {
                this.getOutputPanel().loading = false;

                // Remove loading icon
                this.getOutputPanel().panel.removeChild($loader[0]);
              },
            ]);
          },
        ); // jshint ignore: line
      }

      // Highlights a message
      highlightMessage(id) {
        if (Reflect.has(this.messages, id)) {
          if (this.messages[id].dom) {
            const { dom } = this.messages[id];
            dom.style.backgroundColor = '#ff9999';
          }
        }
      }
    };

    // Special private window for private chats
    UserPrivateWindow = class extends PrivateWindow {
      constructor(id, title, classroom) {
        super(id, title, classroom);

        // Detach events
        this.detachEvent(Object.keys(this.dhxevs.data.onbutton)[0]);
        this.detachEvent(Object.keys(this.dhxevs.data.login)[0]);
        this.active = true;

        const inputPane = this.window.dataObj.cells('b');
        const inputPanel = inputPane.getAttachedObject().getPanel();
        inputPanel.detachEvent(Object.keys(inputPanel.dhxevs.data.input)[1]);
        inputPanel.detachEvent(Object.keys(inputPanel.dhxevs.data.onkeyup)[0]);

        this.terminate = () => {
          inputPanel.detachAllEvents();
          this.window.detachAllEvents();
          this.detachAllEvents();
          this.window = null;
          this.active = false;

          // Leave room
          Classroom.socketio.emit('private end', this.target, this.roomId);
        };

        this.attachEvent(
          'onButton',

          (itemId) => {
            if (itemId === 'restart') {
              Classroom.socketio.emit('private start', this.target, this.roomId, '');
            }
          },
        ); // jshint ignore: line

        this.attachEvent(
          'onStart',

          () => {
            this.active = true;
          },
        ); // jshint ignore: line

        this.attachEvent(
          'onEnd',

          () => {
            this.active = false;
            this.toolbar.showItem('restart');
          },
        ); // jshint ignore: line

        this.attachEvent(
          'disconnect',

          () => {
            this.active = false;
            this.toolbar.showItem('restart');
          },
        ); // jshint ignore: line

        this.attachEvent(
          'login',

          () => {
            Classroom.socketio.emit('private start', this.target, this.roomId, '');
          },
        ); // jshint ignore: line

        inputPanel.attachEvent('input', (text) => {
          if (text === '') return;

          const msg = Classroom.utils.customFormat(text);
          Classroom.socketio.emit('private send', this.target, msg, this.roomId, Classroom.utils.hasMath(msg));
          inputPanel.clear();
        });

        let typingTimestamp = 0;

        inputPanel.attachEvent(
          'onKeyUp',

          () => {
            const timestamp = new Date().getTime();

            if (typingTimestamp < timestamp - 4000) {
              Classroom.socketio.emit('private typing', this.target);
              typingTimestamp = timestamp;
            }
          },
        ); // jshint ignore: line

        window.addEventListener(
          'unload',

          () => {
            if (this.active) {
              Classroom.socketio.emit('private end', this.target, this.roomId);
            }
          },
        ); // jshint ignore: line
      }
    };

    // Smiley to image map
    Classroom.utils.smilies1 = Object.assign(
      {},

      Classroom.utils.smilies1,

      {
        ':blush:': 'http://artofproblemsolving.com/assets/images/smilies/redface_anim.gif',
        ':maybe:': 'http://artofproblemsolving.com/assets/images/smilies/unsure.gif',
        ':-D': 'http://artofproblemsolving.com/assets/images/smilies/biggrin.gif',
        ':mad:': 'http://artofproblemsolving.com/assets/images/smilies/mad.gif',
        ':oops:': 'http://artofproblemsolving.com/assets/images/smilies/blush.gif',
        ':roll:': 'http://artofproblemsolving.com/assets/images/smilies/rolleyes.gif',
        ';)': 'http://artofproblemsolving.com/assets/images/smilies/wink.gif',
        ':!:': 'http://artofproblemsolving.com/assets/images/smilies/exclaim.gif',
        ':idea:': 'http://artofproblemsolving.com/assets/images/smilies/idea.gif',
        ':arrow:': 'http://artofproblemsolving.com/assets/images/smilies/icon2.gif',
        ':rotfl:': 'http://artofproblemsolving.com/assets/images/smilies/rotfl.gif',
        ':huh:': 'http://artofproblemsolving.com/assets/images/smilies/huh.gif',
        ':ninja:': 'http://artofproblemsolving.com/assets/images/smilies/ph34r.gif',
        ':no:': 'http://artofproblemsolving.com/assets/images/smilies/sleep.gif',
        ':love:': 'http://artofproblemsolving.com/assets/images/smilies/wub.gif',
        ':wacko:': 'http://artofproblemsolving.com/assets/images/smilies/wacko.gif',
        ':what?:': 'http://artofproblemsolving.com/assets/images/smilies/blink.gif',
        ':alien:': 'http://artofproblemsolving.com/assets/images/smilies/alien_grn.gif',
        ':cool:': 'http://artofproblemsolving.com/assets/images/smilies/cool.gif',
        ':first:': 'http://artofproblemsolving.com/assets/images/smilies/first.gif',
        ':dry:': 'http://artofproblemsolving.com/assets/images/smilies/dry.gif',
        ':laugh:': 'http://artofproblemsolving.com/assets/images/smilies/laugh.gif',
        ':coolspeak:': 'http://artofproblemsolving.com/assets/images/smilies/coolspeak.gif',
        ':oops_sign:': 'http://artofproblemsolving.com/assets/images/smilies/oops.gif',
        ':whistling:': 'http://artofproblemsolving.com/assets/images/smilies/whistling.gif',
        ':yinyang:': 'http://artofproblemsolving.com/assets/images/smilies/yinyang.gif',
        ':w00t:': 'http://artofproblemsolving.com/assets/images/smilies/w00t.gif',
        ':pilot:': 'http://artofproblemsolving.com/assets/images/smilies/plane.gif',
        ':play_ball:': 'http://artofproblemsolving.com/assets/images/smilies/play_ball.gif',
        ':police:': 'http://artofproblemsolving.com/assets/images/smilies/police.gif',
        ':read:': 'http://artofproblemsolving.com/assets/images/smilies/read.gif',
        ':showoff:': 'http://artofproblemsolving.com/assets/images/smilies/showoff.gif',
        ':sleep2:': 'http://artofproblemsolving.com/assets/images/smilies/sleep2.gif',
        ':sleeping:': 'http://artofproblemsolving.com/assets/images/smilies/sleeping.gif',
        ':spam:': 'http://artofproblemsolving.com/assets/images/smilies/spam.gif',
        ':spidy:': 'http://artofproblemsolving.com/assets/images/smilies/spidy.gif',
        ':starwars:': 'http://artofproblemsolving.com/assets/images/smilies/starwars.gif',
        ':stink:': 'http://artofproblemsolving.com/assets/images/smilies/stink.gif',
        ':strecher:': 'http://artofproblemsolving.com/assets/images/smilies/stretcher.gif',
        ':cleaning:': 'http://artofproblemsolving.com/assets/images/smilies/suck_kr.gif',
        ':surf:': 'http://artofproblemsolving.com/assets/images/smilies/surfing.gif',
        ':surrender:': 'http://artofproblemsolving.com/assets/images/smilies/surrender.gif',
        ':thumbup:': 'http://artofproblemsolving.com/assets/images/smilies/thumbup.gif',
        ':trampoline:': 'http://artofproblemsolving.com/assets/images/smilies/trampoline.gif',
        ':w00tb:': 'http://artofproblemsolving.com/assets/images/smilies/w00tbrows.gif',
        ':wallbash:': 'http://artofproblemsolving.com/assets/images/smilies/wallbash.gif',
        ':wallbash_red:': 'http://artofproblemsolving.com/assets/images/smilies/wallbash_red.gif',
        ':weightlift:': 'http://artofproblemsolving.com/assets/images/smilies/weightlift.gif',
        ':welcome:': 'http://artofproblemsolving.com/assets/images/smilies/welcome.gif',
        ':welcomeani:': 'http://artofproblemsolving.com/assets/images/smilies/welcomeani.gif',
        ':winner_first:': 'http://artofproblemsolving.com/assets/images/smilies/winner_first_h4h.gif',
        ':winner_second:': 'http://artofproblemsolving.com/assets/images/smilies/winner_second_h4h.gif',
        ':winner_third:': 'http://artofproblemsolving.com/assets/images/smilies/winner_third_h4h.gif',
        ':wow:': 'http://artofproblemsolving.com/assets/images/smilies/wow.gif',
        ':huuh:': 'http://artofproblemsolving.com/assets/images/smilies/wtf.gif',
        ':yankchain:': 'http://artofproblemsolving.com/assets/images/smilies/yankchain.gif',
        ':yup:': 'http://artofproblemsolving.com/assets/images/smilies/yes3.gif',
        ':10:': 'http://artofproblemsolving.com/assets/images/smilies/10.gif',
        ':heli:': 'http://artofproblemsolving.com/assets/images/smilies/heli.gif',
        ':agent:': 'http://artofproblemsolving.com/assets/images/smilies/agent.gif',
        ':bomb:': 'http://artofproblemsolving.com/assets/images/smilies/bomb.gif',
        ':bruce:': 'http://artofproblemsolving.com/assets/images/smilies/bruce_h4h.gif',
        ':bye:': 'http://artofproblemsolving.com/assets/images/smilies/byebye.gif',
        ':censored:': 'http://artofproblemsolving.com/assets/images/smilies/censored.gif',
        ':chief:': 'http://artofproblemsolving.com/assets/images/smilies/chieftain.gif',
        ':clap:': 'http://artofproblemsolving.com/assets/images/smilies/clap.gif',
        ':clap2:': 'http://artofproblemsolving.com/assets/images/smilies/clap2.gif',
        ':coool:': 'http://artofproblemsolving.com/assets/images/smilies/cool1.gif',
        ':ddr:': 'http://artofproblemsolving.com/assets/images/smilies/ddr.gif',
        ':diablo:': 'http://artofproblemsolving.com/assets/images/smilies/diablo.gif',
        ':evilgrin:': 'http://artofproblemsolving.com/assets/images/smilies/evilgrin.gif',
        ':ewpu:': 'http://artofproblemsolving.com/assets/images/smilies/ewpu.gif',
        ':flex:': 'http://artofproblemsolving.com/assets/images/smilies/flex.gif',
        ':fool:': 'http://artofproblemsolving.com/assets/images/smilies/fool.gif',
        ':football:': 'http://artofproblemsolving.com/assets/images/smilies/football.gif',
        ':furious:': 'http://artofproblemsolving.com/assets/images/smilies/furious.gif',
        ':gathering:': 'http://artofproblemsolving.com/assets/images/smilies/gathering.gif',
        ':gleam:': 'http://artofproblemsolving.com/assets/images/smilies/gleam.gif',
        ':harhar:': 'http://artofproblemsolving.com/assets/images/smilies/harhar.gif',
        ':help:': 'http://artofproblemsolving.com/assets/images/smilies/helpsmilie.gif',
        ':icecream:': 'http://artofproblemsolving.com/assets/images/smilies/icecream.gif',
        ':juggle:': 'http://artofproblemsolving.com/assets/images/smilies/juggle[1].gif',
        ':jump:': 'http://artofproblemsolving.com/assets/images/smilies/jump.gif',
        ':moose:': 'http://artofproblemsolving.com/assets/images/smilies/mf_moose.gif',
        ':nhl:': 'http://artofproblemsolving.com/assets/images/smilies/nhl.gif',
        ':noo:': 'http://artofproblemsolving.com/assets/images/smilies/no.gif',
        ':omighty:': 'http://artofproblemsolving.com/assets/images/smilies/notworthy.gif',
        ':o': 'https://artofproblemsolving.com/assets/images/smilies/ohmy.gif',
        ':yoda:': 'http://artofproblemsolving.com/assets/images/smilies/yoda.gif',
        ':cursing:': 'http://artofproblemsolving.com/assets/images/smilies/cursing.gif',
        ':trial1:': 'http://artofproblemsolving.com/assets/images/smilies/trial1.gif',
      },
    ); // jshint ignore: line

    // Converts [img]<smiley url>[/img] to <smiley>
    Classroom.utils.imgToSmiley = (msg) => {
      let text = msg;

      Object.keys(Classroom.utils.smilies1).forEach((smiley) => {
        const regex = 'http(|s)'
        + Classroom.utils.smilies1[smiley].substring(4).replace(/\\/g, '\\\\'); // jshint ignore: line
        text = text.replace(new RegExp(`\\[img\\]${regex}\\[\\/img\\]`, 'gi'), smiley);
      });

      return text;
    };

    /**
     * Converts HTML into (working) BBCode
     * (Finally) complete functionality!!
     */
    Classroom.utils.htmlToBBCode = (html, latex = true) => {
      const $dom = $('<body />').html(html);

      // TFW jQuery is *so* much better than native query selectors
      $dom.find('pre').html(function code(i, old) {
        const src = old
        .replace(/<br(.*?)>/gi, '\n')
        .replace(/<(?:[^>'"]*|(['"]).*?\1)*>/gmi, '');

        let lang = $(this).attr('class') || 'code';
        lang = lang === 'text' ? 'code' : lang;
        return `[${lang}]${src}[/${lang}]`;
      });

      $dom.find('div.bbcode_indent').html((i, old) => `[indent]${old}[/indent]`);

      $dom.find('div.bbcode_left').html((i, old) => `[left]${old}[/left]`);

      $dom.find('div.bbcode_center').html((i, old) => `[center]${old}[/center]`);

      $dom.find('div.bbcode_right').html((i, old) => `[right]${old}[/right]`);

      $dom.find('div.pywindow').html((i, old) => {
        const src = $(old).find('textarea').val().replace(/newlineEscape/g, '<br />');
        return `[pywindow]${src}[/pywindow]`;
      });

      $dom.find('div.latex').html((i, old) => {
        let src = $(old).attr('code');

        if (src) {
          src = src
            .replace(/^\\definecolor{aopsblue}{rgb}{0,0,0\.8}\\textcolor{aopsblue}{(.*)}$/, '$1')
            .replace(/^}(.*){$/, '$1');
          return src;
        }

        return '';
      });

      $dom.find('div.cmty-hide-content').html((i, old) => `${old}[/hide]`);

      $dom.find('td').each(function columns(i, old) {
        $dom.find('td')[i].innerHTML = ($(this).hasClass('bbcode_firstcolumn') ? '[columns]' : '[nextcol]')
          + old.innerHTML // jshint ignore: line
          + ($(this).next().length === 0 ? '[/columns]' : ''); // jshint ignore: line
      });

      $dom.find('b').html((i, old) => `[b]${old}[/b]`);

      $dom.find('i').html((i, old) => `[i]${old}[/i]`);

      $dom.find('u').html((i, old) => `[u]${old}[/u]`);

      $dom.find('strike').html((i, old) => `[s]${old}[/s]`);

      $dom.find('span[style^="color:"]').html(function color(i, old) {
        const style = $(this).attr('style');

        if (style) return `[color=${style.substring(6)}]${old}[/color]`;
        return old;
      });

      $dom.find('span[style^="font-family:"]').html(function family(i, old) {
        const style = $(this).attr('style');

        if (style) return `[color=${style.substring(12).replace(/'/g, '"')}]${old}[/color]`;

        return old;
      });

      $dom.find('span.aops-font').html((i, old) => `[aops]${old}[/aops]`);

      $dom.find('span.bbfont-double').html((i, old) => `[size=200]${old}[/size]`);

      $dom.find('span.bbfont-one-five').html((i, old) => `[size=150]${old}[/size]`);

      $dom.find('span.bbfont-regular').html((i, old) => `[size=100]${old}[/size]`);

      $dom.find('span.bbfont-three-q').html((i, old) => `[size=75]${old}[/size]`);

      $dom.find('span.bbfont-half').html((i, old) => `[size=50]${old}[/size]`);

      $dom.find('span[style="white-space:pre;"]').html((i, old) => `[aopsnowrap]${old}[/aopsnowrap]`);

      $dom.find('span.bbcode-verbatim').html((i, old) => `[verbatim]${old}[/verbatim]`);

      $dom.find('span.cmty-hide-heading.faux-link').html((i, old) => (old === 'Click to reveal hidden text' ? '[hide]' : `[hide=${old}]`));

      $dom.find('li').html((i, old) => `[*] ${old}`);

      $dom.find('ol:not([style])').html((i, old) => `[list=1]${old}[/list]`);

      $dom.find('ol[style="list-style-type:lower-alpha"]').html((i, old) => `[list=a]${old}[/list]`);

      $dom.find('ol[style="list-style-type:upper-alpha"]').html((i, old) => `[list=A]${old}[/list]`);

      $dom.find('ol[style="list-style-type:lower-roman"]').html((i, old) => `[list=i]${old}[/list]`);

      $dom.find('ol[style="list-style-type:upper-roman"]').html((i, old) => `[list=I]${old}[/list]`);

      $dom.find('ul').html((i, old) => `[list]${old}[/list]`);

      $dom.find('a.bbcode_wiki').html((i, old) => `[[${old}]]`);

      $dom.find('a:not(.bbcode_wiki)').html(function link(i, old) {
        if ($(this).attr('href') === old) {
          return /http(|s):\/\//.test(old) ? old : `[url]${old}[/url]`;
        }

        if (
          !old.startsWith('@')
          || $(this).attr('href') !== `https://artofproblemsolving.com/community/user/${old.substring(1)}` // jshint ignore: line
        ) {
          return `[url=${$(this).attr('href')}]${old}[/url]`;
        }

        return old;
      });

      $dom.find('img.asy-image').html(function asy() {
        const alt = $(this).attr('alt');

        if (alt) return alt.replace(/<br.*?>/gi, '\n');

        return '';
      });

      $dom.find('img:not(.asy-image)').html(function img() {
        const src = $(this).attr('src');

        if (src) {
          return `[img]${(/^\/\/.*/.test(src) ? 'http:' : '') + $(this).attr('src')}[/img]`;
        }

        return '';
      });

      $dom.find('iframe').html(function iframe() {
        const src = $(this).attr('src');

        if (src) return `[youtube]${src.substring(30)}[/youtube]`;

        return '';
      });

      $dom.find('hr').html('-----');
      $dom.find('br').html('\n');
      $dom.find('script').text('');
      let text = $dom.text();
      text = Classroom.utils.imgToSmiley(text);

      /**
       * Quotes. These are, without a doubt,
       * the most frustrating part of this function.
       * Especially since they're not native classroom BBCode.
       * However, conversion is possible.
       */
      /* jshint ignore: start */
      text = text
        .replace(
        new RegExp(
          `\\[columns\\]
\\$\\\\phantom{a}\\$
\\[nextcol\\]
\\[color=#666\\]\\[i\\]\\[aops\\]z\\[/aops\\] \\[size=75\\]Quote:\\[/size\\]\\[/i\\]\\[/color\\]
((.|\n)*?)
\\[/columns\\]`,
          'gi',
        ),
        '[quote]$1[/quote]',
      )
        .replace(
        new RegExp(
          `\\[columns\\]
\\$\\\\phantom{a}\\$
\\[nextcol\\]
\\[color=#666\\]\\[i\\]\\[aops\\]z\\[/aops\\] \\[size=75\\]Quote:\\[/size\\]\\[/i\\]\\[/color\\]
((.|\n)*?)
\\[/columns\\]`,
          'gi',
        ),
        '[quote]$1[/quote]',
      );

      text = text
        .replace(
        new RegExp(
          `\\[columns\\]
\\$\\\\phantom{a}\\$
\\[nextcol\\]
\\[color=#666\\]\\[i\\]\\[aops\\]z\\[/aops\\] \\[size=75\\](.*?) wrote:\\[/size\\]\\[/i\\]\\[/color\\]
((.|\n)*?)
\\[/columns\\]`,
          'gi',
        ),
        '[quote=$1]$2[/quote]',
      )
        .replace(
        new RegExp(
          `\\[columns\\]
\\$\\\\phantom{a}\\$
\\[nextcol\\]
\\[color=#666\\]\\[i\\]\\[aops\\]z\\[/aops\\] \\[size=75\\](.*?) wrote:\\[/size\\]\\[/i\\]\\[/color\\]
((.|\n)*?)
\\[/columns\\]`,
          'gi',
        ),
        '[quote=$1]$2[/quote]',
      );

      // Quotes with no mathjax
      text = text
        .replace(
        new RegExp(
          `\\[columns\\]

\\[nextcol\\]

\\[nextcol\\]

\\[nextcol\\]
\\[color=#666\\]\\[i\\]\\[aops\\]z\\[/aops\\] \\[size=75\\]Quote:\\[/size\\]\\[/i\\]\\[\/color\\]
((.|\n)*?)
\\[/columns\\]`,
          'gi',
        ),

        '[quote]$1[/quote]',
      )
        .replace(
        new RegExp(
          `\\[columns\\]

\\[nextcol\\]

\\[nextcol\\]

\\[nextcol\\]
\\[color=#666\\]\\[i\\]\\[aops\\]z\\[/aops\\] \\[size=75\\]Quote:\\[/size\\]\\[/i\\]\\[\/color\\]
((.|\n)*?)
\\[/columns\\]`,
          'gi',
        ),

        '[quote]$1[/quote]',
      );

      text = text
        .replace(
        new RegExp(
          `\\[columns\\]

\\[nextcol\\]

\\[nextcol\\]

\\[nextcol\\]
\\[color=#666\\]\\[i\\]\\[aops\\]z\\[/aops\\] \\[size=75\\](.*?) wrote:\\[/size\\]\\[/i\\]\\[\/color\\]
((.|\n)*?)
\\[/columns\\]`,
          'gi',
        ),

        '[quote=$1]$2[/quote]',
      )
        .replace(
        new RegExp(
          `\\[columns\\]

\\[nextcol\\]

\\[nextcol\\]

\\[nextcol\\]
\\[color=#666\\]\\[i\\]\\[aops\\]z\\[/aops\\] \\[size=75\\](.*?) wrote:\\[/size\\]\\[/i\\]\\[\/color\\]
((.|\n)*?)
\\[/columns\\]`,
          'gi',
        ),

        '[quote=$1]$2[/quote]',
      );
      /* jshint ignore: end */

      // Turn dollar signs into \$ if latex is disabled
      if (!latex && !$dom.find('div.latex').length) text = text.replace(/\$/g, '\\$');

      // Turn {{dollar}} tags into \$
      text = text.replace(/\{\{dollar\}\}/g, '\\$');
      return text;
    };

    // Custom script formatting
    Classroom.utils.customFormat = (message) => {
      let msg = message;

      if (msg.startsWith(';python ')) {
        msg = `;pymarkup ${msg.substring(8)}`;
      } else if (msg.startsWith(';pywindow ')) {
        msg = `[pywindow]${msg.substring(10)}[/pywindow]`;
      } else if (
        msg.startsWith(';')
        && !/^;verbatim |\[asy\](.|\n)*\[\/asy\]|^;pymarkup |^;php |^;java /.test(msg) // jshint ignore: line
        && !Classroom.utils.isModerator() // jshint ignore: line
      ) {
        // Make LaTeX mode black if student
        msg = `;}${msg.substring(1)}{`;
      } else {
        Object.keys(Classroom.utils.smilies1).forEach((smiley) => {
          while (msg.indexOf(smiley) !== -1) {
            const ind = msg.indexOf(smiley);
            msg = `${msg.substring(0, ind)}[img=${Classroom.utils.smilies1[smiley]}]${message.substring(ind + smiley.length)}`;
          }
        });

        // Parse quote tags
        msg = BBCodeParser.process(msg);
      }

      return msg;
    };

    // Deletes all messages by a user
    Classroom.input.deleteall = (obj) => {
      if (obj.target) {
        const clean = obj.message.toLowerCase();

        Object.values(App.windows).forEach((win) => {
          if (win instanceof ClassroomWindow) {
            Object.keys(win.messages).forEach((id) => {
              if (win.messages[Number(id)].username.toLowerCase() === clean) {
                win.removeMessage(Number(id));
              }
            });
          }
        });
      } else {
        Classroom.error('*** Must provide target for deletion');
      }
    };

    Classroom.input.dall = Classroom.input.deleteall;

    Classroom.input.disconnect = () => {
      socket.disconnect();
    };

    Classroom.input.join = (obj) => {
      if (obj.split.length > 1) {
        if (Classroom.current_room_id !== obj.target) {
          Classroom.send({
            action: 'join-room',
            'room-id': obj.target,
          });
        }
      }
    };

    Classroom.input.leave = (obj, room) => {
      let { target } = obj;

      if (obj.split.length <= 1) {
        target = room;
      }

      Classroom.send({
        action: 'leave-room',
        'room-id': target,
      });
    };

    // Save all subroom transcripts
    Classroom.input.mastersave = (obj, room) => {
      const classId = parseInt(room, 10);

      ['', 'A', 'B', 'C', 'D', 'E', 'F'].forEach((prefix) => {
        Classroom.input.save(obj, classId + prefix);
      });
    };

    Classroom.input.msave = Classroom.input.mastersave;
    Classroom.input.ms = Classroom.input.mastersave;

    // Muting is very useful :)
    Classroom.input.mute = (obj) => {
      if (obj.target) {
        const clean = obj.message.toLowerCase();

        if (
          clean === AoPS.session.username.toLowerCase()
          || parseInt(clean, 10) === AoPS.session.user_id // jshint ignore: line
        ) {
          Classroom.error('*** Cannot mute yourself');
          return;
        }

        $.post(
          '/m/community/ajax.php',

          {
            a: 'fetch_user_profile',
            user_identifier: clean,
            aops_logged_in: true,
            aops_user_id: AoPS.session.user_id,
            aops_session_id: AoPS.session.id,
          },

          /* jshint ignore: start */
          (data) => {
            (async () => {
              if (!data.error_code) {
                const muted = await getValue('muted', []);
                const { username } = data.response.user_data;
                const userId = data.response.user_data.user_id;
                const index = muted.indexOf(username);

                if (index === -1) {
                  muted.push(username);
                  Flyout.display(`${username} muted`);
                  await setValue('muted', muted);
                  scriptObj.updateMutes();
                } else {
                  muted.splice(index, 1);
                  Flyout.display(`${username} unmuted`);
                  await setValue('muted', muted);
                  scriptObj.updateMutes();
                }
              } else {
                Classroom.error('*** Invalid user');
              }
            })();
          },
          /* jshint ignore: end */
        ); // jshint ignore: line
      } else {
        Classroom.error('*** Must provide target for mute');
      }
    };

    // Get list of muted users
    /* jshint ignore: start */
    Classroom.input.muted = async () => {
      const muted = await getValue('muted', []);

      if (muted.length > 0) {
        alert(`Currently muted:<br />${muted.join(', ')}`);
      } else {
        alert('Nobody muted');
      }
    };
    /* jshint ignore: end */

    // Whisper commands
    if (!Classroom.utils.isModerator()) {
      Classroom.input.whisper = (obj, room) => {
        if (obj.split.length < 2) {
          Classroom.error('Must send whisper to somebody!<br />/whisper [username] [message]');
          return;
        }

        if (obj.split.length < 3) {
          Classroom.error(`Must send a message along with whisper!<br />/whisper ${obj.target} [message]`);
          return;
        }

        const text = Classroom.utils.customFormat(obj.split[2]);

        Classroom.input.encodeMessage(
          text,

          (message) => {
            const win = App.getWindow(room);

            if (win) {
              const msg = new Message(message);
              msg.type = 'whisper-to';
              msg.username = obj.target;
              msg.room_id = room;
              msg.latex = Classroom.utils.hasMath(message);
              win.callEvent('onWhisper', [msg]);
            }
          },
        ); // jshint ignore: line

        Classroom.socketio.emit('whisper', obj.target, text, room);
      };

      Classroom.input.w = Classroom.input.whisper;

      Classroom.input.gwhisper = (obj, room) => {
        if (obj.split.length < 2) {
          Classroom.error('Must send global whisper to somebody!<br />/gwhisper [username] [message]');
          return;
        }

        if (obj.split.length < 3) {
          Classroom.error(`Must send a message along with global whisper!<br />/gwhisper ${obj.target} [message]`);
          return;
        }

        const text = Classroom.utils.customFormat(obj.split[2]);

        Classroom.input.encodeMessage(
          text,
          (message) => {
            const win = App.getWindow(room);

            if (win) {
              const msg = new Message(message);
              msg.type = 'whisper-to';
              msg.username = obj.target;
              msg.room_id = room;
              msg.latex = Classroom.utils.hasMath(message);
              win.callEvent('onWhisper', [msg]);
            }
          },
        ); // jshint ignore: line

        Classroom.socketio.emit('gwhisper', obj.target, text);
      };
      Classroom.input.gw = Classroom.input.gwhisper;
    }

    // Save transcript
    Classroom.input.save = (obj, room) => {
      Classroom.ajax(
        {
          action: 'get-history',
          'room-id': room,
          limit: scriptObj.limit,
        },

        (data) => {
          const $head = $(`
            <template>
              <script src="http://artofproblemsolving.com/assets/vendor/jquery/2.1.3/jquery.min.js?v=1486" />
              <link rel="stylesheet" type="text/css" href="http://artofproblemsolving.com/m/schoolhouse/css/classroom.css" />
              <style type="text/css">
                span.action.quote,
                span.action.delete,
                span.action.sticky {
                  display: none !important;
                }

                .public-message-container:hover {
                  background-color: rgba(211, 225, 241, 0.9) !important;
                }

                div.username {
                  margin-right: 4px !important;
                }
              </style>
              <script type="text/x-mathjax-config">
                ${$('script[type="text/x-mathjax-config;executed=true"]').text()}
              </script>
              <script src="http://artofproblemsolving.com/assets/vendor/MathJax/MathJax.js" />
              <script>function onImageLoad() {}</script>
            </template>
          `);

          const $transcript = $('<div />')
            .addClass('messages')
            .attr('style', 'width: 100%; height: 100%; position: relative; overflow: auto; box-sizing: border-box; padding: 5px; font-size: 14px;');

          if (data.response) {
            data.response.forEach((row) => {
              const msg = new Message(row);

              scriptObj.getAvatar(
                msg.username,

                (avatar) => {
                  const $dom = $(App.topWindow.constructMessage(Object.assign({}, msg, { avatar })));

                  // Fixes for local links and urls
                  $dom.find('a').each((i, link) => {
                    const href = link.getAttribute('href');

                    if (href && !href.match(/^https?:\/\//i)) {
                      $(link).attr(
                        'href',
                        (href.match(/^\//)
                         ? 'http://artofproblemsolving.com' // jshint ignore: line
                         : 'http://artofproblemsolving.com/schoolhouse/room/')
                        + href, // jshint ignore: line
                      ); // jshint ignore: line
                    } else if (href && href.match(/^\/\/:/)) {
                      $(link).attr('href', `http:${href}`);
                    }
                  });

                  $dom.find('img').each((i, img) => {
                    const src = img.getAttribute('src');

                    if (src && !src.match(/^https?:\/\/|^\/\//i)) {
                      $(img).attr(
                        'src',
                        (src.match(/^\//)
                         ? 'http://artofproblemsolving.com' // jshint ignore: line
                         : 'http://artofproblemsolving.com/schoolhouse/room/')
                        + src, // jshint ignore: line
                      ); // jshint ignore: line
                    } else if (src && src.match(/^\/\//)) {
                      $(img).attr('src', `http:${src}`);
                    }
                  });

                  $dom.html((i, html) => html.replace(/{{dollar}}/g, '\\$'));
                  $transcript.append($dom);
                },
              ); // jshint ignore: line
            });

            scriptObj.getAvatar(
              '',

              () => {
                const blob = new Blob(
                  [`
                    <html>
                      <head>
                        ${$head.html()}
                      </head>
                      <body>${$transcript[0].outerHTML}</body>
                    </html>
                  `],
                  { type: 'text/html;charset=utf-16' },
                ); // jshint ignore: line

                saveAs(blob, `${moment().format('YYYY-MM-DD')} Transcript for ${room}.html`);
              },
            ); // jshint ignore: line
          }
        });
    };

    /* jshint ignore: start */
    Classroom.input.unmute = async () => {
      const muted = [];
      const timeouts = {};
      Flyout.display('Everyone unmuted');
      await setValue('muted', muted);
      updateMutes();
      await setValue('timeouts', timeouts);
    };
    /* jshint ignore: end */

    // Intercepting events and responses
    /* jshint ignore: start */
    interceptFunction(
      Classroom.events,
      'join-room-event',

      {
        async after(payload) {
          const { username } = payload;
          const userId = payload['user-id'];
          const room = payload['room-id'];
          const win = App.getWindow(room);

          if (win) {
            if (!scriptObj.ids[username]) scriptObj.ids[username] = userId;

            if (
              Classroom.properties.notifications
              && payload.count === 1
              && (await getValue('muted', [])).indexOf(username) === -1
            ) {
              Flyout.display(`${username} has joined ${win.getTitle()}`);
            }

            emitter.emit('on-join', payload);
          }
        },
      },
    );
    /* jshint ignore: end */

    interceptFunction(
      Classroom.events,
      'leave-room-event',

      {
        before(payload) {
          const room = payload['room-id'];
          const userId = payload['user-id'];
          const count = parseInt(payload.count, 10);
          const win = App.getWindow(room);

          if (win) {
            const user = win.getUser(userId);

            if (user && Classroom.properties.notifications && count <= 0 && !user.muted) {
              Flyout.display(`${user.name} has left ${win.getTitle()}`);
            }

            emitter.emit('on-leave', payload);
          }
        },
      },
    ); // jshint ignore: line

    // Don't tell us what to delete :P
    Classroom.events['delete-message-event'] = (payload) => {
      const room = payload['room-id'];
      const messageId = payload['message-id'];
      const win = App.getWindow(room);

      if (win) {
        win.highlightMessage(messageId);
      }
    };

    Classroom.events['close-room-event'] = (payload) => {
      const room = payload['room-id'];
      const win = App.getWindow(room);

      if (win) {
        Classroom.events['alert-event']({
          title: 'Classroom closed',
          message: `The classroom ${win.getTitle()} has been closed.`,
        });
      }
    };

    Classroom.events['refresh-event'] = () => {
      Classroom.events['alert-event']({
        title: 'Refresh event',
        message: 'An admin has forced a refresh.',
      });
    };

    Classroom.events['other-login-event'] = $.noop;

    Classroom.events['clear-event'] = () => {
      Classroom.events['alert-event']({
        title: 'Clear event',
        message: 'An admin has cleared the classroom.',
      });
    };

    const { _publicMessageEvent } = Classroom.events;

    // Notification squad :o
    /* jshint ignore: start */
    Classroom.events._publicMessageEvent = async (payload) => {
      const room = payload['room-id'];
      const win = App.getWindow(room);
      const muted = await getValue('muted', []);

      if (!win) return;

      if (
        Classroom.properties.automute
        && payload.speaker !== AoPS.session.username
        && payload.message.toLowerCase().startsWith('last')
        && muted.indexOf(payload.speaker) === -1
      ) {
        // Automute
        Classroom.input.mute({
          target: payload.speaker,
          message: payload.speaker,
        });
      } else if (muted.indexOf(payload.speaker) === -1) {
        _publicMessageEvent(payload);

        if (
          Classroom.properties.notifications
          && payload.speaker !== AoPS.session.username
          && payload.message.match(RegExp(`@${AoPS.session.username}`, 'i'))
        ) {
          scriptObj.notification(`${payload.speaker} mentioned you in ${win.getTitle()}`, payload.speaker, payload.message, payload['room-id']);
        } else if (
          Classroom.properties.notifications
          && payload.speaker !== AoPS.session.username
          && !document.hasFocus()
        ) {
          scriptObj.notification(`${payload.speaker} in ${win.getTitle()}`, payload.speaker, payload.message, payload['room-id']);
        }

        // Emit payload so that bots don't have to override main _publicMessageEvent function
        emitter.emit('on-public-message', payload);
      }
    };
    /* jshint ignore: end */

    interceptFunction(
      Classroom.events,
      'whisperEvent',

      {
        after(payload) {
          if (!document.hasFocus() && Classroom.properties.notifications) {
            scriptObj.notification(`Whisper from ${payload.speaker}`, payload.speaker, payload.message, payload['room-id']);
          }
        },
      },
    ); // jshint ignore: line

    interceptFunction(
      Classroom.events,
      'privateSendEvent',

      {
        after(payload) {
          const win = App.getWindow(`private-${payload.window}`);

          if (win && !document.hasFocus() && Classroom.properties.notifications) {
            scriptObj.notification(`Private with ${payload['speaker-name']}`, payload['speaker-name'], payload.message, win.id);
          }
        },
      },
    ); // jshint ignore: line

    interceptFunction(
      Classroom.responses,
      'join-room-response',

      {
        after(payload) {
          emitter.emit('on-join-response', payload);

          // Update user id list
          payload['user-list'].forEach((user) => {
            if (!scriptObj.ids[user.username]) scriptObj.ids[user.username] = user['user-id'];
          });
        },
      },
    ); // jshint ignore: line

    interceptFunction(
      Classroom.responses,
      'leave-room-response',

      {
        after(payload) {
          emitter.emit('on-leave-response', payload);
        },
      },
    ); // jshint ignore: line

    const roomListResponse = Classroom.responses['room-list-response'];

    Classroom.responses['room-list-response'] = (parameters) => {
      let { rooms } = parameters;

      // Add subrooms
      rooms = rooms.map((room) => {
        if (room['room-name'].startsWith('MathJam')) {
          return [room];
        }

        const suffix = room['room-name'].substring(room['room-name'].indexOf('-'));
        const subrooms = ['A', 'B', 'C', 'D', 'E', 'F'];

        return [
          room,

          ...subrooms.map((subroom) => {
            const obj = {
              'room-id': room['room-id'] + subroom,
              'room-name': room['room-id'] + subroom + suffix,
            };

            return obj;
          }),
        ];
      }).reduce((arr, item) => [...arr, ...item], []);

      roomListResponse(Object.assign({}, parameters, { rooms }));
    };

    // Init stuff
    scriptObj.init();
  }
})();