shuffyiosys / RPH Tools

// ==UserScript==
// @name       RPH Tools
// @namespace  https://openuserjs.org/scripts/shuffyiosys/RPH_Tools
// @version    4.4.5
// @description Adds extended settings to RPH
// @match      https://chat.rphaven.com/
// @copyright  (c)2014 shuffyiosys@github
// @grant      none
// @updateURL  https://openuserjs.org/meta/shuffyiosys/RPH_Tools.meta.js
// @license    MIT
// ==/UserScript==

const VERSION_STRING = "4.4.5";

const SETTINGS_NAME = "rph_tools_settings";
/**
 * Marks an HTML element with red or white if there's a problem
 * @param {string} element Full selector of the HTML element to mark
 * @param {boolean} mark If the mark is for good or bad
 */
function markProblem(element, mark) {
  if (mark === true) {
    $(element).css("background", "#FF7F7F");
  }
  else {
    $(element).css("background", "#FFF");
  }
}

/**
 * Checks to see if an input is valid or not and marks it accordingly
 * @param {string} settingId Full selector of the HTML element to check
 * @param {string} setting What kind of setting is being checked
 * @return If the input is valid or not
 */
function validateSetting(settingId, setting) {
  let validInput = false;
  let input = $(settingId).val();

  if (setting === "url") {
    validInput = validateUrl(input);
  }
  else if (setting === "color") {
    input = input.replace("#", "");
    validInput = validateColor(input);
  }
  markProblem(settingId, !validInput);
  return validInput;
}

/**
 * Makes sure the color input is a valid hex color input
 * @param {string} color Color input
 * @returns If the color input is valid
 */
function validateColor(color) {
  let pattern = new RegExp(/([0-9A-Fa-f]{6}$)|([0-9A-Fa-f]{3}$)/i);
  return pattern.test(color);
}

/**
 * Makes sure the URL input is valid
 * @param {string} url URL input
 * @returns If the URL input is valid
 */
let validateUrl = function (url) {
  if (url === "") {
    return true;
  }
  else {
    let regexp = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/;
    return regexp.test(url) === true;
  }
};

/**
 * Adds an option to a select element with a value and its label
 * @param {string} value Value of the option element
 * @param {string} label Label of the option element
 * @param {object} droplist Which select element to add option to
 */
function addToDroplist(value, label, droplist) {
  let droplist_elem = $(droplist);
  droplist_elem.append(
    $("<option>", {
      value: value,
      text: label,
    })
  );
}

/**
 * In an array of objects, return the first instance where a key matches the
 * value being searched.
 * @param {array} objArray Array of objects
 * @param {*} key Key to look for
 * @param {*} value Value of the key to match
 * @return Index of the first instance where the key matches the value, -1
 *         otherwise.
 */
function arrayObjectIndexOf(objArray, key, value) {
  for (let i = 0; i < objArray.length; i++) {
    if (objArray[i][key] === value) {
      return i;
    }
  }
  return -1;
}

/**
 * Sorts the account's username list to alphabetical order
 */
function getSortedNames() {
  let namesToIds = {};
  account.users.forEach(function (userObj) {
    namesToIds[userObj.props.name] = userObj.props.id;
  });

  let sorted = [];
  for (let key in namesToIds) {
    sorted[sorted.length] = key;
  }
  sorted.sort();

  let tempDict = {};
  for (let i = 0; i < sorted.length; i++) {
    tempDict[sorted[i]] = namesToIds[sorted[i]];
  }
  namesToIds = tempDict;
  return namesToIds;
}

/**
 * Generates a randum number using the Linear congruential generator algorithm
 * @param {*} seed - RNG seed value
 */
function LcgRng(seed, init = true) {
  if (init) {
    seed = seed % 2147483647;
    if (seed <= 0) {
      seed += 2147483646;
    }
  }
  return (seed * 16807) % 2147483647;
}

function calculateDiceRolls(dieNum, dieSides, seed) {
  let results = [];
  let result = LcgRng(seed);
  results.push((result % dieSides) + 1);
  for (let die = 1; die < dieNum; die++) {
    result = LcgRng(result, false);
    results.push((result % dieSides) + 1);
  }
  total = results.reduce((a, b) => a + b, 0);
  return {
    results: results,
    total: total,
  };
}

function generateRngResult(command, message, seed) {
  const MSG_ARGS = message.split(/ /gm);
  let resultMsg = "";
  if (command === "rng-coinflip") {
    const outcomes = ["heads", "tails"];
    resultMsg += `flips a coin. It lands on... ${outcomes[LcgRng(seed) % 2]}!`;
  }
  else if (command === "rng-roll") {
    let diceArgs;
    if (MSG_ARGS.length === 1) {
      diceArgs = [1, 20];
    }
    else {
      diceArgs = parseRoll(MSG_ARGS[1]);
    }
    const result = calculateDiceRolls(diceArgs[0], diceArgs[1], seed);
    resultMsg += `rolled ${diceArgs[0]}d${diceArgs[1]}: ${result["results"].join(" ")} (total: ${result.total})`;
  }
  else if (command === "rng-rps") {
    const outcomes = ["Rock!", "Paper!", "Scissors!"];
    resultMsg += `plays Rock, Paper, Scissors and chooses... ${outcomes[
      Math.ceil(Math.random() * 3) % 3
    ].toString()}`;
  }

  return resultMsg;
}

function parsePostCommand(message) {
  let command = "";
  if (message.startsWith("/roll")) {
    command = "rng-roll";
  }
  else if (message.startsWith("/coinflip")) {
    command = "rng-coinflip";
  }
  else if (message.startsWith("/rps")) {
    command = "rng-rps";
  }
  else if (message.startsWith("/me")) {
    command = "me";
  }
  return command;
}

/**
 * Gets the list of vanity names and maps them to an ID
 */
function getVanityNamesToIds() {
  let vanityToIds = {};
  for (let user in messenger.users) {
    let vanityName = messenger.users[user].props.vanity;
    if (vanityName) vanityToIds[vanityName] = user;
  }
  return vanityToIds;
}

function parseRoll(rollArgs) {
  const DIE_NUM_MIN = 1;
  const DIE_NUM_MAX = 100;
  const DIE_SIDE_MIN = 2;
  const DIE_SIDE_MAX = 1000000;
  let die = 1;
  let sides = 20;
  die = parseInt(rollArgs.split("d")[0]);
  sides = parseInt(rollArgs.split("d")[1]);
  die = Math.min(Math.max(die, DIE_NUM_MIN), DIE_NUM_MAX);
  sides = Math.min(Math.max(sides, DIE_SIDE_MIN), DIE_SIDE_MAX);
  return [die, sides];
}

function getCssRoomName(roomName) {
  return roomName.replace(/[^a-z0-9]/g, function (s) {
    var c = s.charCodeAt(0);
    if (c === 32) return "-";
    if (c >= 65 && c <= 90) return "" + s.toLowerCase();
    return ("000" + c.toString(16)).slice(-4);
  });
}

function displayNotification(message) {
  if (document.hidden) {
    let notification = new Notification(message);
    setTimeout(() => {
      notification.close();
    }, 6000);
  }
}

function createTimestamp(time) {
  const timestamp = new Date(time);
  const dateString = timestamp.toLocaleDateString(navigator.language, {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  });
  const delim = dateString.indexOf("/", 3);
  const timeString = timestamp.toTimeString().substring(0, 5);
  return `${dateString.substring(0, delim)} ${timeString}`;
}
/**
 * Generates a hash value for a string
 * This was modified from https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript-jquery
 */
String.prototype.hashCode = function () {
  let hash = 0,
    i,
    chr,
    len;
  if (this.length === 0) return hash;
  for (i = 0, len = this.length; i < len; i++) {
    chr = this.charCodeAt(i);
    hash = (hash << 31) - hash + chr;
    hash |= 0; // Convert to 32bit integer
  }
  return hash;
};
/****
 * This module handles the chat functions of the script.
 ****/
let chatModule = (function () {
  const chatLogsStorageName = "chatLogs";
  const AUTOJOIN_TIMEOUT_SEC = 5 * 1000;
  const MAX_ROOMS = 30;
  const AUTOJOIN_INTERVAL = 2 * 1000;
  const RNG_TIMEOUT = 30 * 1000;
  const ALERT_HIGHLIGHT = `background: #F00; color: #FFF; font-weight: bold;`;
  const html = {
    tabId: "chat-module",
    tabName: "Chat",
    tabContents: `
      <h3>Chat Options</h3><br/>
      <h4>Appearance & Behavior</h4>
      <div class="rpht-option-block">
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="snapRoomListEnable">Snap to room list to room</label>
          <label class="switch"><input type="checkbox" id="snapRoomListEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">When you select a chat tab, snap the room list to the room and expand it if it's collapsed</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="chatColorSelection">Stylize user's messages</label>
          <select style="float: right; width: 110px;" id="chatColorSelection">
            <option value="0">None</option>
            <option value="1" selected>Highlight speech</option>
            <option value="2">Everything</option>
          </select>
          <label class="rpht-label descript-label">Changes the color of user's messages</label>
        </div>
        <div class="rpht-option-section">
          <label style="font-weight: bold; width:522px; padding: 0px;" for="unreadMarkerSelection">Mark rooms with unread messages</label>
          <select style="float: right; width: 110px;" id="unreadMarkerSelection">
            <option value="0">No marker</option>
            <option value="1" selected>Simple</option>
            <option value="2"># unread</option>
          </select>
          <label class="rpht-label descript-label">Marks room tabs with unread messages</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="chatmsgPaddingEnable">Add padding between messages</label>
          <label class="switch"><input type="checkbox" id="chatmsgPaddingEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Adds padding between messages to increase readibility</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="showCommandWindowEnable">Show command window</label>
          <label class="switch"><input type="checkbox" id="showCommandWindowEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Shows the command window when typing a command</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="removeHighlightingEnable">Remove message highlighting</label>
          <label class="switch"><input type="checkbox" id="removeHighlightingEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Removes the subtle highlighting from certain chat messages</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="enableTabSwitch">Enable tab switch hotkey</label>
          <label class="switch"><input type="checkbox" id="enableTabSwitch"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Press Alt + Shift + Left/Right to switch between tabs
          This will not work if you have auto-sorting on in the UI options</label>
        </div>
        <div class="rpht-option-section option-section-bottom">
          <label class="rpht-label checkbox-label" for="enableImagePreview">Enable image previews</label>
          <label class="switch"><input type="checkbox" id="enableImagePreview"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Links to images show up in chat. This may not work for some links.</label>
        </div>
      </div>
      <h4>Chat Pinging</h4>
      <div class="rpht-option-block">
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="notifyPingEnable">Enable pings</label>
          <label class="switch"><input type="checkbox" id="notifyPingEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">  Turns on ping notifications in chat</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="selfPingEnable">Can ping yourself</label>
          <label class="switch"><input type="checkbox" id="selfPingEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Pings will trigger on your own messages</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="notifyNotificationEnable">Enable desktop notifications</label>
          <label class="switch"><input type="checkbox" id="notifyNotificationEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Pops up a notification when you get pinged</label>
        </div>
        <div class="rpht-option-section">
          <p>Names to be pinged (comma separated)</p>
          <textarea id="pingNames" rows="8" class="rpht_textarea"> </textarea>
        </div>
        <div class="rpht-option-section">
          <label><strong>Ping sound URL</strong></label><br>
          <input type="text" class="rpht-long-input" id="pingURL"><br><br>
          <label class="rpht-label descript-label">URL to an audio file, or leave blank for no sound</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="pingExactMatch">Exact match</label>
          <label class="switch"><input type="checkbox" id="pingExactMatch"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">e.g., If pinging on "Mel", matches on "Mel" and not "Melody"</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="pingCaseSense">Case sensitive</label>
          <label class="switch"><input type="checkbox" id="pingCaseSense"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">e.g., If pinging on "Mel", matches on "Mel" and not "mel"</label>
        </div>
        <div class="rpht-option-section">
          <h4>Ping styling</h4>
          <label class="rpht-label text-input-label">Text Color</label><input type="text" class="rpht-short-input" id="pingTextColor" value="#000"><br /><br />
          <label class="rpht-label text-input-label">Highlight</label><input type="text" class="rpht-short-input" id="pingHighlightColor" value="#FFA"><br><br>
          <label class="rpht-label checkbox-label" style="font-weight:initial;" for="pingBoldEnable">Add <strong>bold</strong></label>
          <label class="switch"><input type="checkbox" id="pingBoldEnable"><span class="rpht-slider round"></span></label><br><br>
          <label class="rpht-label checkbox-label" style="font-weight:initial;" for="pingItalicsEnable">Add <em>Italics</em></label>
          <label class="switch"><input type="checkbox" id="pingItalicsEnable"><span class="rpht-slider round"></span></label>
        </div>
        <div class="rpht-option-section option-section-bottom">
            <label class="rpht-label checkbox-label">Ping Tester: </label>
            <input type="text" class="rpht-long-input" id="pingPreviewInput" placeholder="Enter ping word to test"><br /><br />
            <label>Ping preview:</label><span id="pingPreviewText"></span>
        </div>
      </div>
      <h4>Auto Joining</h4>
      <div class="rpht-option-block">
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="trackSession">Sessioning</label>
          <label class="switch"><input type="checkbox" id="trackSession"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Keeps track of which rooms you were in, then rejoins them when you log in again.</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="joinFavEnable">Join favorites</label>
          <label class="switch"><input type="checkbox" id="joinFavEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Join rooms that are in the favorite rooms list</label>
        </div>
        <div class="rpht-option-section option-section-bottom">
          <h4>Favorite Rooms</h4>
          <label class="rpht-label split-input-label">Username </label><select class="split-input-label" id="favUserDropList"></select><br /><br />
          <label class="rpht-label split-input-label">Room </label><input class="split-input-label" type="text" id="favRoom" name="favRoom"><br /><br />
          <label class="rpht-label split-input-label">Password</label><input class="split-input-label" type="text" id="favRoomPw" name="favRoomPw"><br /><br />
          <button style="width: 60px; float:right;" type="button" id="favAdd">Add</button><br /><br />
          <select style="width: 100%;" id="favRoomsList" size="10"></select><br><br>
          <button style="float:right;" type="button" id="favRemove">Remove</button><br />
        </div>
      </div>`,
  };

  const CHAT_COMMANDS = new(function () {
    this.away = `<tr><td><code>/away [message]</code></td><td style="padding-bottom:10px;">Sets your status to "Away" and the status message<br>Example: <code>/away I'm away</code></td></tr>`;
    this.coinflip = `<tr><td><code>/coinflip</code></td><td style="padding-bottom:10px;">Performs a coin flip</td></tr>`;
    this.leave = `<tr><td><code>/leave</code></td><td style="padding-bottom:10px;">Leaves the current room</td></tr>`;
    this.me = `<tr><td><code>/me</code></td><td style="padding-bottom:10px;">Formats text as an action</td></tr>`;
    this.roll = `<tr><td><code>/roll [N]d[S]</code></td><td style="padding-bottom:10px;">Performs a dice roll with N die of S sides. For example /roll 3d12 will roll three, 12-sided die. Doing /roll by itself will default to 1d20</td></tr>`;
    this.rps = `<tr><td><code>/rps</code></td><td style="padding-bottom:10px;">Performs a Rock/Paper/Scissors action</td></tr>`;
    this.status = `<tr><td><code>/status [message]</code></td><td style="padding-bottom:10px;">Sets your status message<br>Example: <code>/status I'm tabbed out</code></td></tr>`;
    this.kick = `<tr><td><code>/kick [username],[reason]</code></td><td style="padding-bottom:10px;">Kicks [username] from the current room with [reason] (optional)</td></tr>`;
    this.ban = `<tr><td><code>/ban [username],[reason]</code></td><td style="padding-bottom:10px;">Bans [username] from the current room with [reason] (optional)</td></tr>`;
    this[
      "add-mod"
    ] = `<tr><td><code>/add-mod [username]</code></td><td style="padding-bottom:10px;">Adds [username] as a mod of the current room</td></tr>`;
    this[
      "add-owner"
    ] = `<tr><td><code>/add-onwer [username]</code></td><td style="padding-bottom:10px;">Adds [username] as the owner of the current room</td></tr>`;
    this.unban = `<tr><td><code>/unban [username],[reason]</code></td><td style="padding-bottom:10px;">Unbans [username] from the current room with [reason] (optional)</td></tr>`;
    this[
      "remove-mod"
    ] = `<tr><td><code>/remove-mod [username]</code></td><td style="padding-bottom:10px;">Removes [username] as a mod of the current room</td></tr>`;
    this[
      "remove-owner"
    ] = `<tr><td><code>/remove-owner [username]</code></td><td style="padding-bottom:10px;">Removes [username] as the owner of the current room</td></tr>`;
  })();

  const CHAT_COMMAND_HTML = `<div id="chatCommandTooltip" class="rpht-tooltip-common rpht-cmd-tooltip"></div>`;

  const DICE_ROLL_POPUP_HTML = `<div id="diceRollerPopup" class="rpht-tooltip-common">
    <p style="margin-bottom:10px;">Dice Roller <span id="diceRollerClose" class="rpht-close-btn">&nbsp;X&nbsp;</span></p>
    <label class="rpht-die-label"># of die</label> <input id="rpht_dieRollerCount" class="rpht-die-updown" type="number" max="100" min="1" value="1">
    <br>
    <label class="rpht-die-label"># of sides</label> <input id="rpht_dieRollerSides" class="rpht-die-updown" type="number"max="1000" min="2" value="20">
    <br><br>
    <button id="dieRollButton">
      Let's roll!
    </button>
    <hr style="margin-top: 6px; margin-bottom: 6px; ">
    <button id="coinFlipButton">
      Flip a coin!
    </button>
  </div>`;

  let chatSettings = {};
  let chatRoomLogs = null;
  let localStorageName = "chatSettings";
  let isRoomMod = {};
  let autoDismissTimer = null;
  let autoJoinTimer = null;
  let pingHighlightText = "";

  function updateSetting(settingName, selector) {
    let element = $(selector);

    if (element.length < 1) {
      return;
    }

    let value = null;
    if (element[0].localName === "input" && element[0].type === "checkbox") {
      value = $(selector).is(":checked");
    }
    else if (element[0].localName === "option") {
      value = parseInt($(selector).val());
    }

    chatSettings[settingName] = value;
    saveSettings();
  }

  function setupSnapRoomList() {
    for (idx in rph.roomsJoined) {
      const room = rph.roomsJoined[idx];
      const roomCssName = getCssRoomName(room.roomname);
      if (chatSettings.snapRoomList === true) {
        $(`li.${room.user}_${roomCssName}`).click(() => {
          scrollToRoomList(roomCssName);
        });
      }
      else {
        $._data($(`li.${room.user}_${roomCssName}`)[0], "events").click.pop();
      }
    }
  }

  function init() {
    loadSettings();

    $("#chat-bottom").append(CHAT_COMMAND_HTML);
    $("#chat-bottom").append(DICE_ROLL_POPUP_HTML);
    $("#chatCommandTooltip").hide();
    $("#diceRollerPopup").hide();

    /* General Options */
    $("#snapRoomListEnable").change(() => {
      updateSetting("snapRoomList", "#snapRoomListEnable");
      setupSnapRoomList();
    });

    $("#chatColorSelection").change(() => {
      updateSetting("colorStylizing", "#chatColorSelection option:selected");
    });

    $("#unreadMarkerSelection").change(() => {
      updateSetting("unreadMarkerSelection", "#unreadMarkerSelection option:selected");
    });

    $("#chatmsgPaddingEnable").change(() => {
      chatSettings.msgPadding = $("#chatmsgPaddingEnable").is(":checked");
      saveSettings();
    });

    $("#showCommandWindowEnable").change(() => {
      chatSettings.showCommandWindow = $("#showCommandWindowEnable").is(":checked");
      saveSettings();
    });

    $("#removeHighlightingEnable").change(() => {
      chatSettings.removeHighlighting = $("#removeHighlightingEnable").is(":checked");
      saveSettings();
    });

    $(`#enableTabSwitch`).change(() => {
      chatSettings.enableTabSwitch = $("#enableTabSwitch").is(":checked");

      if (chatSettings.enableTabSwitch === true) {
        $(document).on("keydown", changeTab);
      }
      else {
        $(document).off("keydown", changeTab);
      }
      saveSettings();
    });

    $(`#enableImagePreview`).change(() => {
      updateSetting("enableImagePreview", "#enableImagePreview");
    });

    /* Pinging Options */
    $("#notifyPingEnable").change(() => {
      chatSettings.enablePings = $("#notifyPingEnable").is(":checked");
      saveSettings();
    });

    $("#notifyNotificationEnable").change(() => {
      chatSettings.pingNotify = $("#notifyNotificationEnable").is(":checked");
      saveSettings();
    });

    $("#selfPingEnable").change(() => {
      chatSettings.selfPing = $("#selfPingEnable").is(":checked");
      saveSettings();
    });

    $("#pingNames").blur(() => {
      let triggers = $("#pingNames").val().replace("\n", "").replace("\r", "");
      chatSettings.triggers = triggers;
      saveSettings();
    });

    $("#pingURL").blur(() => {
      chatSettings.audioUrl = $("#pingURL").val();
      rph.sounds.notify = new Audio(chatSettings.audioUrl);
      saveSettings();
    });

    $("#pingTextColor").blur(() => {
      let colorInput = $("#pingTextColor").val();
      if (validateColor(colorInput) === true) {
        chatSettings.color = colorInput;
        generateHighlightStyle();
        saveSettings();
      }
    });

    $("#pingHighlightColor").blur(() => {
      if (validateSetting("#pingHighlightColor", "color") === true) {
        chatSettings.highlight = $("#pingHighlightColor").val();
        generateHighlightStyle();
        saveSettings();
      }
    });

    $("#pingBoldEnable").change(() => {
      chatSettings.bold = $("#pingBoldEnable").is(":checked");
      generateHighlightStyle();
      saveSettings();
    });

    $("#pingItalicsEnable").change(() => {
      chatSettings.italics = $("#pingItalicsEnable").is(":checked");
      generateHighlightStyle();
      saveSettings();
    });

    $("#pingExactMatch").change(() => {
      chatSettings.exact = $("#pingExactMatch").is(":checked");
      saveSettings();
    });

    $("#pingCaseSense").change(() => {
      chatSettings.case = $("#pingCaseSense").is(":checked");
      saveSettings();
    });

    $("#pingPreviewInput").keyup(() => {
      let msg = $("#pingPreviewInput").val();
      let testRegex = matchPing(msg);
      if (testRegex !== null) {
        msg = msg.replace(
          testRegex,
          `<span style="${pingHighlightText}">${msg.match(testRegex)}</span>`
        );
        rph.sounds.notify.play();
        $("#pingPreviewText").html(` &nbsp;${msg}`);
      }
      else {
        $("#pingPreviewText").html(` No match`);
      }
    });

    /* Session Options */
    $("#trackSession").click(() => {
      chatSettings.trackSession = $("#trackSession").is(":checked");
      if (chatSettings.trackSession) {
        chatSettings.session = rph.roomsJoined;
      }
      else {
        chatSettings.session = [];
      }
      saveSettings();
    });

    $("#joinFavEnable").click(() => {
      chatSettings.joinFavorites = $("#joinFavEnable").is(":checked");
      saveSettings();
    });

    $("#favAdd").click(() => {
      parseFavoriteRoom($("#favRoom").val());
      settingsModule.saveSettings(localStorageName, chatSettings);
    });

    $("#favRemove").click(() => {
      removeFavoriteRoom();
      settingsModule.saveSettings(localStorageName, chatSettings);
    });

    /* Die roller */
    $("#dieRollButton").click(() => {
      const DIE_COUNT = $("#rpht_dieRollerCount").val();
      const DIE_SIDES = $("#rpht_dieRollerSides").val();
      $(`textarea.${$("li.tab.active")[0].className.split(" ")[2]}.active`).val(
        `/roll ${DIE_COUNT}d${DIE_SIDES}`
      );
      $(`textarea.${$("li.tab.active")[0].className.split(" ")[2]}.active`).trigger({
        type: "keydown",
        which: 13,
        keyCode: 13,
      });
      $("#diceRollerPopup").hide();
    });

    $("#coinFlipButton").click(() => {
      $(`textarea.${$("li.tab.active")[0].className.split(" ")[2]}.active`).val(`/coinflip`);
      $(`textarea.${$("li.tab.active")[0].className.split(" ")[2]}.active`).trigger({
        type: "keydown",
        which: 13,
        keyCode: 13,
      });
      $("#diceRollerPopup").hide();
    });

    $("#diceRollerClose").click(() => {
      $("#diceRollerPopup").hide();
    });

    $(window).unload(function () {
      chatRoomLogs.timestamp = Date.now();
      settingsModule.saveSettings(chatLogsStorageName, chatRoomLogs);
    });

    /* General intialization */
    $(window).resize(resizeChatTabs);

    socket.on("confirm-room-join", (data) => {
      roomSetup(data);
    });

    socket.on("room-users-leave", () => {
      chatSettings.session = rph.roomsJoined;
      saveSettings();
    });

    socket.on("msg", (data) => {
      for (let dataIdx = 0; dataIdx < data.length; dataIdx++) {
        const msgData = data[data.length - 1 - dataIdx];
        let thisRoom = getRoom(msgData.room);
        let messages = $(`div[data-roomname="${msgData.room}"]`).children();
        for (let idx = messages.length - 2 - dataIdx; idx > 0; idx--) {
          let message = messages[idx];
          if ($(message.children[0].children[0]).attr("data-userid") == msgData.userid) {
            message.children[0].children[0].innerHTML = createTimestamp(msgData.time);
            processMsg(thisRoom, msgData, message, isRoomMod[msgData.room]);
            break;
          }
        }
      }
    });

    /* Setup the timer for automatically dismissing the opening dialog once
  rooms are available. The timer clears after. */
    autoDismissTimer = setInterval(() => {
      if (Object.keys(rph.rooms).length > 0) {
        $("button span:contains('Continue')").trigger("click");
        clearTimeout(autoDismissTimer);
      }
    }, 500);

    socket.on("account-users", () => {
      setTimeout(() => {
        $("#favUserDropList").empty();
        let namesToIds = getSortedNames();
        for (let name in namesToIds) {
          addToDroplist(namesToIds[name], name, "#favUserDropList");
        }
      }, 3000);
    });

    /* Fix the room management dialog */
    $("#room-management-dialog > div.inner").css("height", "100%");
    $("#room-management-dialog > div.inner > div").css("width", "640px");
    $("#room-management-dialog > div.inner > div").css("float", "right");
    $("iframe.group-iframe").css("width", "calc(100% - 640px)");
    $("iframe.group-iframe").css("height", "100%");

    /* Add user list toggling */
    $("#chat-menu").append('<li class="toggleUserList"><a>👤</a></li>');
    $("li.toggleUserList").click(toggleRoomList);

    /* Kick off auto joining */
    if (chatSettings.joinFavorites || chatSettings.trackSession) {
      autoJoinTimer = setInterval(autoJoiningHandler, AUTOJOIN_INTERVAL);
    }
  }

  function toggleRoomList() {
    $(".rooms.nano-content").toggle();
    if ($(".rooms.nano-content").is(":visible")) {
      $("#chat").width("");
      $("#chat-bottom").width("");
    }
    else {
      $("#chat").width("97%");
      $("#chat-bottom").width("97%");
    }
  }

  /**
   * When user joins a room, do the following:
   * - Set up the .onMessage function for pinging
   * - Add the user's name to the chat tab and textarea
   * - Create a room-pair name for the Modding section
   * - Add the room the session.
   * @param {object} room Room that the user has joined
   */
  function roomSetup(room) {
    let thisRoom = getRoom(room.room);

    /* This is to filter out double room leaving. */
    thisRoom.userLeave = (function () {
      let cached_function = thisRoom.userLeave;
      return function () {
        if (thisRoom.users.indexOf(arguments[0]) > -1) {
          cached_function.apply(this, arguments);
        }
      };
    })();

    const NUM_USERS = account.userids.length;
    for (let idx = 0; idx < NUM_USERS && !isRoomMod[room.room]; idx++) {
      if (
        thisRoom.props.mods.indexOf(account.userids[idx]) > -1 ||
        thisRoom.props.owners.indexOf(account.userids[idx]) > -1
      ) {
        isRoomMod[room.room] = true;
        break;
      }
    }

    getUserById(room.userid, (User) => {
      const roomCss = getCssRoomName(thisRoom.props.name);
      const moddingModule = rphToolsModule.getModule("Modding Module");
      if (moddingModule !== null && isRoomMod[room.room]) {
        moddingModule.addModRoomPair(User.props, thisRoom.props.name);
      }

      $(`li.${User.props.id}_${roomCss}`).click(() => {
        for (let roomTab of thisRoom.$tabs) {
          roomTab.removeAttr("style");
        }
      });

      /* Set up room tab and input box */
      setupRoomTabs(User, roomCss);

      /* Setup popups and tooltips */
      setupTextboxInput(User, roomCss, thisRoom);

      /* Adjust chat tab size */
      $("#chat-tabs").addClass("rpht_chat_tab");
      resizeChatTabs();
    });

    chatSettings.session = rph.roomsJoined;
    saveSettings();
  }

  function setupRoomTabs(User, roomCss) {
    const userId = User.props.id;
    const username = User.props.name;
    const color = User.props.color[0];
    const buttonCommonStyle = "cursor:pointer; float: right; width: 21px; height: 21px;";

    $(`li.${userId}_${roomCss}`).prepend(
      `<p style="font-size: x-small; height:16px; margin-top: -10px;">${username}</p>`
    );
    $(`li.${userId}_${roomCss} > a.close`).on("click", () => {
      if (chatSettings.trackSession) {
        chatSettings.session = rph.roomsJoined;
        saveSettings();
      }
    });

    $(`textarea.${userId}_${roomCss}`).prop("placeholder", `Post as ${username}`);
    $(`textarea${userId}_${roomCss}`).css("color", `${color}`);
    $(`div.${userId}_${roomCss} .user-for-textarea span`).css("overflow", "hidden");
    $(`div.${userId}_${roomCss} .user-for-textarea div`)
      .css("width", "234px")
      .append(
        `<button class="${userId}_${roomCss} roller-button" style="cursor:pointer; float: right; width: auto; height: 21px;" title="Dice roller">🎲</button>`
      )
      .append(
        `<button class="${userId}_${roomCss} bold-button" style="${buttonCommonStyle} font-weight: bold;" title="Bold selection">B</button>`
      )
      .append(
        `<button class="${userId}_${roomCss} italics-button" style="${buttonCommonStyle} font-style: italic;" title="Italics selection">I</button>`
      )
      .append(
        `<button class="${userId}_${roomCss} underline-button" style="${buttonCommonStyle} text-decoration: underline;" title="Underline selection">U</button>`
      )
      .append(
        `<button class="${userId}_${roomCss} linethrough-button" style="${buttonCommonStyle} text-decoration: line-through;" title="Linethrough selection">S</button>`
      )
      .append(
        `<button class="${userId}_${roomCss} spoiler-button" style="${buttonCommonStyle} font-style: italic;" title="Spoiler selection"><span class="spoiler">S</span></button>`
      )
      .append(
        `<button class="${userId}_${roomCss} superscript-button" style="${buttonCommonStyle} font-style: italic;" title="Superscript selection"><sup>S</sup></button>`
      )
      .append(
        `<button class="${userId}_${roomCss} subscript-button" style="${buttonCommonStyle} font-size: smaller;" title="Subscript selection"><sub>S</sub></button>`
      );
    $(`button.${userId}_${roomCss}.roller-button`).click(() => {
      $("#diceRollerPopup").toggle();
    });
    $(`button.${userId}_${roomCss}.bold-button`).click(() => {
      styleSelection(`${userId}_${roomCss}`, "bold");
    });
    $(`button.${userId}_${roomCss}.italics-button`).click(() => {
      styleSelection(`${userId}_${roomCss}`, "italics");
    });
    $(`button.${userId}_${roomCss}.underline-button`).click(() => {
      styleSelection(`${userId}_${roomCss}`, "underline");
    });
    $(`button.${userId}_${roomCss}.linethrough-button`).click(() => {
      styleSelection(`${userId}_${roomCss}`, "linethrough");
    });
    $(`button.${userId}_${roomCss}.spoiler-button`).click(() => {
      styleSelection(`${userId}_${roomCss}`, "spoiler");
    });
    $(`button.${userId}_${roomCss}.subscript-button`).click(() => {
      styleSelection(`${userId}_${roomCss}`, "sub");
    });
    $(`button.${userId}_${roomCss}.superscript-button`).click(() => {
      styleSelection(`${userId}_${roomCss}`, "super");
    });

    if (chatSettings.snapRoomList === true) {
      $(`li.${userId}_${roomCss}`).click(() => {
        scrollToRoomList(roomCss);
      });
    }
  }

  function styleSelection(textareaClass, styleType) {
    const chatTextbox = $(`textarea.${textareaClass}`)[0];
    const start = chatTextbox.selectionStart;
    const end = chatTextbox.selectionEnd;
    let tag = "";
    if (styleType == "bold") {
      tag = "**";
    }
    else if (styleType == "italics") {
      tag = "//";
    }
    else if (styleType == "underline") {
      tag = "__";
    }
    else if (styleType == "linethrough") {
      tag = "--";
    }
    else if (styleType == "spoiler") {
      tag = "[spoiler]";
    }
    else if (styleType == "sub") {
      tag = "vv";
    }
    else if (styleType == "super") {
      tag = "^^";
    }

    const original = chatTextbox.value;
    const highlighted = original.substring(start, end).trim();
    chatTextbox.value = original.substring(0, start);

    if (original[start] == " ") {
      chatTextbox.value += " ";
    }

    chatTextbox.value += tag;
    chatTextbox.value += highlighted;
    if (styleType == "spoiler") {
      chatTextbox.value += "[/spoiler]";
    }
    else {
      chatTextbox.value += tag;
    }
    if (original[end - 1] == " ") {
      chatTextbox.value += " ";
    }

    chatTextbox.value += original.substring(end);
    $(`textarea.${textareaClass}`).focus();
    if (start != end) {
      $(`textarea.${textareaClass}`)[0].setSelectionRange(start + tag.length, end + tag.length);
    }
    else {
      $(`textarea.${textareaClass}`)[0].setSelectionRange(start + tag.length, start + tag.length);
    }
  }

  function setupTextboxInput(User, roomCss, thisRoom) {
    const userId = User.props.id;
    let chatTextArea = $(`textarea.${userId}_${roomCss}`);
    $(`li.${userId}_${roomCss} a.close`).click(() => {
      $("#chatCommandTooltip").hide();
      $("#diceRollerPopup").hide();
    });
    chatTextArea.unbind("keyup");
    chatTextArea.bind("keydown", (ev) => {
      intputChatText(ev, User, thisRoom);
    });
    chatTextArea.on("input", () => {
      const chatInput = chatTextArea.val().trim();
      $("#chatCommandTooltip").hide();
      if (chatInput[0] === "/" && chatSettings.showCommandWindow) {
        const commandTable = buildComamndTable(chatTextArea.val().trim());
        $("#chatCommandTooltip").html(commandTable).show();
      }
    });
  }

  function processMsg(thisRoom, msgData, msgHtml, isMod) {
    let contentQuery = $(msgHtml.children[1].children[0]);
    /* If the message was an action, switch the query to where it really is */
    if (msgHtml.className.includes("action")) {
      contentQuery = $(msgHtml.children[1].children[1]);
    }

    /* Separate the new content from the previous content */
    const msgLineCount = msgData.msg.split("\n").length;
    const contentLines = contentQuery[0].innerHTML.split("<br>");
    const prevMsgs = contentLines.slice(0, contentLines.length - msgLineCount);

    // Fix issues with the URL parser inserting formatting tags
    let latestLine = contentLines[contentLines.length - 1];
    let sections = latestLine.split(`"`);
    for (let i = 0; i < sections.length; i++) {
      let section = sections[i];
      if (sections[i].indexOf("http") !== 0) {
        continue;
      }
      section = section.replaceAll(`<u>`, `__`);
      section = section.replaceAll(`</u>`, `__`);
      section = section.replaceAll(`<sub>`, `vv`);
      section = section.replaceAll(`</sub>`, `vv`);
      section = section.replaceAll(`<strike>`, `--`);
      section = section.replaceAll(`</strike>`, `--`);
      section = section.replaceAll(`<em>`, `//`);
      section = section.replaceAll(`</em>`, `//`);
      section = section.replaceAll(`<strong>`, `**`);
      section = section.replaceAll(`</strong>`, `**`);
      sections[i] = section;
    }
    contentLines[contentLines.length - 1] = sections.join(`"`);

    /* Add padding and remove stlying of the content */
    if (chatSettings.msgPadding && !$(msgHtml.children[1])[0].className.includes("msg-padding")) {
      $(msgHtml.children[1])[0].className += " msg-padding";
      contentQuery.removeAttr("style");
    }

    if (!thisRoom.active && msgData.room === thisRoom.props.name) {
      switch (chatSettings.unreadMarkerSelection) {
        case 2:
          break;
        case 1:
          $(`li.tab.tab-${getCssRoomName(thisRoom.props.name)}`).css(
            "border-bottom",
            "4px solid #EEE"
          );
          /* Falling through intentionally */
        default:
          for (let roomTab of thisRoom.$tabs) {
            $(roomTab.children()[2]).hide();
          }
          break;
      }
    }

    getUserById(msgData.userid, (user) => {
      let newMsgLines = contentLines.slice(contentLines.length - msgLineCount);

      for (let msgIdx = 0; msgIdx < newMsgLines.length; msgIdx++) {
        if (newMsgLines[msgIdx].indexOf("​") === -1) {
          continue;
        }

        const SEED = newMsgLines[msgIdx].split("​")[1];
        const MSG_CHUNKS = newMsgLines[msgIdx].split(/ /g);
        let validResult = true;
        newMsgLines[msgIdx] = newMsgLines[msgIdx].substring(
          0,
          newMsgLines[msgIdx].indexOf(" @​")
        );

        /* If the RNG was a dice roll, verify the roll by running the RNG with the same seed */
        if (newMsgLines[msgIdx].search("rolled") > -1) {
          const ROLL_PARAMS = parseRoll(MSG_CHUNKS[2]);
          const ROLL_RESULTS = calculateDiceRolls(ROLL_PARAMS[0], ROLL_PARAMS[1], SEED);
          for (let idx = 0; idx < ROLL_PARAMS[0] && validResult; idx++) {
            validResult = !(ROLL_RESULTS["results"][idx] !== parseInt(MSG_CHUNKS[idx + 3]));
          }
        }
        else if (newMsgLines[msgIdx].search("flips" > -1)) {
          const outcomes = ["heads", "tails"];
          let outcome = outcomes[LcgRng(SEED) % 2];
          validResult = newMsgLines[msgIdx].search(outcome) > -1;
        }

        if (validResult === false) {
          newMsgLines[
            msgIdx
          ] += ` <span style="background:#F44; color: #FFF;" title="Do not use this result">&#9746;</span>`;
        }
        else if (msgData.time - SEED > RNG_TIMEOUT) {
          newMsgLines[
            msgIdx
          ] += ` <span style="background:#FFD800; color: #000;" title="This result is outdated">&#9072;</span>`;
        }
        else {
          newMsgLines[
            msgIdx
          ] += ` <span style="background:#4A4; color: #FFF;" title="This result is good">&#9745;</span>`;
        }
      }

      let newMsg = ``;
      if (contentLines.length !== 1 && !("buffer" in msgData)) {
        newMsg += `<br>`;
      }
      newMsg += `${newMsgLines.join("<br>")}`;

      if (!msgData.buffer) {
        const selfMsg = account.userids.includes(msgData.userid);
        let notificationTrigger = 0;
        if (
          chatSettings.enablePings &&
          ((chatSettings.selfPing && selfMsg === true) || selfMsg === false)
        ) {
          let testRegex = matchPing(newMsg);
          if (testRegex) {
            newMsg = newMsg.replace(
              testRegex,
              `<span style="${pingHighlightText}">${newMsg.match(testRegex)}</span>`
            );
            rph.sounds.notify.play();
            notificationTrigger = 1;

            if (chatSettings.pingNotify && thisRoom.active === false) {
              displayNotification(
                `${user.props.name} pinged you in ${thisRoom.props.name}`,
                chatSettings.notifyTime
              );
            }
          }
        }

        /* Process other's messages for issues if a mod */
        if (isMod && moddingModule && selfMsg === false) {
          let alertRegex = null;
          let alertWords = moddingModule.getAlertWords();
          alertRegex = matchPing(newMsg, alertWords, false, true);
          // Process alert
          if (alertRegex) {
            newMsg = newMsg.replace(
              alertRegex,
              `<span style="${ALERT_HIGHLIGHT}">${newMsg.match(alertRegex)}</span>`
            );
            moddingModule.playAlert();
            notificationTrigger = 2;
          }
        }

        if (thisRoom.active === false && notificationTrigger > 0) {
          let background = notificationTrigger === 2 ? "#F00" : chatSettings.highlight;
          let textColor = notificationTrigger === 2 ? "#FFF" : chatSettings.color;

          $(`li.tab.tab-${getCssRoomName(thisRoom.props.name)}`).css({
            "background-color": background,
            color: textColor,
          });
        }
      }

      contentQuery.html(`${prevMsgs.join("<br>")} ${newMsg}`);
      if (chatSettings.colorStylizing == 0) {
        const CHILD_NODE_COUNT = contentQuery[0].childNodes.length;
        for (let i = 0; i < CHILD_NODE_COUNT; i++) {
          console.log(contentQuery[0].childNodes[i]);
          if ("classLlist" in contentQuery[0].childNodes[i]) {
            contentQuery[0].childNodes[i].classList = [];
          }
        }
      }
      else if (chatSettings.colorStylizing == 2) {
        const colorClasses = ["", "two-color", "three-color"];
        let classString = `${contentQuery[0].className}`;
        let styleString = `color: #${user.props.color[0]};`;

        classString += ` ${colorClasses[user.props.color.length - 1]}`;
        contentQuery[0].className = classString.trim();
        contentQuery.attr("style", styleString);
      }

      if (chatSettings.enableImagePreview) {
        let contentsChildren = contentQuery[0].children;
        let images = [];
        for (let i = 0; i < contentsChildren.length; i++) {
          const child = contentsChildren[i];
          if (child.tagName == "A") {
            const url = child.attributes["href"].nodeValue;
            if (url.search(`\.png|\.jpe?g|\.gif|\.webm`) > -1) {
              images.push(url);
            }
          }
        }
        contentQuery.find("div.rpht-images").remove();
        let imageArea = `<div class="rpht-images">`;
        images.forEach((url) => {
          imageArea += `<img src="${url}" width="240px" alt="${url}">&nbsp;`;
        });
        imageArea += `</div>`;
        contentQuery.append(imageArea);
      }

      if (chatSettings.removeHighlighting) {
        $(msgHtml).removeClass("action");
        $(msgHtml).removeClass("group-member");
        $(msgHtml).removeClass("self");
        $(msgHtml).removeClass("mod");
        $(msgHtml).removeClass("friend");
      }

      if (thisRoom.props.name in chatRoomLogs === false) {
        chatRoomLogs[thisRoom.props.name] = [];
      }
      if (chatRoomLogs[thisRoom.props.name].length >= 30) {
        chatRoomLogs[thisRoom.props.name].shift();
      }

      if (contentLines.length !== 1) {
        const lastIdx = chatRoomLogs[thisRoom.props.name].length - 1;
        chatRoomLogs[thisRoom.props.name][lastIdx] = msgHtml.innerHTML;
      }
      else {
        chatRoomLogs[thisRoom.props.name].push(msgHtml.innerHTML);
      }
    });
  }

  function buildComamndTable(message) {
    let commandEntry = "";
    let commandTable = "";
    if (message.length === 1) {
      commandEntry = Object.values(CHAT_COMMANDS).join("\n");
    }
    else {
      const command = message.split(" ")[0].substring(1);
      Object.keys(CHAT_COMMANDS)
        .filter((key) => key.startsWith(command))
        .forEach((key) => (commandEntry += CHAT_COMMANDS[key]));
    }

    if (commandEntry.length > 0) {
      commandTable = `<table style="width: 100%;">
      <tbody>
        <tr><td>Chat Commands:</td>  <td style="width: 68%;">&nbsp;</td></tr>
        ${commandEntry}
      </tbody>
    </table>`;
      $("#chatCommandTooltip").addClass("rpht-tooltip-common");
    }
    else {
      $("#chatCommandTooltip").removeClass("rpht-tooltip-common");
    }
    return commandTable;
  }

  /**
   * Parses a slash command from an input source.
   * @param {object} inputTextBox HTML element that holds the input textbox
   * @param {object} Room Room data
   * @param {object} User User data
   */
  function parseSlashCommand(inputTextBox, Room, User) {
    let newMessage = inputTextBox.val();
    let error = false;
    let cmdArgs = newMessage.split(/ (.+)/);

    switch (cmdArgs[0]) {
      case "/status":
      case "/away":
        if (cmdArgs.length != 3) {
          error = true;
        }
        else {
          let type = 0;
          if (cmdArgs[0] === "/away") {
            type = 1;
          }
          socket.emit("modify", {
            userid: User.props.id,
            statusMsg: cmdArgs[1],
            statusType: type,
          });
          inputTextBox.val("");
        }
        break;
      case "/coinflip": {
        const outcomes = ["heads", "tails"];
        const seed = new Date().getTime();
        let resultMsg = `/me flips a coin. It lands on... **${
            outcomes[LcgRng(seed) % 2]
          }**! @&#8203;${seed}`;
        Room.sendMessage(resultMsg, User.props.id);
      }
      break;
      case "/roll": {
        const seed = new Date().getTime();
        let diceArgs = [1, 20];
        let resultMsg = `/me `;
        let results = [];
        let result = LcgRng(seed);

        if (cmdArgs[1]) {
          diceArgs = parseRoll(cmdArgs[1]);
        }

        results.push((result % diceArgs[1]) + 1);
        for (let die = 1; die < diceArgs[0]; die++) {
          result = LcgRng(result);
          results.push((result % diceArgs[1]) + 1);
        }
        total = results.reduce((a, b) => a + b, 0);
        resultMsg += `rolled ${diceArgs[0]}d${diceArgs[1]}: ${results.join(
            " "
          )} (total: ${total}) @&#8203;${seed}`;
        Room.sendMessage(resultMsg, User.props.id);
      }
      break;
      case "/rps": {
        const results = ["Rock!", "Paper!", "Scissors!"];
        newMessage = `/me plays Rock, Paper, Scissors and chooses... ${results[
            Math.ceil(Math.random() * 3) % 3
          ].toString()}`;
        Room.sendMessage(newMessage, User.props.id);
      }
      break;
      case "/leave":
        socket.emit("leave", {
          userid: User.props.id,
          name: Room.props.name,
        });
        break;
      case "/kick":
      case "/ban":
      case "/unban":
      case "/add-owner":
      case "/add-mod":
      case "/remove-owner":
      case "/remove-mod":
        let moddingModule = rphToolsModule.getModule("Modding Module");
        if (cmdArgs.length < 2) {
          error = true;
        }
        else if (moddingModule) {
          let action = cmdArgs[0].substring(1, cmdArgs[0].length);
          let commaIdx = cmdArgs[1].indexOf(",");
          let targetName = cmdArgs[1];
          let reason = "";
          if (commaIdx > -1) {
            targetName = cmdArgs[1].substring(0, commaIdx);
            reason = cmdArgs[1].substring(commaIdx + 1, cmdArgs[1].length);
          }
          moddingModule.emitModAction(action, targetName, User.props.name, Room.props.name, reason);
          inputTextBox.val("");
        }
        break;
      default:
        Room.sendMessage(newMessage, User.props.id);
        break;
    }

    if (error) {
      Room.appendMessage(
        '<span class="first">&nbsp;</span><span title=>Error in command input</span>'
      ).addClass("sys");
    }
  }

  /**
   *
   * @param {object} ev - Event
   * @param {object} User - User the textbox is attached to
   * @param {oject} Room - Room the textbox is attached to
   */
  function intputChatText(ev, User, Room) {
    let inputTextarea = $(`textarea.${User.props.id}_${getCssRoomName(Room.props.name)}.active`);
    let message = inputTextarea.val().trim();

    if (message.length > 4000) {
      Room.appendMessage(
        `<span class="first">&nbsp;</span><span title="">Message too long</span>`
      ).addClass("sys");
      return;
    }
    else if (message.length === 0) {
      return;
    }
    else if (ev.keyCode !== 13 || ev.shiftKey === true || ev.ctrlKey === true) {
      return;
    }
    else if (ev.keyCode === 13 && (ev.shiftKey === true || ev.ctrlKey === true)) {
      inputTextarea.val(inputTextarea.val() + "\n");
    }

    $("#chatCommandTooltip").hide();
    if (message[0] === "/" && message.substring(0, 2) !== "//" && chatModule) {
      parseSlashCommand(inputTextarea, Room, User);
    }
    else {
      Room.sendMessage(message, User.props.id);
    }
    inputTextarea.val("");
  }

  /**
   * Checks if the message has any ping terms
   * @param {string} msg - The message for the chat
   * @returns Returns the match or null
   */
  function matchPing(
    msg,
    triggers = chatSettings.triggers,
    caseSensitive = chatSettings.case,
    exactMatch = chatSettings.exact
  ) {
    if (triggers.length === 0) {
      return;
    }
    let result = null;
    const pingNames = triggers.split(",");
    const regexParam = caseSensitive ? "m" : "im";
    for (i = 0; i < pingNames.length; i++) {
      let trigger = pingNames[i].trim();
      if (trigger === "") {
        continue;
      }
      const regexPattern = exactMatch ? `\\b${trigger}\\b` : trigger;
      const urlRegex = new RegExp(`href=".*?${trigger}.*?"`, "");
      let testRegex = new RegExp(regexPattern, regexParam);
      /* Check if search term is not in a link as well */
      if (!urlRegex.test(msg) && testRegex.test(msg)) {
        result = testRegex;
        break;
      }
    }
    return result;
  }

  /**
   * Resizes chat tabs based on the width of the tabs vs. the screen size.
   */
  function resizeChatTabs() {
    /* Window is smaller than the tabs width */
    if (
      $("#chat-tabs")[0].clientWidth < $("#chat-tabs")[0].scrollWidth ||
      $("#chat-tabs")[0].clientWidth > $("#chat-bottom")[0].clientWidth
    ) {
      $("#chat-top").css("padding-bottom", "136px");
      $("#chat-bottom").css("margin-top", "-138px");
    }
    else {
      $("#chat-top").css("padding-bottom", "120px");
      $("#chat-bottom").css("margin-top", "-118px");
    }
    // Debouce the function.
    $(window).off("resize", resizeChatTabs);
    setTimeout(() => {
      $(window).resize(resizeChatTabs);
    }, 100);
  }

  function generateHighlightStyle() {
    pingHighlightText = `color: ${chatSettings.color}; background: ${chatSettings.highlight};`;
    if (chatSettings.bold === true) {
      pingHighlightText += " font-weight: bold;";
    }
    if (chatSettings.italics === true) {
      pingHighlightText += " font-style:italic;";
    }
  }

  function scrollToRoomList(roomName) {
    const elementPrefix = "div.room-header-";
    if ($(`${elementPrefix}${roomName} > .content > .users`).is(":visible") === false) {
      $(`${elementPrefix}${roomName} > .header`).click();
    }
    $(`${elementPrefix}${roomName}`)[0].scrollIntoView();
  }

  function changeTab(e) {
    if (e.altKey === false || e.shiftKey === false) {
      return;
    }

    const FOCUS_TIMEOUT_MS = 100;
    if (e.which == 37) {
      let prevTab = $("ul.chat-tabs>li.active").prev();
      if (prevTab.hasClass("thumb") === true) {
        prevTab = $("ul.chat-tabs>li.active").parent().prev();

        if (prevTab.length > 0) {
          $(prevTab[0].children[1]).click();
        }
      }
      else {
        prevTab.click();
      }
      setTimeout(() => {
        $("textarea.active").focus();
      }, FOCUS_TIMEOUT_MS);
      return false;
    }
    else if (e.which == 39) {
      let nextTab = $("ul.chat-tabs>li.active").parent().next();
      if (nextTab.length === 0) {
        nextTab = $("ul.chat-tabs>li.active").next();

        if (nextTab.length > 0) {
          nextTab.click();
        }
      }
      else {
        $(nextTab[0].children[1]).click();
      }
      setTimeout(() => {
        $("textarea.active").focus();
      }, FOCUS_TIMEOUT_MS);
      return false;
    }
  }

  /** AUTO JOINING FUNCTIONS **********************************************/
  /**
   * Handler for the auto-joining mechanism.
   **/
  function autoJoiningHandler() {
    /* Don't run this if there's no rooms yet. */
    if (Object.keys(rph.rooms).length === 0) {
      return;
    }
    else if ($("#chat-tabs")[0].childNodes.length > 0) {
      /* If RPH's sessioning kicked in, clear the timeout and return. */
      setTimeout(() => {
        rph.roomsJoined.forEach((joinedRoom) => {
          populateChatLog(joinedRoom.roomname);
        });
      }, 250);

      clearTimeout(autoJoinTimer);
      return;
    }

    $(
        '<div id="rpht-autojoin" class="inner" style="background: #333;">' +
        "<p>Autojoining or restoring session in about 5 seconds.</p>" +
        '<p>Press "Cancel" to stop.</p>' +
        "</div>"
      )
      .dialog({
        open: function (event, ui) {
          setTimeout(() => {
            $("#rpht-autojoin").dialog("close");
          }, AUTOJOIN_TIMEOUT_SEC);
        },
        buttons: {
          Cancel: () => {
            joinedSession = true;
            chatSettings.session = [];
            clearTimeout(autoJoinTimer);
            $("#rpht-autojoin").dialog("close");
          },
        },
      })
      .dialog("open");

    clearTimeout(autoJoinTimer);
    autoJoinTimer = setTimeout(joinRooms, AUTOJOIN_TIMEOUT_SEC);
  }

  function populateChatLog(roomName) {
    const thisRoom = getRoom(roomName);
    if (roomName in chatRoomLogs) {
      chatRoomLogs[roomName].forEach((logEntry) => {
        thisRoom.appendMessage(logEntry);
      });
    }
  }

  /**
   * Join rooms in the favorites and what was in the session.
   */
  function joinRooms() {
    if ($("#chat-tabs")[0].childNodes.length > 0) {
      return;
    }
    if (chatSettings.joinFavorites === true) {
      joinFavoriteRooms();
    }
    if (chatSettings.trackSession === true) {
      joinPreviousSession();
    }
  }

  function joinFavoriteRooms() {
    chatSettings.favRooms.forEach((favRoom) => {
      socket.emit("join", {
        name: favRoom.room,
        userid: favRoom.userId,
        pw: favRoom.roomPw,
      });
    });
  }

  function joinPreviousSession() {
    const sessionLen = chatSettings.session.length;
    for (let i = 0; i < sessionLen; i++) {
      const favoritesLen = chatSettings.favRooms.length;
      const sessionRoom = chatSettings.session[i];
      let canJoin = true;

      for (let j = 0; chatSettings.joinFavorites && j < favoritesLen; j++) {
        const favRoom = chatSettings.favRooms[j];
        if (favRoom.name == sessionRoom.roomname && favRoom.userId == sessionRoom.user) {
          canJoin = false;
          break;
        }
      }

      if (canJoin) {
        socket.emit("join", {
          name: sessionRoom.roomname,
          userid: sessionRoom.user,
        });
      }
    }
    joinedSession = true;
  }

  /**
   * Adds an entry to the Favorite Chat Rooms list from an input
   * @param {string} roomname - Name of the room
   */
  function parseFavoriteRoom(roomname) {
    let room = getRoom(roomname);
    if (room === undefined) {
      markProblem("favRoom", true);
      return;
    }
    if (chatSettings.favRooms.length < MAX_ROOMS) {
      let selectedFav = $("#favUserDropList option:selected");
      let hashStr = $("#favRoom").val() + selectedFav.html();
      let favRoomObj = {
        _id: hashStr.hashCode(),
        user: selectedFav.html(),
        userId: parseInt(selectedFav.val()),
        room: $("#favRoom").val(),
        roomPw: $("#favRoomPw").val(),
      };
      addFavoriteRoom(favRoomObj);
      markProblem("favRoom", false);
    }
  }

  /**
   * Adds a favorite room to the settings list
   * @param {Object} favRoomObj - Object containing the favorite room parameters.
   */
  function addFavoriteRoom(favRoomObj) {
    if (arrayObjectIndexOf(chatSettings.favRooms, "_id", favRoomObj._id) === -1) {
      $("#favRoomsList").append(
        '<option value="' +
        favRoomObj._id +
        '">' +
        favRoomObj.user +
        ": " +
        favRoomObj.room +
        "</option>"
      );
      chatSettings.favRooms.push(favRoomObj);
    }
    if (chatSettings.favRooms.length >= MAX_ROOMS) {
      $("#favAdd").text("Favorites Full");
      $("#favAdd")[0].disabled = true;
    }
  }

  /**
   * Removes an entry to the Favorite Chat Rooms list
   */
  function removeFavoriteRoom() {
    let favItem = document.getElementById("favRoomsList");
    let favItemId = $("#favRoomsList option:selected").val();
    favItem.remove(favItem.selectedIndex);
    for (let idx = 0; idx < chatSettings.favRooms.length; idx++) {
      if (chatSettings.favRooms[idx]._id == favItemId) {
        chatSettings.favRooms.splice(idx, 1);
        break;
      }
    }
    if (chatSettings.favRooms.length < 10) {
      $("#favAdd").text("Add");
      $("#favAdd")[0].disabled = false;
    }
  }

  /**
   * Save current settings
   */
  function saveSettings() {
    settingsModule.saveSettings(localStorageName, chatSettings);
  }
  /**
   * Loads settings from local storage
   * @param {object} storedSettings Object containing the settings
   */
  function loadSettings() {
    let storedSettings = settingsModule.getSettings(localStorageName);
    chatRoomLogs = settingsModule.getSettings(chatLogsStorageName);
    if (chatRoomLogs === null) {
      chatRoomLogs = {};
    }
    chatSettings = {
      snapRoomList: true,
      colorStylizing: 1,
      unreadMarkerSelection: 1,
      msgPadding: false,
      showCommandWindow: false,
      enableTabSwitch: false,
      removeHighlighting: false,
      enableImagePreview: false,

      enablePings: true,
      pingNotify: false,
      selfPing: false,
      triggers: [],
      audioUrl: "https://www.rphaven.com/sounds/boop.mp3",
      color: "#000",
      highlight: "#FFA",
      bold: false,
      italics: false,
      exact: false,
      case: false,

      joinFavorites: false,
      trackSession: false,
      favRooms: [],
      session: [],
    };
    if (storedSettings) {
      chatSettings = Object.assign(chatSettings, storedSettings);
    }

    $("#snapRoomListEnable").prop("checked", chatSettings.snapRoomList);
    $(`#chatColorSelection option[value='${chatSettings.colorStylizing}']`).prop("selected", true);
    $(`#unreadMarkerSelection option[value='${chatSettings.unreadMarkerSelection}']`).prop(
      "selected",
      true
    );
    $("#chatmsgPaddingEnable").prop("checked", chatSettings.msgPadding);
    $("#showCommandWindowEnable").prop("checked", chatSettings.showCommandWindow);
    $("#removeHighlightingEnable").prop("checked", chatSettings.removeHighlighting);
    $("#enableTabSwitch").prop("checked", chatSettings.enableTabSwitch);
    $("#enableImagePreview").prop("checked", chatSettings.enableImagePreview);

    $("#notifyPingEnable").prop("checked", chatSettings.enablePings);
    $("#selfPingEnable").prop("checked", chatSettings.selfPing);
    $("#notifyNotificationEnable").prop("checked", chatSettings.pingNotify);
    $("#pingNames").val(chatSettings.triggers);
    $("#pingURL").val(chatSettings.audioUrl);
    $("#pingTextColor").val(chatSettings.color);
    $("#pingHighlightColor").val(chatSettings.highlight);
    $("input#pingBoldEnable").prop("checked", chatSettings.bold);
    $("input#pingItalicsEnable").prop("checked", chatSettings.italics);
    $("input#pingExactMatch").prop("checked", chatSettings.exact);
    $("input#pingCaseSense").prop("checked", chatSettings.case);

    $("#trackSession").prop("checked", chatSettings.trackSession);
    $("#joinFavEnable").prop("checked", chatSettings.joinFavorites);
    for (let i = 0; i < chatSettings.favRooms.length; i++) {
      let favRoomObj = chatSettings.favRooms[i];
      $("#favRoomsList").append(
        '<option value="' +
        favRoomObj._id +
        '">' +
        favRoomObj.user +
        ": " +
        favRoomObj.room +
        "</option>"
      );
    }

    if (chatSettings.enableTabSwitch === true) {
      $(document).on("keydown", changeTab);
    }
    else {
      $(document).off("keydown", changeTab);
    }

    generateHighlightStyle();
    rph.sounds.notify = new Audio(chatSettings.audioUrl);
  }

  function getHtml() {
    return html;
  }

  function toString() {
    return "Chat Module";
  }

  return {
    init: init,
    loadSettings: loadSettings,
    getHtml: getHtml,
    toString: toString,
  };
})();
/**
 * This module handles features for the PM system.
 */
let pmModule = (function () {
  let pmSettings = {};

  let localStorageName = "pmSettings";

  let html = {
    tabId: "pm-module",
    tabName: "PMs",
    tabContents: `<h3>PM Settings</h3><br>
      <h4>Appearance</h4>
      <div class="rpht-option-block">
        <div class="rpht-option-section"> 
          <label class="rpht-label checkbox-label" for="receivePmsEnable">Receive PMs</label>
          <label class="switch"><input type="checkbox" id="receivePmsEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Enables receiving of PMs (this does not persist between sessions)</label>
        </div>
        <div class="rpht-option-section"> 
          <label class="rpht-label checkbox-label" for="pmColorEnable">Use user text colors</label>
          <label class="switch"><input type="checkbox" id="pmColorEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Use the user\'s color to stylize their text</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="pmSideTabsEnable">Tabs on side</label> 
          <label class="switch"><input type="checkbox" id="pmSideTabsEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Puts the PM tabs on the side, listing them vertically. Requires page refresh for changes to take effect</label>
        </div>
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="keepInBgEnable">Keep window in the background</label> 
          <label class="switch"><input type="checkbox" id="keepInBgEnable"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Upon receiving a PM, prevents the window from showing if it's already closed.</label>
        </div>
        <div class="rpht-option-section option-section-bottom">
          <label class="rpht-label checkbox-label" for="pmEnableImagePreview">Enable image previews</label> 
          <label class="switch"><input type="checkbox" id="pmEnableImagePreview"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Links to images show up in chat. This may not work for some links.</label>
        </div>
      </div>
      <h4>Notifications</h4>
      <div class="rpht-option-block">
        <div class="rpht-option-section">
          <label class="rpht-label checkbox-label" for="pmNotify">Desktop notifications</label>
          <label class="switch"><input type="checkbox" id="pmNotify"><span class="rpht-slider round"></span></label>
          <label class="rpht-label descript-label">Pushes a desktop notification when you get a PM</label>
        </div>
        <div class="rpht-option-section option-section-bottom">
          <label class="rpht-label split-input-label">PM sound URL</label>
          <input class="split-input-label" type="text" id="pmPingURL" name="pmPingURL" style="margin-bottom: 12px;">
        </div>
      </div>
      <h4>Force removal</h4>
      <div class="rpht-option-block">
        <div class="rpht-option-section">
          <p><strong>Force unfriend</strong></p>
          <p>This forces unfriending of someone if they "deleted" their name to help clean up your friend list</p>
          <input type="text" class="rpht-long-input" id="unfriendYourNameInput"
            maxlength="300" placeholder="Your name"> <br><br>
          <input type="text" class="rpht-long-input" id="unfriendOtherNameInput"
            maxlength="300" placeholder="Friend's name"> <br><br>
          <button type="button" style="float:right; width:60px" id="forceUnfriendButton">Unfriend</button>
        </div>
        <div class="rpht-option-section option-section-bottom">
          <p><strong>Force unblock</strong></p>
          <p>This forces unblocking of someone if they "deleted" their name to help clean up your block list</p>
          <input type="text" class="rpht-long-input" id="forceUnblockInput"
            maxlength="300" placeholder="User to unblock..."> <br><br> 
          <button type="button" style="float:right; width:60px" id="forceUnblockButton">Unblock</button>
        </div>
      </div>
      <h4>Away message</h4>
      <div class="rpht-option-block">
        <div class="rpht-option-section option-section-bottom">
          <p>Usernames</p> <select style="width: 100%;" id="pmNamesDroplist" size="10"></select><br><br>
          <label><strong>Away Message </strong></label><input type="text" class="rpht-long-input" id="awayMessageTextbox"
            maxlength="300" placeholder="Away message..."> <br><br> <button type="button"
            style="float:right; width:60px" id="setAwayButton">Enable</button> <button type="button"
            style="float:right; margin-right: 20px; width:60px" id="removeAwayButton">Disable</button>
        </div>
      </div>`,
  };

  let awayMessages = {};

  let pmHandlers = [];

  function init() {
    loadSettings();

    $("#receivePmsEnable").change(() => {
      const receivePms = $("#receivePmsEnable").is(":checked");
      console.log("Receive PMs?", receivePms);
      if (receivePms) {
        pmHandlers.forEach((handler) => {
          socket.on("pm", handler);
        });
      }
      else {
        pmHandlers = socket.listeners("pm");
        socket.removeAllListeners("pm");
      }
    });

    $("#pmSideTabsEnable").change(() => {
      pmSettings.sideTabs = $("#pmSideTabsEnable").is(":checked");
      settingsModule.saveSettings(localStorageName, pmSettings);
    });

    $("#keepInBgEnable").change(() => {
      pmSettings.keepInBgEnable = $("#keepInBgEnable").is(":checked");
      settingsModule.saveSettings(localStorageName, pmSettings);
    });

    $("#pmEnableImagePreview").change(() => {
      pmSettings.pmEnableImagePreview = $("#pmEnableImagePreview").is(":checked");
      settingsModule.saveSettings(localStorageName, pmSettings);
    });

    $("#pmColorEnable").change(() => {
      pmSettings.colorText = $("#pmColorEnable").is(":checked");
      settingsModule.saveSettings(localStorageName, pmSettings);
    });

    $("#pmNotify").change(() => {
      pmSettings.notify = $("#pmNotify").is(":checked");
      settingsModule.saveSettings(localStorageName, pmSettings);
    });

    $("#pmNamesDroplist").change(() => {
      let userId = $("#pmNamesDroplist option:selected").val();
      let message = "";

      if (awayMessages[userId] !== undefined) {
        message = awayMessages[userId].message;
      }
      $("input#awayMessageTextbox").val(message);
    });

    $("#forceUnfriendButton").click(() => {
      forceUnfriend($("#unfriendYourNameInput").val(), $("#unfriendOtherNameInput").val());
    });

    $("#forceUnblockButton").click(() => {
      forceUnblock($("#forceUnblockInput").val());
    });

    $("#setAwayButton").click(() => {
      setPmAway();
    });

    $("#removeAwayButton").click(() => {
      removePmAway($("#pmNamesDroplist option:selected").val());
    });

    $("#pmPingURL").change(() => {
      if (validateSetting("#pmPingURL", "url")) {
        pmSettings.audioUrl = $("#pmPingURL").val();
        rph.sounds.im = new Audio(pmSettings.audioUrl);
        settingsModule.saveSettings(localStorageName, pmSettings);
      }
    });

    $("#pmNotify").change(() => {
      pmSettings.notify = $("#pmNotify").is(":checked");
      settingsModule.saveSettings(localStorageName, pmSettings);
    });
    $("#pm-msgs span").css("opacity", 0.85);

    socket.on("pm", handlePm);

    socket.on("pm-confirmation", handlePmConfirmation);

    socket.on("account-users", handleAccountUsers);
  }

  function handlePm(data) {
    if (account.ignores.indexOf(data.to) > -1) {
      return;
    }

    rph.getPm({
      from: data.from,
      to: data.to
    }, (pm) => {
      getUserByName(pm.to.props.name, (user) => {
        processPmMsg(user, data, pm);
      });

      if (pmSettings.notify) {
        displayNotification(`${pm.to.props.name} sent a PM to you for ${pm.from.props.name}`);
      }

      if (awayMessages[data.from] && awayMessages[data.from].enabled) {
        awayMessages[data.from].usedPmAwayMsg = true;
        socket.emit("pm", {
          from: data.from,
          to: data.to,
          msg: awayMessages[data.from].message,
          target: "all",
        });
      }

      if (pmSettings.keepInBackground && !$("#pm-dialog").parent().is(":visible")) {
        $("#pm-header").siblings("button").click();
      }
    });
  }

  async function handlePmConfirmation(data) {
    rph.getPm({
      from: data.to,
      to: data.from
    }, function (pm) {
      getUserByName(pm.from.props.name, (user) => {
        processPmMsg(user, data, pm);

        if (awayMessages[data.to] && awayMessages[data.to].enabled) {
          $("#pmNamesDroplist option")
            .filter(function () {
              return this.value == data.to;
            })
            .css("background-color", "")
            .html(user.props.name);
          awayMessages[data.to].enabled = false;
        }
      });
    });

    return Promise.resolve(true);
  }

  function handleAccountUsers() {
    setTimeout(() => {
      $("#pmNamesDroplist").empty();
      let namesToIds = getSortedNames();
      for (let name in namesToIds) {
        addToDroplist(namesToIds[name], name, "#pmNamesDroplist");
      }
    }, 3000);
  }

  function processPmMsg(user, data, pm) {
    let pmMsgQuery = pm.$msgs[0].childNodes[pm.$msgs[0].childNodes.length - 1];
    const classes = $(pmMsgQuery).attr("class").split(" ");
    if (classes[0] === "typing-notify") {
      pmMsgQuery = pm.$msgs[0].childNodes[pm.$msgs[0].childNodes.length - 2];
    }

    let nameQuery = $(pmMsgQuery.childNodes[1].childNodes[1]);
    let msgQuery = $(pmMsgQuery.childNodes[1].childNodes[2]);
    let pmCommand = parsePostCommand(data.msg);
    pmMsgQuery.childNodes[1].childNodes[0].innerHTML = createTimestamp(data.date);

    if (pmCommand.includes("rng")) {
      msgQuery[0].innerHTML = ` ${generateRngResult(
        pmCommand,
        data.msg,
        data.date
      )} <span style="background:#4A4; color: #FFF;"> &#9745; </span>`;
      nameQuery[0].innerHTML = `${user.props.name}`;
    }
    else if (pmCommand === "me") {
      nameQuery[0].innerHTML = `${user.props.name} `;
    }
    else {
      nameQuery.html(`&nbsp;${user.props.name}:&nbsp;`);
    }
    $(msgQuery).removeClass("action");

    nameQuery.attr("style", `color: #${user.props.color[0]}`);
    if (pmSettings.colorText) {
      msgQuery.attr("style", `color: #${user.props.color[0]}`);
    }

    if (pmSettings.pmEnableImagePreview) {
      let contentsChildren = msgQuery[0].children;
      let images = [];
      for (let i = 0; i < contentsChildren.length; i++) {
        const child = contentsChildren[i];
        if (child.tagName == "A") {
          const url = child.attributes["href"].nodeValue;
          if (url.search(`\.png|\.jpe?g|\.gif|\.webm`) > -1) {
            images.push(url);
          }
        }
      }
      msgQuery.find("div.rpht-images").remove();
      let imageArea = `<div class="rpht-images">`;
      images.forEach((url) => {
        imageArea += `<img src="${url}" width="320px" alt="${url}">&nbsp;`;
      });
      imageArea += `</div>`;
      msgQuery.append(imageArea);
    }
  }

  /**
   * Adds an away status to a character
   */
  function setPmAway() {
    let userId = $("#pmNamesDroplist option:selected").val();
    let name = $("#pmNamesDroplist option:selected").html();
    if (!awayMessages[userId]) {
      let awayMsgObj = {
        usedPmAwayMsg: false,
        message: "",
        enabled: false,
      };
      awayMessages[userId] = awayMsgObj;
    }

    if (!awayMessages[userId].enabled) {
      $("#pmNamesDroplist option:selected").html("[Away]" + name);
    }
    awayMessages[userId].enabled = true;
    awayMessages[userId].message = $("input#awayMessageTextbox").val();
    $("#pmNamesDroplist option:selected").css("background-color", "#FFD800");
    $("#pmNamesDroplist option:selected").prop("selected", false);

    console.log(
      "RPH Tools[setPmAway]: Setting away message for",
      name,
      "with message",
      awayMessages[userId].message
    );
  }

  /**
   * Removes an away status for a character
   */
  function removePmAway(userId) {
    if (!awayMessages[userId]) {
      return;
    }
    let name = $("#pmNamesDroplist option:selected").html();
    if (awayMessages[userId].enabled && name.startsWith("[Away]")) {
      awayMessages[userId].enabled = false;
      $("#pmNamesDroplist option:selected").html(name.substring(6, name.length));
      $("#pmNamesDroplist option:selected").css("background-color", "");
      $("input#awayMessageTextbox").val("");
      console.log("RPH Tools[removePmAway]: Remove away message for", name);
    }
  }

  /** Forces removal of a friend */
  async function forceUnfriend(yourName, friendName) {
    const yourUser = await getUserByName(yourName);
    const friendUser = await getUserByName(friendName);
    socket.emit("deny-friend-request", {
      from: yourUser.props.id,
      to: friendUser.props.id
    });
  }

  /** Forces removal of a blocked name */
  function forceUnblock(otherName) {
    getUserByName(otherName, (User) => socket.emit("unblock", {
      id: User.props.id
    }));
  }

  function loadSettings() {
    let storedSettings = settingsModule.getSettings(localStorageName);
    pmSettings = {
      colorText: false,
      keepInBackground: true,
      notify: false,
      audioUrl: "https://www.rphaven.com/sounds/imsound.mp3",
      sideTabs: false,
      pmEnableImagePreview: false,
      blockedUsers: [],
    };

    if (storedSettings) {
      pmSettings = Object.assign(pmSettings, storedSettings);
    }

    $("#receivePmsEnable").prop("checked", true);
    $("#pmColorEnable").prop("checked", pmSettings.colorText);
    $("#pmSideTabsEnable").prop("checked", pmSettings.sideTabs);
    $("#keepInBgEnable").prop("checked", pmSettings.keepInBgEnable);
    $("#pmEnableImagePreview").prop("checked", pmSettings.pmEnableImagePreview);
    $("#pmNotify").prop("checked", pmSettings.notify);
    $("#pmPingURL").val(pmSettings.audioUrl);
    rph.sounds.im = new Audio(pmSettings.audioUrl);

    if (pmSettings.sideTabs === true) {
      let pmTabs = $("div.ul-rows").detach();
      $("head").append(`<style>ul.pm-tabs li.tab{display: block; width: auto;}</style>`);
      $("#pm-dialog").css("display", "flex");
      $("#pm-dialog > div")[0].id = "pm-content";
      $("#pm-content").css("width", "75%");
      $("#pm-dialog").append(`<div id="pm-tabs" style="width: 25%; background: #303235; overflow: auto"></div>`);
      $("#pm-tabs").append(pmTabs);
    }
  }

  function getHtml() {
    return html;
  }

  function toString() {
    return "PM Module";
  }

  return {
    init: init,
    loadSettings: loadSettings,
    getHtml: getHtml,
    toString: toString,
  };
})();
/**
 * This module handles chat modding features. These include an easier way to
 * issue kicks, bans, promotions and demotions. It also can set up monitoring
 * of certain words and alert the mod.
 */
let moddingModule = (function () {
  let settings = {};

  let localStorageName = "modSettings";

  let html = {
    tabId: "modding-module",
    tabName: "Modding",
    tabContents: "<h3>Moderator Control</h3><br>" +
      "<h4>Shortcuts</h4>" +
      '<div class="rpht-option-block">' +
      "  <p><strong>Note:</strong> This must be done with the mods chat tab selected.</p>" +
      "  <p>General form: <code>/[action] [username],[reason]</code>. The reason is optional.</p>" +
      "  <p>Example: <code>/kick Alice,Being rude</code></p>" +
      "  <p>Supported actions: kick, ban, unban, add-mod, remove-mod, add-owner, remove-owner</p>" +
      "</div>" +
      "<h4>Mod commands</h4>" +
      '<div class="rpht-option-block">' +
      '  <div class="rpht-option-section">' +
      '    <label class="rpht-label split-input-label">Room-Name pair</label>' +
      '    <select class="split-input-label" id="roomModSelect"><option value="">&lt;Blank out fields&gt;</option></select><br /><br />' +
      '    <label class="rpht-label split-input-label">Room:</label><input class="split-input-label" type="text" id="modRoomTextInput" placeholder="Room"><br /><br />' +
      '    <label class="rpht-label split-input-label">Mod name:</label><input class="split-input-label" type="text" id="modFromTextInput" placeholder="Your mod name"><br /><br />' +
      '    <label class="rpht-label split-input-label">Reason Message:</label><input class="split-input-label" type="text" id="modMessageTextInput" placeholder="Message"><br /><br />' +
      "  </div>" +
      '  <div class="rpht-option-section option-section-bottom">' +
      "    <p>Perform action on these users (comma separated): </p>" +
      '    <textarea name="modTargetTextInput" id="modTargetTextInput" rows=2 class="rpht_textarea"></textarea>' +
      "    <br /><br />" +
      '    <table style="width: 600px;" cellpadding="2">' +
      "      <tbody>" +
      "        <tr>" +
      '          <td valign="top">' +
      '            <button style="width: 60px;" type="button" id="kickButton">Kick</button>' +
      "          </td>" +
      "          <td>" +
      '            <button style="width: 60px; margin-bottom: 8px;" type="button" id="banButton">Ban</button><br />' +
      '            <button style="width: 60px;" type="button" id="unbanButton">Unban</button>' +
      "          </td>" +
      "          <td>" +
      '            <button style="width: 60px; margin-bottom: 8px;" type="button" id="modButton">Mod</button><br>' +
      '            <button style="width: 60px;" type="button" id="unmodButton">Unmod</button>' +
      "          </td>" +
      "          <td>" +
      '            <button style="width: 80px; margin-bottom: 8px;" type="button" id="OwnButton">Owner</button><br>' +
      '            <button style="width: 80px;" type="button" id="UnownButton">Unowner</button>' +
      "          </td>" +
      "        </tr>" +
      "      </tbody>" +
      "    </table>" +
      "    <br><br>" +
      '    <button type="button" id="resetPwButton">Reset PW</button>' +
      "  </div>" +
      "</div>" +
      "<h4>Word Alert</h4>" +
      '<div class="rpht-option-block">' +
      '  <div class="rpht-option-section">' +
      '    <label class="rpht-label checkbox-label" for="wordAlertEnable">Enable word alerting</label>' +
      '    <label class="switch"><input type="checkbox" id="wordAlertEnable"><span class="rpht-slider round"></span></label>' +
      '    <label class="rpht-label descript-label">Highlights words that you want to be pinged on for moderation</label>' +
      "  </div>" +
      '  <div class="rpht-option-section option-section-bottom">' +
      "    <p><strong>Note:</strong> Separate all entries with a pipe character ( | ).</p>" +
      '    <textarea name="alertTriggers" id="alertTriggers" rows=4 class="rpht_textarea"></textarea>' +
      "  </div>" +
      "</div>",
  };

  let alertSound = null;

  let roomNamePairs = {};

  function init() {
    loadSettings();

    $("#roomModSelect").change(function () {
      let roomModeIdx = $("#roomModSelect")[0].selectedIndex;
      let roomModVal = $("#roomModSelect")[0].options[roomModeIdx].value;
      if (roomNamePairs[roomModVal]) {
        $("input#modRoomTextInput").val(roomNamePairs[roomModVal].roomName);
        $("input#modFromTextInput").val(roomNamePairs[roomModVal].modName);
      }
      else {
        $("input#modRoomTextInput").val("");
        $("input#modFromTextInput").val("");
      }
    });

    $("#resetPwButton").click(function () {
      let room = $("input#modRoomTextInput").val();

      getUserByName($("input#modFromTextInput").val(), function (user) {
        socket.emit("modify", {
          room: room,
          userid: user.props.id,
          props: {
            pw: false,
          },
        });
      });
    });

    $("#kickButton").click(function () {
      modAction("kick");
    });

    $("#banButton").click(function () {
      modAction("ban");
    });

    $("#unbanButton").click(function () {
      modAction("unban");
    });

    $("#modButton").click(function () {
      modAction("add-mod");
    });

    $("#unmodButton").click(function () {
      modAction("remove-mod");
    });

    $("#OwnButton").click(function () {
      modAction("add-owner");
    });

    $("#UnOwnButton").click(function () {
      modAction("remove-owner");
    });

    $("#wordAlertEnable").click(function () {
      settings.alertOnWords = $("#wordAlertEnable").is(":checked");
      settingsModule.saveSettings(localStorageName, settings);
    });

    $("#modAlertWords").blur(function () {
      settings.alertWords = $("#modAlertWords")
        .val()
        .replace(/\r?\n|\r/, "");
      settingsModule.saveSettings(localStorageName, settings);
    });

    $("#modAlertUrl").blur(function () {
      if (validateSetting("modAlertUrl", "url")) {
        settings.alertUrl = $("#modAlertUrl").val();
        settingsModule.saveSettings(localStorageName, settings);
        alertSound = new Audio(settings.alertUrl);
      }
    });

    $("#alertTriggers").blur(function () {
      settings.alertWords = $("#alertTriggers").val();
      settingsModule.saveSettings(localStorageName, settings);
    });
  }

  /**
   * Performs a modding action. This will look for a user's vanity name first, then act on that.
   * @param {string} action Name of the action being performed
   */
  function modAction(action) {
    let targets = $("#modTargetTextInput")
      .val()
      .replace(/\r?\n|\r/, "");
    let vanityMap = getVanityNamesToIds();
    targets = targets.split(",");
    console.log("RPH Tools[modAction]: Performing", action, "on", targets);
    targets.forEach(function (target) {
      if (vanityMap[target]) {
        target = messenger.users[vanityMap[target]].props.name;
      }
      emitModAction(
        action,
        target,
        $("input#modFromTextInput").val(),
        $("input#modRoomTextInput").val(),
        $("input#modMessageTextInput").val()
      );
    });
  }

  /**
   * Sends off the mod action to the chat socket
   * @param {string} action Name of the action being performed
   * @param {string} targetName User name of the recipient of the action
   */
  function emitModAction(action, targetName, modName, roomName, reasonMsg) {
    getUserByName(targetName, function (target) {
      getUserByName(modName, function (user) {
        let modMessage = "";
        if (action === "kick" || action === "ban" || action === "unban") {
          modMessage = reasonMsg;
        }
        socket.emit(action, {
          room: roomName,
          userid: user.props.id,
          targetid: target.props.id,
          msg: modMessage,
        });
      });
    });
  }

  function findUserAsMod(userObj) {
    Object.keys(rph.rooms).forEach((roomname) => {
      let roomObj = getRoom(roomname);
      if (
        roomObj.props.mods.indexOf(userObj.props.id) > -1 ||
        roomObj.props.owners.indexOf(userObj.props.id) > -1
      ) {
        addModRoomPair(userObj.props, roomname);
      }
    });
  }

  /**
   * Adds a key/value pair option to the Room-Name Pair droplist.
   * @param {number} userId User ID of the mod
   * @param {object} thisRoom Object containing the room data.
   */
  function addModRoomPair(userProps, roomName) {
    let roomNamePair = roomName + ": " + userProps.name;
    let roomNameValue = roomName + "." + userProps.id;
    let roomNameObj = {
      roomName: roomName,
      modName: userProps.name,
      modId: userProps.id,
    };

    if (roomNamePairs[roomNameValue] === undefined) {
      roomNamePairs[roomNameValue] = roomNameObj;
      $("#roomModSelect").append('<option value="' + roomNameValue + '">' + roomNamePair + "</option>");
    }
  }

  /**
   * Plays the alert sound
   */
  function playAlert() {
    alertSound.play();
  }

  function loadSettings() {
    settings = {
      alertOnWords: false,
      alertWords: "",
      alertUrl: "https://www.rphaven.com/sounds/boop.mp3",
    };
    let storedSettings = settingsModule.getSettings(localStorageName);

    if (storedSettings) {
      settings = Object.assign(settings, storedSettings);
    }

    $("#modAlertUrl").val(settings.alertUrl);
    $("#wordAlertEnable").prop("checked", settings.alertOnWords);
    $("#modAlertWords").val(settings.alertWords);
    alertSound = new Audio(settings.alertUrl);

    $("#alertTriggers").val(settings.alertWords);
  }

  function getAlertWords() {
    return settings.alertWords;
  }

  function getHtml() {
    return html;
  }

  function toString() {
    return "Modding Module";
  }

  return {
    init: init,
    emitModAction: emitModAction,
    findUserAsMod: findUserAsMod,
    addModRoomPair: addModRoomPair,
    playAlert: playAlert,
    loadSettings: loadSettings,
    getAlertWords: getAlertWords,
    getHtml: getHtml,
    toString: toString,
  };
})();
let logManagerModule = (function () {
  const INDEXED_DB_VERS = 20;
  let request;
  let logDb;
  let fileContent;
  let logDbDump = {};
  let logEntryDump = {};
  let logEntries = {};
  let idsToNames = {};
  let searchName = "";
  let exactSearch = false;

  let deleteTimer = null;
  let byUsername = false;

  const html = {
    tabId: "log-manager-module",
    tabName: "Log Manager",
    tabContents: `
        <h3>Log Manager</h3><br>
        <div id="log-import-container">
          <h4>Log Importer</h4><br />
          <input id="logFileInput" type="file" /> <button style="display: none;"
            id="retryImportButton">Retry</button><br /><br />
          <p id="importStatus"></p>
        </div>
        <div id="log-export-container">
          <h4>Log Exporter</h4><br />
          <p><strong>Search Options</strong></p><br />
          <p style="line-height: 2em;">
            <label class="rphlm-label rphlm-spacing">Date</label>
            <input type="date" id="startDateInput" name="startDate" style="min-width: 0px;">
             to 
            <input type="date" id="endDateInput" name="endDate" style="min-width: 0px;">
          </p>
          <p style="line-height: 2em;">
            <label class="rphlm-label rphlm-spacing" for="searchNameInput">Name search</label>
            <input style="width: 360px; min-width: initial;" id="searchNameInput" type="text">
          </p>
          <p style="line-height: 2em;">
            <label class="rphlm-label rphlm-spacing" for="exactSearchCheckbox">Exact name search</label>
            <input id="exactSearchCheckbox" type="checkbox">
          </p>
          <p style="line-height: 2em;">
            <label class="rphlm-label rphlm-spacing" for="reverseNamesCheckbox">Select by your name first</label>
            <input id="reverseNamesCheckbox" type="checkbox">
          </p>
          <p style="line-height: 2em;">
            <label class="rphlm-label rphlm-spacing"></label>
            <button id="getLogsButton">Get logs</button>
          </p>
          <hr>
          <div id="logEntriesContainer" style="display: none">
            <div id="downloadLinks">
              <p><strong>Log Management</strong></p><br />
              <p>Download:
                <a id="downloadPlainTextLink">Download log as plaintext</a> |
                <a id="downloadJsonLink">Export log for importing</a> |
                <a id="downloadAllLink">Download all logs</a>
              </p>
              <br>
              <p>
                Delete:
                <button id="deleteButton" style="background:red">Delete this log</button> |
                <button id="deleteFromNameButton" style="background:red">Delete logs from...</button>
              </p>
            </div>
            <hr style="margin-top: 20px;" />
            <p><strong>View log</strong></p><br />
            <label id="logFirstName" class="rphlm-label rphlm-spacing">Others name </label>
              <select class="rphlm-spacing" id="nameOneDropList"></select>
            <a id="yourProfileLink" style="margin-left: 10px; display: none;" target="_blank">See profile</a><br /><br />
            <label id="logSecondName" class="rphlm-label rphlm-spacing">Your name </label>
              <select class="rphlm-spacing" id="nameTwoDropList"></select>
            <a id="otherProfileLink" style="margin-left: 10px; display: none;" target="_blank">See profile</a><br /><br />
            <div class="rphlm-logContent" id="log-contents"></div>
          </div>
        </div>`,
  };

  function init() {
    const rphLogManagerCss = `<style>
    .rphlm-label {padding-left: 0px; text-align:justify; display:inline-block; cursor:default;}
    .rphlm-spacing {width: 240px;}
    .rphlm-logContent {border:#888 solid 1px;border-radius:10px padding-bottom:12px;margin-bottom:12px; width: 100%; height: 720px; overflow: auto;}
    .dropdownContainer {display:inline-block; min-width: 280px; max-width: 280px;}
    .dropdownOptions { max-height: 320px; overflow-y: auto; position: absolute; width: 230px; display: none; background: #f6f6f6;}
    .dropdownOptions > a {padding: 12px 16px; text-decoration: none; display: block;}
    </style>
  `;
    $("head").append(rphLogManagerCss);

    $("#logFileInput").change(handleFileInput);
    $("#retryImportButton").click(() => {
      $("#retryImportButton").hide();
      loadLogFile(fileContent);
    });
    $("#getLogsButton").click(getLogs);
    $("#nameOneDropList").change(updateDropdownLists);
    $("#nameTwoDropList").change(() => {
      const otherName = $("#nameTwoDropList option:selected").val();
      $("#otherProfileLink").attr("href", `https://profiles.rphaven.com/${otherName}`);
      fillInLogContents();
    });
    $("#deleteButton").click(() => {
      handleDelete("#deleteButton", "Delete logs from...", deleteLog);
    });
    $("#deleteFromNameButton").click(() => {
      handleDelete("#deleteFromNameButton", "Delete this log", deleteLogsByName);
    });

    socket.on("account-users", createLogDatabase);
  }

  /** UI related functions *****************************************************/
  function handleFileInput() {
    let file = $("#logFileInput")[0].files[0];
    (async () => {
      fileContent = await file.text();
      loadLogFile(fileContent);
    })();
  }

  function handleDelete(elementId, defaultText, deleteFunction) {
    if (typeof deleteFunction !== "function") {
      return;
    }

    if (deleteTimer === null) {
      $(elementId).html("Press again to delete...");
      deleteTimer = setTimeout(() => {
        deleteTimer = null;
        $(elementId).html(defaultText);
      }, 5000);
    }
    else {
      deleteFunction().then(() => {
        return 1;
      });
      clearTimeout(deleteTimer);
      deleteTimer = null;
    }
  }

  function fillInLogContents() {
    const username = $("#nameOneDropList option:selected").val();
    const otherName = $("#nameTwoDropList option:selected").val();
    const entry = logEntries[username][otherName];

    $("#log-contents").empty();
    for (let timestamp in entry) {
      $("#log-contents").append(
        `<p>${createTimestamp(parseInt(timestamp))} ${entry[timestamp].author}: ${entry[timestamp].msg}</p>`
      );
      logEntryDump[entry[timestamp].dBkey] = logDbDump[entry[timestamp].dBkey];
    }

    $("#downloadPlainTextLink").attr(
      "href",
      makeTextFile($("#log-contents").html().replace(/<p>/g, "").replace(/<\/p>/g, "\n"))
    );
    $("#downloadPlainTextLink").attr("download", `${username}-${otherName}-log.txt`);

    $("#downloadJsonLink").attr("href", makeTextFile(JSON.stringify(logEntryDump, null, 4)));
    $("#downloadJsonLink").attr("download", `${username}-${otherName}-log.json`);
    $("#deleteFromNameButton").text(`Delete logs from ${username}`);
  }

  function updateDropdownLists() {
    const username = $("#nameOneDropList option:selected").val();
    const otherNames = Object.keys(logEntries[username]).sort();
    $("#nameTwoDropList").empty();

    otherNames.forEach((name) => {
      addToDroplist(name, name, "#nameTwoDropList");
    });

    const otherName = $("#nameTwoDropList option:selected").val();
    $("#yourProfileLink").attr("href", `https://profiles.rphaven.com/${username}`);
    $("#otherProfileLink").attr("href", `https://profiles.rphaven.com/${otherName}`);
    $("#yourProfileLink").show();
    $("#otherProfileLink").show();
    fillInLogContents();
  }

  function refreshNameDropLists() {
    $("#nameOneDropList").empty();
    const names = Object.keys(logEntries).sort();
    names.forEach((name) => {
      addToDroplist(name, name, "#nameOneDropList");
    });

    updateDropdownLists();
  }

  function addToDroplist(value, label, droplist) {
    let droplist_elem = $(droplist);
    droplist_elem.append(
      $("<option>", {
        value: value,
        text: label,
      })
    );
  }

  const toggleableElements = [
    "#getLogsButton",
    "#searchNameInput",
    "#exactSearchCheckbox",
    "#reverseNamesCheckbox",
    "#nameOneDropList",
    "#nameTwoDropList",
    "#deleteButton",
    "#deleteFromNameButton",
  ];

  function disableControls() {
    toggleableElements.forEach((element) => {
      $(element).prop("disabled", true);
    });

    $("#downloadPlainTextLink").removeAttr("href download");
    $("#downloadJsonLink").removeAttr("href download");
    $("#downloadAllLink").removeAttr("href download");
  }

  function enableControls() {
    toggleableElements.forEach((element) => {
      $(element).prop("disabled", false);
    });
  }

  /* Functions related to database manipulation ********************************/
  function createLogDatabase() {
    // If this database was not created, create it.
    request = indexedDB.open(`${account.props.accid}`, INDEXED_DB_VERS);
    request.onupgradeneeded = function (event) {
      logDb = event.target.result;
      logDb.onerror = function (event) {
        console.log(event);
      };
      let newObjectStore = logDb.createObjectStore("msgs", {
        keyPath: [
          ["date", "fromid", "userid"], "userid", ["userid", "otherid"],
          ["fromid", "date"], "date"
        ],
      });

      newObjectStore.transaction.oncomplete = () => {
        logDb.close();
      };
    };
  }

  function getLogs() {
    logEntries = {};
    logDbDump = {};
    byUsername = $("#reverseNamesCheckbox").is(":checked");
    searchName = $("input#searchNameInput").val();

    if ($("#exactSearchCheckbox").is(":checked") == true) {
      getUserByName(searchName)
        .then(() => {
          startSearch();
        })
        .catch(() => {
          $("input#searchNameInput").css("background", "#FF7F7F");
        });
    }
    else {
      startSearch();
    }
  }

  function startSearch() {
    $("input#searchNameInput").css("background", "");
    $("#log-contents").empty();
    $("#nameTwoDropList").empty();
    $("#nameOneDropList").empty();
    $("#logEntriesContainer").show();

    if (byUsername == true) {
      $(`label#logFirstName`).first().text("Your name");
      $(`label#logSecondName`).first().text("Other's name");
    }
    else {
      $(`label#logFirstName`).first().text("Other's name");
      $(`label#logSecondName`).first().text("Your name");
    }

    request = indexedDB.open(`${account.props.accid}`, INDEXED_DB_VERS);
    request.onsuccess = function (event) {
      logDb = event.target.result;
      logDb.transaction(["msgs"]).objectStore("msgs").openCursor().onsuccess = processLogEntry;
    };
  }

  function processLogEntry(event) {
    const startTime = isNaN($("#startDateInput")[0].valueAsNumber) ? 0 : $("#startDateInput")[0].valueAsNumber;
    const endTime = isNaN($("#endDateInput")[0].valueAsNumber) ? Date.now() : $("#endDateInput")[0].valueAsNumber;
    let cursor = event.target.result;
    $("#getLogsButton").html("Getting logs...");
    disableControls();

    if (!cursor || (cursor && cursor.value.date > endTime)) {
      let link = $("#downloadAllLink");
      link.attr("href", makeTextFile(`${JSON.stringify(logDbDump, null, 4)}`));
      link.attr("download", `${account.props.accid}-all-logs.txt`);

      setTimeout(() => {
        $("#getLogsButton").html("Get logs");
        enableControls();

        if (Object.keys(logEntries).length > 0) {
          refreshNameDropLists();
        }
      }, 100);

      return;
    }
    let logEntry = cursor.value;
    let key = cursor.key.join();

    if (((Math.log(logEntry.date) * Math.LOG10E + 1) | 0) < 11) {
      logEntry.date *= 1000;
    }

    if (startTime > logEntry.date) {
      cursor.continue();
    }
    else {
      logDbDump[key] = cursor.value;

      if (logEntry.otherid in idsToNames && logEntry.fromid in idsToNames && logEntry.userid in idsToNames) {
        logEntry.other_name = idsToNames[logEntry.otherid];
        logEntry.from_name = idsToNames[logEntry.fromid];
        logEntry.user_name = idsToNames[logEntry.userid];
        addLogEntry(logEntry);
      }
      else {
        getUserById(logEntry.otherid)
          .then((user) => {
            logEntry.other_name = user.props.name;
            idsToNames[user.props.id] = user.props.name;
            return getUserById(logEntry.fromid);
          })
          .then((user) => {
            logEntry.from_name = user.props.name;
            idsToNames[user.props.id] = user.props.name;
            return getUserById(logEntry.userid);
          })
          .then((user) => {
            logEntry.user_name = user.props.name;
            idsToNames[user.props.id] = user.props.name;
            addLogEntry(logEntry, key);
            return Promise.resolve();
          });
      }

      cursor.continue();
    }
  }

  function addLogEntry(logEntry, key) {
    const username = $("#reverseNamesCheckbox").is(":checked") ? logEntry.user_name : logEntry.other_name;
    const otherName = $("#reverseNamesCheckbox").is(":checked") ? logEntry.other_name : logEntry.user_name;

    if (searchName.length > 0) {
      const reTerm = new RegExp(searchName, exactSearch ? `i` : ``);
      if (username.search(reTerm) == -1 && otherName.search(reTerm) == -1) {
        return;
      }
    }

    if (username in logEntries === false) {
      logEntries[username] = {};

      /* Sort names as they come in */
      let options = $("#nameOneDropList option");
      let arr = options
        .map(function (_, o) {
          return {
            t: $(o).text(),
            v: o.value,
          };
        })
        .get();
      arr.sort(function (o1, o2) {
        return o1.t > o2.t ? 1 : o1.t < o2.t ? -1 : 0;
      });
      options.each(function (i, o) {
        o.value = arr[i].v;
        $(o).text(arr[i].t);
      });
    }
    if (otherName in logEntries[username] === false) {
      logEntries[username][otherName] = {};
    }

    logEntries[username][otherName][logEntry.date] = {
      author: logEntry.from_name,
      msg: logEntry.msg,
      dBkey: key,
    };
  }

  function loadLogFile(jsonString) {
    $("#importStatus").text("Importing log...");
    try {
      request = indexedDB.open(`${account.props.accid}`, INDEXED_DB_VERS);
      request.onsuccess = function (event) {
        logDb = event.target.result;
        processLogFile(JSON.parse(jsonString));
      };
    }
    catch (e) {
      $("#importStatus").text("Error importing log");
      $("#retryImportButton").show();
      console.log(e);
    }
  }

  function processLogFile(jsonBlob) {
    let tx = logDb.transaction("msgs", "readwrite");
    let store = tx.objectStore("msgs");
    for (let key in jsonBlob) {
      let keypath = key.split(",");
      for (let i = 0; i < 3; i++) {
        keypath[i] = parseInt(keypath[i]);
      }

      store.put({
        id: key,
        date: jsonBlob[key].date,
        fromid: jsonBlob[key].fromid,
        userid: jsonBlob[key].userid,
        otherid: jsonBlob[key].otherid,
        msg: jsonBlob[key].msg,
      });
    }

    /* Remove the ID key in each log to conform with how RPH stores logs */
    tx = logDb.transaction("msgs", "readwrite");
    store = tx.objectStore("msgs").openCursor(null, "prev").onsuccess = function (event) {
      var cursor = event.target.result;
      if (cursor && "id" in cursor.value) {
        delete cursor.value.id;
        cursor.update(cursor.value);
        cursor.continue();
      }
    };

    tx.oncomplete = () => {
      $("#importStatus").text("Importing done!");
    };
  }

  async function deleteLog() {
    let otherUser = await getUserByName($("#nameOneDropList option:selected").val());
    let acctUser = await getUserByName($("#nameTwoDropList option:selected").val());

    if (byUsername == true) {
      acctUser = await getUserByName($("#nameOneDropList option:selected").val());
      otherUser = await getUserByName($("#nameTwoDropList option:selected").val());
    }

    $("#deleteButton").html("Deleting logs...");
    disableControls();

    let tx = logDb.transaction("msgs", "readwrite");
    let store = (tx.objectStore("msgs").openCursor(null, "prev").onsuccess = function (event) {
      var cursor = event.target.result;
      if (!cursor) {
        let primaryKey = byUsername === true ? acctUser.props.name : otherUser.props.name;
        let secondaryKey = byUsername === true ? otherUser.props.name : acctUser.props.name;
        delete logEntries[primaryKey][secondaryKey];
        if (Object.keys(logEntries[primaryKey]).length === 0) {
          delete logEntries[primaryKey];
        }
        refreshNameDropLists();
        enableControls();
        $("#deleteButton").html("Delete this log");

        if (primaryKey in logEntries) {
          $(`#nameOneDropList`).val(primaryKey);
          updateDropdownLists();
        }
        return;
      }
      else if (cursor.value.userid == acctUser.props.id && cursor.value.otherid == otherUser.props.id) {
        cursor.delete();
      }
      cursor.continue();
    });
  }

  async function deleteLogsByName() {
    let userData = await getUserByName($("#nameOneDropList option:selected").val());

    $("#deleteFromNameButton").html("Deleting logs...");
    disableControls();

    let tx = logDb.transaction("msgs", "readwrite");
    let store = (tx.objectStore("msgs").openCursor(null, "prev").onsuccess = function (event) {
      var cursor = event.target.result;
      if (!cursor) {
        $("#deleteFromNameButton").html("Delete logs from this name");
        delete logEntries[userData.props.name];
        enableControls();
        refreshNameDropLists();
        return;
      }
      else if (cursor.value.otherid == userData.props.id) {
        cursor.delete();
      }
      cursor.continue();
    });
  }

  /** Utility functions ********************************************************/
  function createTimestamp(time) {
    const timestamp = new Date(time);
    const dateString = timestamp.toLocaleDateString(navigator.language);
    const timeString = timestamp.toTimeString().substring(0, 5);
    return `${dateString} ${timeString}`;
  }

  function makeTextFile(text) {
    let textFile = null;
    let data = new Blob([text], {
      type: "text/plain",
    });

    // If we are replacing a previously generated file we need to
    // manually revoke the object URL to avoid memory leaks.
    if (textFile !== null) {
      window.URL.revokeObjectURL(textFile);
    }

    textFile = window.URL.createObjectURL(data);

    return textFile;
  }

  function getHtml() {
    return html;
  }

  function toString() {
    return "Log Manager Module";
  }

  return {
    init: init,
    getHtml: getHtml,
    toString: toString,
  };
})();
/**
 * Handles importing, exporting, and deleting of settings.
 */
let settingsModule = (function () {
  let html = {
    tabId: "settings-module",
    tabName: "Settings",
    tabContents: "<h3>Script Settings</h3><br>" +
      "<h4>Import/Export settings</h4>" +
      '<div class="rpht-option-block">' +
      '  <div class="rpht-option-section">' +
      '    <label class="rpht-label split-input-label">Export settings to a JSON text file</label>' +
      `    <a class="split-input-label" id="downloadSettingsLink" download="settings.txt">Download settings</a>` +
      "  </div>" +
      '  <div class="rpht-option-section">' +
      '    <label class="rpht-label split-input-label">Import settings from a JSON text file</label>' +
      '    <input class="split-input-label" id="importFileInput" type="file" /> <button style="display: none;" id="retryImportButton">Retry</button>' +
      '    <p id="importSettingsStatus"></p>' +
      "  </div>" +
      '  <div class="rpht-option-section option-section-bottom">' +
      '    <label class="rpht-label checkbox-label">Import/export settings from text</label>' +
      '    <textarea name="importExportText" id="importExportTextarea" rows=10 class="rpht_textarea"></textarea>' +
      "    <br /><br />" +
      '    <button type="button" style="width: 60px;" id="exportButton">Export</button>' +
      '    <button type="button" style="margin-left: 10px; width: 60px;" id="importButton">Import</button>' +
      '    <button type="button" style="float: right; background: red;" id="deleteSettingsButton">Delete settings</button>' +
      "  </div>" +
      "</div>",
  };

  let confirmDelete = false;

  let deleteTimer = null;

  /**
   * Initializes the GUI components of the module.
   */
  function init() {
    if (!localStorage.getItem(SETTINGS_NAME)) {
      localStorage.setItem(SETTINGS_NAME, JSON.stringify({}));
    }

    $("#importButton").click(() => {
      let importSuccess = importSettingsHanlder($("textarea#importExportTextarea").val());
      if (importSuccess) {
        markProblem("textarea#importExportTextarea", false);
      }
      else {
        markProblem("textarea#importExportTextarea", true);
      }
    });

    $("#exportButton").click(() => {
      $("textarea#importExportTextarea").val(exportSettings());
    });

    $("#downloadSettingsLink").click(() => {
      let link = document.getElementById("downloadSettingsLink");
      link.href = makeTextFile(localStorage.getItem(SETTINGS_NAME));
      $("#downloadSettingsLink").attr("download", `rph-tools-settings.txt`);
    });

    $("#importFileInput").change(() => {
      let file = $("#importFileInput")[0].files[0];
      (async () => {
        fileContent = await file.text();
        let successfulImport = importSettingsHanlder(fileContent);

        if (successfulImport === false) {
          $("#importSettingsStatus").first().text("There was a problem with the import");
        }
        else {
          $("#importSettingsStatus").first().text("Import successful");
        }
      })();
    });

    $("#printSettingsButton").click(() => {
      printSettings();
    });

    $("#deleteSettingsButton").click(() => {
      deleteSettingsHanlder();
    });
  }

  /**
   * Handles the initial portion of importing settings. This checks the input
   * to see if it's a valid JSON formatted string.
   */
  function importSettingsHanlder(jsonText) {
    let successfulImport = false;
    try {
      let newSettings = JSON.parse(jsonText);
      localStorage.setItem(SETTINGS_NAME, JSON.stringify(newSettings));
      rphToolsModule.getAllModules().forEach((module) => {
        if (module.loadSettings) {
          module.loadSettings();
        }
      });
      successfulImport = true;
    }
    catch {
      console.log("[RPHT.Settings]: There was a problem with importing settings");
    }
    return successfulImport;
  }

  /**
   * Exports settings into a JSON formatted string
   */
  function exportSettings() {
    const settings = JSON.parse(localStorage.getItem(SETTINGS_NAME));
    delete settings.chatLogs;
    markProblem("textarea#importExportTextarea", false);
    return JSON.stringify(settings, "\n", 4);
  }

  /**
   * Logic to confirm deleting settings. The button needs to be pressed twice
   * within 10 seconds for the settings to be deleted.
   */
  function deleteSettingsHanlder() {
    if (confirmDelete === false) {
      $("#deleteSettingsButton").text("Press again to delete");
      confirmDelete = true;

      /* Set a timeout to make "confirmDelete" false automatically */
      deleteTimer = setTimeout(() => {
        confirmDelete = false;
        $("#deleteSettingsButton").text("Delete Settings");
      }, 10 * 1000);
    }
    else if (confirmDelete === true) {
      clearTimeout(deleteTimer);
      console.log("RPH Tools[Settings Module]: Deleting settings");
      $("#deleteSettingsButton").text("Delete Settings");
      confirmDelete = false;
      localStorage.removeItem(SETTINGS_NAME);
      localStorage.setItem(SETTINGS_NAME, JSON.stringify({}));
      rphToolsModule.getAllModules().forEach((module) => {
        if (module.loadSettings) {
          console.log(`RPH Tools[Settings Module]: ${module.toString()}`);
          module.loadSettings();
        }
      });
    }
  }

  function makeTextFile(text) {
    let textFile = null;
    let data = new Blob([text], {
      type: "text/plain"
    });

    // If we are replacing a previously generated file we need to
    // manually revoke the object URL to avoid memory leaks.
    if (textFile !== null) {
      window.URL.revokeObjectURL(textFile);
    }

    textFile = window.URL.createObjectURL(data);

    return textFile;
  }

  function saveSettings(moduleName, moduleSettings) {
    let settings = JSON.parse(localStorage.getItem(SETTINGS_NAME));
    settings[moduleName] = {};
    settings[moduleName] = moduleSettings;
    localStorage.setItem(SETTINGS_NAME, JSON.stringify(settings));
  }

  function getSettings(moduleName) {
    let settings = JSON.parse(localStorage.getItem(SETTINGS_NAME));
    let moduleSettings = null;
    if (settings[moduleName]) {
      moduleSettings = settings[moduleName];
    }
    return moduleSettings;
  }

  function getHtml() {
    return html;
  }

  function toString() {
    return "Settings Module";
  }

  /**
   * Public members of the module
   */
  return {
    init: init,
    saveSettings: saveSettings,
    getSettings: getSettings,
    getHtml: getHtml,
    toString: toString,
  };
})();
/**
 * This module handles the "About" section for information on RPH Tools.
 */
let aboutModule = (function () {
  let html = {
    tabId: "about-module",
    tabName: "About",
    tabContents: "<h3>RPH Tools</h3><br>" +
      "<p><strong>Version: " +
      VERSION_STRING +
      "</strong>" +
      ' | <a href="https://openuserjs.org/install/shuffyiosys/RPH_Tools.user.js" target="_blank">Install the latest version</a>' +
      ' | <a href="https://github.com/shuffyiosys/rph-tools/blob/master/CHANGELOG.md" target="_blank">Version history</a>' +
      ' | <a href="https://discord.gg/HBEaGjs" target="_blank">Discord channel</a>' +
      ' | <a href="https://openuserjs.org/scripts/shuffyiosys/RPH_Tools" target="_blank">OpenUserJs page</a>' +
      "</p></br>" +
      '<p>Created by shuffyiosys. Under MIT License (SPDX: MIT). Feel free to make contributions to <a href="https://github.com/shuffyiosys/rph-tools" target="_blank">the repo</a>!</p><br />' +
      '<p><a href="https://github.com/shuffyiosys/rph-tools/blob/master/docs/quick-guide.md" target="_blank">Quick guide to using RPH Tools</a></p></br>',
  };

  function init() {
    return;
  }

  function getHtml() {
    return html;
  }

  function toString() {
    return "About Module";
  }

  return {
    init: init,
    getHtml: getHtml,
    toString: toString,
  };
})();
/**
 * Main RPH Tools module
 */
let rphToolsModule = (function () {
  let modules = [];

  let rpht_css =
    "<style>" +
    "#settings-dialog .inner > div > div.rpht-option-block{width:640px;border:#888 solid 1px;border-radius:10px;padding:12px;padding-top:16px;padding-bottom:16px;margin-bottom:16px;}" +
    ".rpht-option-section{border-bottom:#444 solid 1px;padding-bottom:12px;margin-bottom:12px;}" +
    ".option-section-bottom{border-bottom:none;margin-bottom:0;}" +
    ".rpht-label{padding-left: 0px;text-align:justify;display:inline-block;cursor:default;}" +
    ".checkbox-label{font-weight:700;width:542px;cursor:pointer;}" +
    ".descript-label{width:480px;margin-top:8px;}" +
    ".text-input-label{width:400px;}" +
    ".split-input-label {width: 300px;}" +
    ".rpht_textarea{border:1px solid #000;width:611px;padding:2px;background:#e6e3df;}" +
    ".rpht_chat_tab{height:54px;overflow-x:auto;overflow-y:hidden;white-space:nowrap;}" +
    ".rpht-checkbox{height:16px;width:16px;}" +
    "input.rpht-short-input{width:200px;}" +
    "input.rpht-long-input{max-width:100%;}" +
    ".msg-padding{line-height: 1.25em}" +
    ".switch{position:relative;right:12px;width:50px;height:24px;float:right;}" +
    ".switch input{opacity:0;width:0;height:0;}" +
    ".rpht-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background-color:#ccc;-webkit-transition:.4s;transition:.4s}" +
    '.rpht-slider:before{position:absolute;content:"";height:16px;width:16px;left:4px;bottom:4px;background-color:#fff;-webkit-transition:.4s;transition:.4s}' +
    "input:checked+.rpht-slider{background-color:#2196f3}" +
    "input:focus+.rpht-slider{box-shadow:0 0 1px #2196f3}" +
    "input:checked+.rpht-slider:before{transform:translateX(26px)}" +
    ".rpht-slider.round{border-radius:34px}" +
    ".rpht-slider.round:before{border-radius:50%}" +
    ".rpht-tooltip-common{position: absolute; bottom: 120px; left: 200px; width: auto; height: auto; color: #dedbd9; background: #303235; opacity: 0.9; padding: 10px;}" +
    ".rpht-cmd-tooltip{width: 800px; height: auto;}" +
    ".rpht-cmd-tooltip:hover{opacity: 0;}" +
    ".rpht-die-label{text-align: right; display: inline-block; width: 74px; margin-right: 7px;}" +
    ".rpht-die-updown{width: 60px; min-width: 0px;}" +
    ".rpht-close-btn{margin-left: 40px; width: 24px; cursor: pointer;}" +
    ".rpht-close-btn:hover{background: #CA7169;}" +
    "#diceRollerPopup button{width: 146px;}" +
    "</style>";

  /**
   * Initializes the modules and the HTML elements it handles.
   * @param {Array} addonModules Modules to add into the system.
   */
  function init(addonModules) {
    let $settingsDialog = $("#settings-dialog");
    modules = addonModules;

    if (Notification.permission !== "denied") {
      Notification.requestPermission();
    }

    $("head").append(rpht_css);
    $("#settings-dialog .inner ul.tabs").append("<h3>RPH Tools</h3>");

    /* Checks to see if there's a local store for settings and creates one
     * if there isn't. */
    let settings = localStorage.getItem(SETTINGS_NAME);
    if (!settings) {
      settings = {};
      localStorage.setItem(SETTINGS_NAME, JSON.stringify(settings));
    }

    modules.forEach(function (module) {
      if (module.getHtml) {
        html = module.getHtml();
        $("#settings-dialog .inner ul.tabs").append(
          '<li><a href="#' + html.tabId + '">' + html.tabName + "</a></li>"
        );
        $("#settings-dialog .inner div.content div.inner").append(
          '<div id="' + html.tabId + '" style="display: none;">' + html.tabContents + "</div>"
        );

        $settingsDialog.find('.tabs a[href="#' + html.tabId + '"]').click(function (ev) {
          $settingsDialog.find(".content .inner > div").hide();
          $settingsDialog.find($(this).attr("href")).show();
          ev.preventDefault();
        });

        module.init();
      }
    });
  }

  /**
   * Returns a module based on a name passed in.
   * @param {string} name Name of the module to get the data
   * @returns Returns the module, if found. Otherwise returns null.
   */
  let getModule = function (name) {
    let module = null;
    for (let i = 0; i < modules.length; i++) {
      if (modules[i].toString() === name) {
        module = modules[i];
        break;
      }
    }
    return module;
  };

  function getAllModules() {
    return modules;
  }

  function getHtml() {
    return html;
  }

  function toString() {
    return "RPH Tools Module";
  }

  return {
    init: init,
    getModule: getModule,
    getAllModules: getAllModules,
    getHtml: getHtml,
    toString: toString,
  };
})();
/****************************************************************************
 * Script initializations to execute after the page loads
 ***************************************************************************/
$(function () {
  console.log(`RPH Tools ${VERSION_STRING} start`);
  let modules = [chatModule, pmModule, moddingModule, logManagerModule, settingsModule, aboutModule];

  rphToolsModule.init(modules);
  console.log("RPH Tools initialization complete");
});