code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import http from 'http';
import { inspect } from 'util';
import { createLogger, directLog } from '../logging.js';
import { require } from '../require.js';
import { loadSourceMaps, retrace } from './retrace.js';

const WebSocket = require('ws');

const logger = createLogger('link');

const DEBUG = process.argv.includes('--debug');

export { loadSourceMaps };

export const setupLink = () => new LinkServer();

class LinkServer {
  constructor() {
    logger.log('setting up');
    this.wss = null;
    this.setupWebSocketLink();
    this.setupHttpLink();
  }

  // WebSocket-based client link
  setupWebSocketLink() {
    const port = 3000;
    this.wss = new WebSocket.Server({ port });
    this.wss.on('connection', ws => {
      logger.log('client connected');
      ws.on('message', json => {
        const msg = deserializeObject(json);
        this.handleLinkMessage(ws, msg);
      });
      ws.on('close', () => {
        logger.log('client disconnected');
      });
    });
    logger.log(`listening on port ${port} (WebSocket)`);
  }

  // One way HTTP-based client link for IE8
  setupHttpLink() {
    const port = 3001;
    this.httpServer = http.createServer((req, res) => {
      if (req.method === 'POST') {
        let body = '';
        req.on('data', chunk => {
          body += chunk.toString();
        });
        req.on('end', () => {
          const msg = deserializeObject(body);
          this.handleLinkMessage(null, msg);
          res.end();
        });
        return;
      }
      res.write('Hello');
      res.end();
    });
    this.httpServer.listen(port);
    logger.log(`listening on port ${port} (HTTP)`);
  }

  handleLinkMessage(ws, msg) {
    const { type, payload } = msg;
    if (type === 'log') {
      const { level, ns, args } = payload;
      // Skip debug messages
      if (level <= 0 && !DEBUG) {
        return;
      }
      directLog(ns, ...args.map(arg => {
        if (typeof arg === 'object') {
          return inspect(arg, {
            depth: Infinity,
            colors: true,
            compact: 8,
          });
        }
        return arg;
      }));
      return;
    }
    if (type === 'relay') {
      for (let client of this.wss.clients) {
        if (client === ws) {
          continue;
        }
        this.sendMessage(client, msg);
      }
      return;
    }
    logger.log('unhandled message', msg);
  }

  sendMessage(ws, msg) {
    ws.send(JSON.stringify(msg));
  }

  broadcastMessage(msg) {
    const clients = [...this.wss.clients];
    if (clients.length === 0) {
      return;
    }
    logger.log(`broadcasting ${msg.type} to ${clients.length} clients`);
    for (let client of clients) {
      const json = JSON.stringify(msg);
      client.send(json);
    }
  }
}

const deserializeObject = str => {
  return JSON.parse(str, (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (value.__undefined__) {
        // NOTE: You should not rely on deserialized object's undefined,
        // this is purely for inspection purposes.
        return {
          [inspect.custom]: () => undefined,
        };
      }
      if (value.__number__) {
        return parseFloat(value.__number__);
      }
      if (value.__error__) {
        if (!value.stack) {
          return value.string;
        }
        return retrace(value.stack);
      }
      return value;
    }
    return value;
  });
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

const inception = Date.now();

// Runtime detection
const isNode = process && process.release && process.release.name === 'node';
let isChrome = false;
try {
  isChrome = window.navigator.userAgent.toLowerCase().includes('chrome');
}
catch {}

// Timestamping function
const getTimestamp = () => {
  const timestamp = String(Date.now() - inception)
    .padStart(4, '0')
    .padStart(7, ' ');
  const seconds = timestamp.substr(0, timestamp.length - 3);
  const millis = timestamp.substr(-3);
  return `${seconds}.${millis}`;
};

const getPrefix = (() => {
  if (isNode) {
    // Escape sequences
    const ESC = {
      dimmed: '\x1b[38;5;240m',
      bright: '\x1b[37;1m',
      reset: '\x1b[0m',
    };
    return ns => [
      `${ESC.dimmed}${getTimestamp()} ${ESC.bright}${ns}${ESC.reset}`,
    ];
  }
  if (isChrome) {
    // Styles
    const styles = {
      dimmed: 'color: #888',
      bright: 'font-weight: bold',
    };
    return ns => [
      `%c${getTimestamp()}%c ${ns}`,
      styles.dimmed,
      styles.bright,
    ];
  }
  return ns => [
    `${getTimestamp()} ${ns}`,
  ];
})();

/**
 * Creates a logger object.
 */
export const createLogger = ns => ({
  log: (...args) => console.log(...getPrefix(ns), ...args),
  trace: (...args) => console.trace(...getPrefix(ns), ...args),
  debug: (...args) => console.debug(...getPrefix(ns), ...args),
  info: (...args) => console.info(...getPrefix(ns), ...args),
  warn: (...args) => console.warn(...getPrefix(ns), ...args),
  error: (...args) => console.error(...getPrefix(ns), ...args),
});

/**
 * Explicitly log with chosen namespace.
 */
export const directLog = (ns, ...args) =>
  console.log(...getPrefix(ns), ...args);

repo_name: AnalWerewolf/sundown

license: mit


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import fs from 'fs';
import os from 'os';
import { basename } from 'path';
import { DreamSeeker } from './dreamseeker.js';
import { createLogger } from './logging.js';
import { resolveGlob, resolvePath } from './util.js';
import { regQuery } from './winreg.js';

const logger = createLogger('reloader');

const HOME = os.homedir();
const SEARCH_LOCATIONS = [
  // Custom location
  process.env.BYOND_CACHE,
  // Windows
  `${HOME}/*/BYOND/cache`,
  // Wine
  `${HOME}/.wine/drive_c/users/*/*/BYOND/cache`,
  // Lutris
  `${HOME}/Games/byond/drive_c/users/*/*/BYOND/cache`,
  // WSL
  `/mnt/c/Users/*/*/BYOND/cache`,
];

let cacheRoot;

export const findCacheRoot = async () => {
  if (cacheRoot) {
    return cacheRoot;
  }
  logger.log('looking for byond cache');
  // Find BYOND cache folders
  for (let pattern of SEARCH_LOCATIONS) {
    if (!pattern) {
      continue;
    }
    const paths = await resolveGlob(pattern);
    if (paths.length > 0) {
      cacheRoot = paths[0];
      onCacheRootFound(cacheRoot);
      return cacheRoot;
    }
  }
  // Query the Windows Registry
  if (process.platform === 'win32') {
    logger.log('querying windows registry');
    let userpath = await regQuery(
      'HKCU\\Software\\Dantom\\BYOND',
      'userpath');
    if (userpath) {
      cacheRoot = userpath
        .replace(/\\$/, '')
        .replace(/\\/g, '/')
        + '/cache';
      onCacheRootFound(cacheRoot);
      return cacheRoot;
    }
  }
  logger.log('found no cache directories');
};

const onCacheRootFound = cacheRoot => {
  logger.log(`found cache at '${cacheRoot}'`);
  // Plant a dummy
  fs.closeSync(fs.openSync(cacheRoot + '/dummy', 'w'));
};

export const reloadByondCache = async bundleDir => {
  const cacheRoot = await findCacheRoot();
  if (!cacheRoot) {
    return;
  }
  // Find tmp folders in cache
  const cacheDirs = await resolveGlob(cacheRoot, './tmp*');
  if (cacheDirs.length === 0) {
    logger.log('found no tmp folder in cache');
    return;
  }
  // Get dreamseeker instances
  const pids = cacheDirs.map(cacheDir => (
    parseInt(cacheDir.split('/cache/tmp').pop(), 10)
  ));
  const dssPromise = DreamSeeker.getInstancesByPids(pids);
  // Copy assets
  const assets = await resolveGlob(bundleDir, './*.+(bundle|chunk|hot-update).*');
  for (let cacheDir of cacheDirs) {
    // Clear garbage
    const garbage = await resolveGlob(cacheDir, './*.+(bundle|chunk|hot-update).*');
    try {
      for (let file of garbage) {
        fs.unlinkSync(file);
      }
      // Copy assets
      for (let asset of assets) {
        const destination = resolvePath(cacheDir, basename(asset));
        fs.writeFileSync(destination, fs.readFileSync(asset));
      }
      logger.log(`copied ${assets.length} files to '${cacheDir}'`);
    }
    catch (err) {
      logger.error(`failed copying to '${cacheDir}'`);
      logger.error(err);
    }
  }
  // Notify dreamseeker
  const dss = await dssPromise;
  if (dss.length > 0) {
    logger.log(`notifying dreamseeker`);
    for (let dreamseeker of dss) {
      dreamseeker.topic({
        tgui: 1,
        type: 'cacheReloaded',
      });
    }
  }
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { createRequire } from 'module';

export const require = createRequire(import.meta.url);


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import fs from 'fs';
import path from 'path';
import { require } from './require.js';

const globPkg = require('glob');

export const resolvePath = path.resolve;

/**
 * Combines path.resolve with glob patterns.
 */
export const resolveGlob = (...sections) => {
  const unsafePaths = globPkg.sync(path.resolve(...sections), {
    strict: false,
    silent: true,
  });
  const safePaths = [];
  for (let path of unsafePaths) {
    try {
      fs.statSync(path);
      safePaths.push(path);
    }
    catch {}
  }
  return safePaths;
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import fs from 'fs';
import { createRequire } from 'module';
import { dirname } from 'path';
import { loadSourceMaps, setupLink } from './link/server.js';
import { createLogger } from './logging.js';
import { reloadByondCache } from './reloader.js';
import { resolveGlob } from './util.js';

const logger = createLogger('webpack');

/**
 * @param {any} config
 * @return {WebpackCompiler}
 */
export const createCompiler = async options => {
  const compiler = new WebpackCompiler();
  await compiler.setup(options);
  return compiler;
};

class WebpackCompiler {
  async setup(options) {
    // Create a require context that is relative to project root
    // and retrieve all necessary dependencies.
    const requireFromRoot = createRequire(dirname(import.meta.url) + '/../..');
    const webpack = await requireFromRoot('webpack');
    const createConfig = await requireFromRoot('./webpack.config.js');
    const config = createConfig({}, options);
    // Inject the HMR plugin into the config if we're using it
    if (options.hot) {
      config.plugins.push(new webpack.HotModuleReplacementPlugin());
    }
    this.webpack = webpack;
    this.config = config;
    this.bundleDir = config.output.path;
  }

  async watch() {
    logger.log('setting up');
    // Setup link
    const link = setupLink();
    // Instantiate the compiler
    const compiler = this.webpack.webpack(this.config);
    // Clear garbage before compiling
    compiler.hooks.watchRun.tapPromise('tgui-dev-server', async () => {
      const files = await resolveGlob(this.bundleDir, './*.hot-update.*');
      logger.log(`clearing garbage (${files.length} files)`);
      for (let file of files) {
        fs.unlinkSync(file);
      }
      logger.log('compiling');
    });
    // Start reloading when it's finished
    compiler.hooks.done.tap('tgui-dev-server', async stats => {
      // Load source maps
      await loadSourceMaps(this.bundleDir);
      // Reload cache
      await reloadByondCache(this.bundleDir);
      // Notify all clients that update has happened
      link.broadcastMessage({
        type: 'hotUpdate',
      });
    });
    // Start watching
    logger.log('watching for changes');
    compiler.watch({}, (err, stats) => {
      if (err) {
        logger.error('compilation error', err);
        return;
      }
      stats
        .toString(this.config.devServer.stats)
        .split('\n')
        .forEach(line => logger.log(line));
    });
  }
}


code:
/**
 * Tools for dealing with Windows Registry bullshit.
 *
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { exec } from 'child_process';
import { promisify } from 'util';
import { createLogger } from './logging.js';

const logger = createLogger('winreg');

export const regQuery = async (path, key) => {
  if (process.platform !== 'win32') {
    return null;
  }
  try {
    const command = `reg query "${path}" /v ${key}`;
    const { stdout } = await promisify(exec)(command);
    const keyPattern = `    ${key}    `;
    const indexOfKey = stdout.indexOf(keyPattern);
    if (indexOfKey === -1) {
      logger.error('could not find the registry key');
      return null;
    }
    const indexOfEol = stdout.indexOf('\r\n', indexOfKey);
    if (indexOfEol === -1) {
      logger.error('could not find the end of the line');
      return null;
    }
    const indexOfValue = stdout.indexOf(
      '    ',
      indexOfKey + keyPattern.length);
    if (indexOfValue === -1) {
      logger.error('could not find the start of the key value');
      return null;
    }
    const value = stdout.substring(indexOfValue + 4, indexOfEol);
    return value;
  }
  catch (err) {
    logger.error(err);
    return null;
  }
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { Flex } from 'tgui/components';

export const Notifications = props => {
  const { children } = props;
  return (
    <div className="Notifications">
      {children}
    </div>
  );
};

const NotificationsItem = props => {
  const {
    rightSlot,
    children,
  } = props;
  return (
    <Flex
      align="center"
      className="Notification">
      <Flex.Item
        className="Notification__content"
        grow={1}>
        {children}
      </Flex.Item>
      {rightSlot && (
        <Flex.Item className="Notification__rightSlot">
          {rightSlot}
        </Flex.Item>
      )}
    </Flex>
  );
};

Notifications.Item = NotificationsItem;


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { Button, Section, Stack } from 'tgui/components';
import { Pane } from 'tgui/layouts';
import { NowPlayingWidget, useAudio } from './audio';
import { ChatPanel, ChatTabs } from './chat';
import { useGame } from './game';
import { Notifications } from './Notifications';
import { PingIndicator } from './ping';
import { SettingsPanel, useSettings } from './settings';

export const Panel = (props, context) => {
  // IE8-10: Needs special treatment due to missing Flex support
  if (Byond.IS_LTE_IE10) {
    return (
      <HoboPanel />
    );
  }
  const audio = useAudio(context);
  const settings = useSettings(context);
  const game = useGame(context);
  if (process.env.NODE_ENV !== 'production') {
    const { useDebug, KitchenSink } = require('tgui/debug');
    const debug = useDebug(context);
    if (debug.kitchenSink) {
      return (
        <KitchenSink panel />
      );
    }
  }
  return (
    <Pane theme={settings.theme}>
      <Stack fill vertical>
        <Stack.Item>

            <Stack mr={1} align="center">
              <Stack.Item grow overflowX="auto">
                <ChatTabs />
              </Stack.Item>
              <Stack.Item>
                <PingIndicator />
              </Stack.Item>
              <Stack.Item>
                <Button
                  color="grey"
                  selected={audio.visible}
                  icon="music"
                  tooltip="Music player"
                  tooltipPosition="bottom-start"
                  onClick={() => audio.toggle()} />
              </Stack.Item>
              <Stack.Item>
                <Button
                  icon={settings.visible ? 'times' : 'cog'}
                  selected={settings.visible}
                  tooltip={settings.visible
                    ? 'Close settings'
                    : 'Open settings'}
                  tooltipPosition="bottom-start"
                  onClick={() => settings.toggle()} />
              </Stack.Item>
            </Stack>

        </Stack.Item>
        {audio.visible && (
          <Stack.Item>

              <NowPlayingWidget />

          </Stack.Item>
        )}
        {settings.visible && (
          <Stack.Item>
            <SettingsPanel />
          </Stack.Item>
        )}
        <Stack.Item grow>

            <Pane.Content scrollable>
              {!settings.visible && (

              )}
              <ChatPanel lineHeight={settings.lineHeight} />
            </Pane.Content>
            <Notifications>
              {game.connectionLostAt && (
                <Notifications.Item
                  rightSlot={(
                    <Button
                      color="white"
                      onClick={() => Byond.command('.reconnect')}>
                      Reconnect
                    </Button>
                  )}>
                  You are either AFK, experiencing lag or the connection
                  has closed.
                </Notifications.Item>
              )}
              {game.roundRestartedAt && (
                <Notifications.Item>
                  The connection has been closed because the server is
                  restarting. Please wait while you automatically reconnect.
                </Notifications.Item>
              )}
            </Notifications>

        </Stack.Item>
      </Stack>
    </Pane>
  );
};

const HoboPanel = (props, context) => {
  const settings = useSettings(context);
  return (
    <Pane theme={settings.theme}>
      <Pane.Content scrollable>
        <Button
          style={{
            position: 'fixed',
            top: '1em',
            right: '2em',
            'z-index': 1000,
          }}
          selected={settings.visible}
          onClick={() => settings.toggle()}>
          Settings
        </Button>
        {settings.visible && (
          <SettingsPanel />
        ) || (
          <ChatPanel lineHeight={settings.lineHeight} />
        )}
      </Pane.Content>
    </Pane>
  );
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { toFixed } from 'common/math';
import { useDispatch, useSelector } from 'common/redux';
import { Button, Flex, Knob } from 'tgui/components';
import { useSettings } from '../settings';
import { selectAudio } from './selectors';

export const NowPlayingWidget = (props, context) => {
  const audio = useSelector(context, selectAudio);
  const dispatch = useDispatch(context);
  const settings = useSettings(context);
  const title = audio.meta?.title;
  return (
    <Flex align="center">
      {audio.playing && (
        <>
          <Flex.Item
            shrink={0}
            mx={0.5}
            color="label">
            Now playing:
          </Flex.Item>
          <Flex.Item
            mx={0.5}
            grow={1}
            style={{
              'white-space': 'nowrap',
              'overflow': 'hidden',
              'text-overflow': 'ellipsis',
            }}>
            {title || 'Unknown Track'}
          </Flex.Item>
        </>
      ) || (
        <Flex.Item grow={1} color="label">
          Nothing to play.
        </Flex.Item>
      )}
      {audio.playing && (
        <Flex.Item mx={0.5} fontSize="0.9em">
          <Button
            tooltip="Stop"
            icon="stop"
            onClick={() => dispatch({
              type: 'audio/stopMusic',
            })} />
        </Flex.Item>
      )}
      <Flex.Item mx={0.5} fontSize="0.9em">
        <Knob
          minValue={0}
          maxValue={1}
          value={settings.adminMusicVolume}
          step={0.0025}
          stepPixelSize={1}
          format={value => toFixed(value * 100) + '%'}
          onDrag={(e, value) => settings.update({
            adminMusicVolume: value,
          })} />
      </Flex.Item>
    </Flex>
  );
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { useSelector, useDispatch } from 'common/redux';
import { selectAudio } from './selectors';

export const useAudio = context => {
  const state = useSelector(context, selectAudio);
  const dispatch = useDispatch(context);
  return {
    ...state,
    toggle: () => dispatch({ type: 'audio/toggle' }),
  };
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export { useAudio } from './hooks';
export { audioMiddleware } from './middleware';
export { NowPlayingWidget } from './NowPlayingWidget';
export { audioReducer } from './reducer';


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { AudioPlayer } from './player';

export const audioMiddleware = store => {
  const player = new AudioPlayer();
  player.onPlay(() => {
    store.dispatch({ type: 'audio/playing' });
  });
  player.onStop(() => {
    store.dispatch({ type: 'audio/stopped' });
  });
  return next => action => {
    const { type, payload } = action;
    if (type === 'audio/playMusic') {
      const { url, ...options } = payload;
      player.play(url, options);
      return next(action);
    }
    if (type === 'audio/stopMusic') {
      player.stop();
      return next(action);
    }
    if (type === 'settings/update' || type === 'settings/load') {
      const volume = payload?.adminMusicVolume;
      if (typeof volume === 'number') {
        player.setVolume(volume);
      }
      return next(action);
    }
    return next(action);
  };
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { createLogger } from 'tgui/logging';

const logger = createLogger('AudioPlayer');

export class AudioPlayer {
  constructor() {
    // Doesn't support HTMLAudioElement
    if (Byond.IS_LTE_IE9) {
      return;
    }
    // Set up the HTMLAudioElement node
    this.node = document.createElement('audio');
    this.node.style.setProperty('display', 'none');
    document.body.appendChild(this.node);
    // Set up other properties
    this.playing = false;
    this.volume = 1;
    this.options = {};
    this.onPlaySubscribers = [];
    this.onStopSubscribers = [];
    // Listen for playback start events
    this.node.addEventListener('canplaythrough', () => {
      logger.log('canplaythrough');
      this.playing = true;
      this.node.playbackRate = this.options.pitch || 1;
      this.node.currentTime = this.options.start || 0;
      this.node.volume = this.volume;
      this.node.play();
      for (let subscriber of this.onPlaySubscribers) {
        subscriber();
      }
    });
    // Listen for playback stop events
    this.node.addEventListener('ended', () => {
      logger.log('ended');
      this.stop();
    });
    // Listen for playback errors
    this.node.addEventListener('error', e => {
      if (this.playing) {
        logger.log('playback error', e.error);
        this.stop();
      }
    });
    // Check every second to stop the playback at the right time
    this.playbackInterval = setInterval(() => {
      if (!this.playing) {
        return;
      }
      const shouldStop = this.options.end > 0
        && this.node.currentTime >= this.options.end;
      if (shouldStop) {
        this.stop();
      }
    }, 1000);
  }

  destroy() {
    if (!this.node) {
      return;
    }
    this.node.stop();
    document.removeChild(this.node);
    clearInterval(this.playbackInterval);
  }

  play(url, options = {}) {
    if (!this.node) {
      return;
    }
    logger.log('playing', url, options);
    this.options = options;
    this.node.src = url;
  }

  stop() {
    if (!this.node) {
      return;
    }
    if (this.playing) {
      for (let subscriber of this.onStopSubscribers) {
        subscriber();
      }
    }
    logger.log('stopping');
    this.playing = false;
    this.node.src = '';
  }

  setVolume(volume) {
    if (!this.node) {
      return;
    }
    this.volume = volume;
    this.node.volume = volume;
  }

  onPlay(subscriber) {
    if (!this.node) {
      return;
    }
    this.onPlaySubscribers.push(subscriber);
  }

  onStop(subscriber) {
    if (!this.node) {
      return;
    }
    this.onStopSubscribers.push(subscriber);
  }
}


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

const initialState = {
  visible: false,
  playing: false,
  track: null,
};

export const audioReducer = (state = initialState, action) => {
  const { type, payload } = action;
  if (type === 'audio/playing') {
    return {
      ...state,
      visible: true,
      playing: true,
    };
  }
  if (type === 'audio/stopped') {
    return {
      ...state,
      visible: false,
      playing: false,
    };
  }
  if (type === 'audio/playMusic') {
    return {
      ...state,
      meta: payload,
    };
  }
  if (type === 'audio/stopMusic') {
    return {
      ...state,
      visible: false,
      playing: false,
      meta: null,
    };
  }
  if (type === 'audio/toggle') {
    return {
      ...state,
      visible: !state.visible,
    };
  }
  return state;
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export const selectAudio = state => state.audio;


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { useDispatch, useSelector } from 'common/redux';
import { Button, Collapsible, Divider, Input, Section, Stack } from 'tgui/components';
import { removeChatPage, toggleAcceptedType, updateChatPage } from './actions';
import { MESSAGE_TYPES } from './constants';
import { selectCurrentChatPage } from './selectors';

export const ChatPageSettings = (props, context) => {
  const page = useSelector(context, selectCurrentChatPage);
  const dispatch = useDispatch(context);
  return (

      <Stack align="center">
        <Stack.Item grow={1}>
          <Input
            fluid
            value={page.name}
            onChange={(e, value) => dispatch(updateChatPage({
              pageId: page.id,
              name: value,
            }))} />
        </Stack.Item>
        <Stack.Item>
          <Button
            icon="times"
            color="red"
            onClick={() => dispatch(removeChatPage({
              pageId: page.id,
            }))}>
            Remove
          </Button>
        </Stack.Item>
      </Stack>
      <Divider />

        {MESSAGE_TYPES
          .filter(typeDef => !typeDef.important && !typeDef.admin)
          .map(typeDef => (
            <Button.Checkbox
              key={typeDef.type}
              checked={page.acceptedTypes[typeDef.type]}
              onClick={() => dispatch(toggleAcceptedType({
                pageId: page.id,
                type: typeDef.type,
              }))}>
              {typeDef.name}
            </Button.Checkbox>
          ))}
        <Collapsible
          mt={1}
          color="transparent"
          title="Admin stuff">
          {MESSAGE_TYPES
            .filter(typeDef => !typeDef.important && typeDef.admin)
            .map(typeDef => (
              <Button.Checkbox
                key={typeDef.type}
                checked={page.acceptedTypes[typeDef.type]}
                onClick={() => dispatch(toggleAcceptedType({
                  pageId: page.id,
                  type: typeDef.type,
                }))}>
                {typeDef.name}
              </Button.Checkbox>
            ))}
        </Collapsible>

  );
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { shallowDiffers } from 'common/react';
import { Component, createRef } from 'inferno';
import { Button } from 'tgui/components';
import { chatRenderer } from './renderer';

export class ChatPanel extends Component {
  constructor() {
    super();
    this.ref = createRef();
    this.state = {
      scrollTracking: true,
    };
    this.handleScrollTrackingChange = value => this.setState({
      scrollTracking: value,
    });
  }

  componentDidMount() {
    chatRenderer.mount(this.ref.current);
    chatRenderer.events.on('scrollTrackingChanged',
      this.handleScrollTrackingChange);
    this.componentDidUpdate();
  }

  componentWillUnmount() {
    chatRenderer.events.off('scrollTrackingChanged',
      this.handleScrollTrackingChange);
  }

  componentDidUpdate(prevProps) {
    requestAnimationFrame(() => {
      chatRenderer.ensureScrollTracking();
    });
    const shouldUpdateStyle = (
      !prevProps || shallowDiffers(this.props, prevProps)
    );
    if (shouldUpdateStyle) {
      chatRenderer.assignStyle({
        'width': '100%',
        'white-space': 'pre-wrap',
        'font-size': this.props.fontSize,
        'line-height': this.props.lineHeight,
      });
    }
  }

  render() {
    const {
      scrollTracking,
    } = this.state;
    return (
      <>
        <div className="Chat" ref={this.ref} />
        {!scrollTracking && (
          <Button
            className="Chat__scrollButton"
            icon="arrow-down"
            onClick={() => chatRenderer.scrollToBottom()}>
            Scroll to bottom
          </Button>
        )}
      </>
    );
  }
}


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { useDispatch, useSelector } from 'common/redux';
import { Box, Tabs, Flex, Button } from 'tgui/components';
import { changeChatPage, addChatPage } from './actions';
import { selectChatPages, selectCurrentChatPage } from './selectors';
import { openChatSettings } from '../settings/actions';

const UnreadCountWidget = ({ value }) => (
  <Box
    style={{
      'font-size': '0.7em',
      'border-radius': '0.25em',
      'width': '1.7em',
      'line-height': '1.55em',
      'background-color': 'crimson',
      'color': '#fff',
    }}>
    {Math.min(value, 99)}
  </Box>
);

export const ChatTabs = (props, context) => {
  const pages = useSelector(context, selectChatPages);
  const currentPage = useSelector(context, selectCurrentChatPage);
  const dispatch = useDispatch(context);
  return (
    <Flex align="center">
      <Flex.Item>
        <Tabs textAlign="center">
          {pages.map(page => (
            <Tabs.Tab
              key={page.id}
              selected={page === currentPage}
              rightSlot={page.unreadCount > 0 && (
                <UnreadCountWidget value={page.unreadCount} />
              )}
              onClick={() => dispatch(changeChatPage({
                pageId: page.id,
              }))}>
              {page.name}
            </Tabs.Tab>
          ))}
        </Tabs>
      </Flex.Item>
      <Flex.Item ml={1}>
        <Button
          color="transparent"
          icon="plus"
          onClick={() => {
            dispatch(addChatPage());
            dispatch(openChatSettings());
          }} />
      </Flex.Item>
    </Flex>
  );
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { createAction } from 'common/redux';
import { createPage } from './model';

export const loadChat = createAction('chat/load');
export const rebuildChat = createAction('chat/rebuild');
export const updateMessageCount = createAction('chat/updateMessageCount');
export const addChatPage = createAction('chat/addPage', () => ({
  payload: createPage(),
}));
export const changeChatPage = createAction('chat/changePage');
export const updateChatPage = createAction('chat/updatePage');
export const toggleAcceptedType = createAction('chat/toggleAcceptedType');
export const removeChatPage = createAction('chat/removePage');
export const changeScrollTracking = createAction('chat/changeScrollTracking');
export const saveChatToDisk = createAction('chat/saveToDisk');


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export const MAX_VISIBLE_MESSAGES = 2500;
export const MAX_PERSISTED_MESSAGES = 1000;
export const MESSAGE_SAVE_INTERVAL = 10000;
export const MESSAGE_PRUNE_INTERVAL = 60000;
export const COMBINE_MAX_MESSAGES = 5;
export const COMBINE_MAX_TIME_WINDOW = 5000;
export const IMAGE_RETRY_DELAY = 250;
export const IMAGE_RETRY_LIMIT = 10;
export const IMAGE_RETRY_MESSAGE_AGE = 60000;

// Default message type
export const MESSAGE_TYPE_UNKNOWN = 'unknown';

// Internal message type
export const MESSAGE_TYPE_INTERNAL = 'internal';

// Must match the set of defines in code/__DEFINES/chat.dm
export const MESSAGE_TYPE_SYSTEM = 'system';
export const MESSAGE_TYPE_LOCALCHAT = 'localchat';
export const MESSAGE_TYPE_RADIO = 'radio';
export const MESSAGE_TYPE_INFO = 'info';
export const MESSAGE_TYPE_WARNING = 'warning';
export const MESSAGE_TYPE_DEADCHAT = 'deadchat';
export const MESSAGE_TYPE_OOC = 'ooc';
export const MESSAGE_TYPE_ADMINPM = 'adminpm';
export const MESSAGE_TYPE_COMBAT = 'combat';
export const MESSAGE_TYPE_ADMINCHAT = 'adminchat';
export const MESSAGE_TYPE_MODCHAT = 'modchat';
export const MESSAGE_TYPE_EVENTCHAT = 'eventchat';
export const MESSAGE_TYPE_ADMINLOG = 'adminlog';
export const MESSAGE_TYPE_ATTACKLOG = 'attacklog';
export const MESSAGE_TYPE_DEBUG = 'debug';

// Metadata for each message type
export const MESSAGE_TYPES = [
  // Always-on types
  {
    type: MESSAGE_TYPE_SYSTEM,
    name: 'System Messages',
    description: 'Messages from your client, always enabled',
    selector: '.boldannounce',
    important: true,
  },
  // Basic types
  {
    type: MESSAGE_TYPE_LOCALCHAT,
    name: 'Local',
    description: 'In-character local messages (say, emote, etc)',
    selector: '.say, .emote',
  },
  {
    type: MESSAGE_TYPE_RADIO,
    name: 'Radio',
    description: 'All departments of radio messages',
    selector: '.alert, .minorannounce, .syndradio, .centcomradio, .aiprivradio, .comradio, .secradio, .gangradio, .engradio, .medradio, .sciradio, .suppradio, .servradio, .radio, .deptradio, .binarysay, .newscaster, .resonate',
  },
  {
    type: MESSAGE_TYPE_INFO,
    name: 'Info',
    description: 'Non-urgent messages from the game and items',
    selector: '.notice:not(.pm), .adminnotice, .infobox, .info, .sinister, .cult, .infoplain, .announce, .hear, .smallnotice, .holoparasite',
  },
  {
    type: MESSAGE_TYPE_WARNING,
    name: 'Warnings',
    description: 'Urgent messages from the game and items',
    selector: '.warning:not(.pm), .critical, .flashinguserdanger, .userdanger, .animatedpain, .lowpain, .lowestpain, .artery, .infection, .necrosis, .dead, .italics, .alertsyndie, .warningplain',
  },
  {
    type: MESSAGE_TYPE_DEADCHAT,
    name: 'Deadchat',
    description: 'All of deadchat',
    selector: '.deadsay, .ghostalert',
  },
  {
    type: MESSAGE_TYPE_OOC,
    name: 'OOC',
    description: 'The bluewall of global OOC messages',
    selector: '.ooc, .adminooc, .oocplain',
  },
  {
    type: MESSAGE_TYPE_ADMINPM,
    name: 'Admin PMs',
    description: 'Messages to/from admins (adminhelp)',
    selector: '.pm, .adminhelp',
  },
  {
    type: MESSAGE_TYPE_COMBAT,
    name: 'Combat Log',
    description: 'Urist McTraitor has stabbed you with a knife!',
    selector: '.danger, .flashingdanger, .tinydanger, .smalldanger',
  },
  {
    type: MESSAGE_TYPE_UNKNOWN,
    name: 'Unsorted',
    description: 'Everything we could not sort, always enabled',
  },
  // Admin stuff
  {
    type: MESSAGE_TYPE_ADMINCHAT,
    name: 'Admin Chat',
    description: 'ASAY messages',
    selector: '.admin_channel, .adminsay',
    admin: true,
  },
  {
    type: MESSAGE_TYPE_MODCHAT,
    name: 'Mod Chat',
    description: 'MSAY messages',
    selector: '.mod_channel',
    admin: true,
  },
  {
    type: MESSAGE_TYPE_ADMINLOG,
    name: 'Admin Log',
    description: 'ADMIN LOG: Urist McAdmin has jumped to coordinates X, Y, Z',
    selector: '.log_message',
    admin: true,
  },
  {
    type: MESSAGE_TYPE_ATTACKLOG,
    name: 'Attack Log',
    description: 'Urist McTraitor has shot John Doe',
    admin: true,
  },
  {
    type: MESSAGE_TYPE_DEBUG,
    name: 'Debug Log',
    description: 'DEBUG: SSPlanets subsystem Recover().',
    admin: true,
  },
];


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export { ChatPageSettings } from './ChatPageSettings';
export { ChatPanel } from './ChatPanel';
export { ChatTabs } from './ChatTabs';
export { chatMiddleware } from './middleware';
export { chatReducer } from './reducer';


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import DOMPurify from 'dompurify';
import { storage } from 'common/storage';
import { loadSettings, updateSettings } from '../settings/actions';
import { selectSettings } from '../settings/selectors';
import { addChatPage, changeChatPage, changeScrollTracking, loadChat, rebuildChat, removeChatPage, saveChatToDisk, toggleAcceptedType, updateMessageCount } from './actions';
import { MAX_PERSISTED_MESSAGES, MESSAGE_SAVE_INTERVAL } from './constants';
import { createMessage, serializeMessage } from './model';
import { chatRenderer } from './renderer';
import { selectChat, selectCurrentChatPage } from './selectors';

// List of blacklisted tags
const FORBID_TAGS = [
  'a',
  'iframe',
  'link',
  'video',
];

const saveChatToStorage = async store => {
  const state = selectChat(store.getState());
  const fromIndex = Math.max(0,
    chatRenderer.messages.length - MAX_PERSISTED_MESSAGES);
  const messages = chatRenderer.messages
    .slice(fromIndex)
    .map(message => serializeMessage(message));
  storage.set('chat-state', state);
  storage.set('chat-messages', messages);
};

const loadChatFromStorage = async store => {
  const [state, messages] = await Promise.all([
    storage.get('chat-state'),
    storage.get('chat-messages'),
  ]);
  // Discard incompatible versions
  if (state && state.version <= 4) {
    store.dispatch(loadChat());
    return;
  }
  if (messages) {
    for (let message of messages) {
      if (message.html) {
        message.html = DOMPurify.sanitize(message.html, {
          FORBID_TAGS,
        });
      }
    }
    const batch = [
      ...messages,
      createMessage({
        type: 'internal/reconnected',
      }),
    ];
    chatRenderer.processBatch(batch, {
      prepend: true,
    });
  }
  store.dispatch(loadChat(state));
};

export const chatMiddleware = store => {
  let initialized = false;
  let loaded = false;
  chatRenderer.events.on('batchProcessed', countByType => {
    // Use this flag to workaround unread messages caused by
    // loading them from storage. Side effect of that, is that
    // message count can not be trusted, only unread count.
    if (loaded) {
      store.dispatch(updateMessageCount(countByType));
    }
  });
  chatRenderer.events.on('scrollTrackingChanged', scrollTracking => {
    store.dispatch(changeScrollTracking(scrollTracking));
  });
  setInterval(() => saveChatToStorage(store), MESSAGE_SAVE_INTERVAL);
  return next => action => {
    const { type, payload } = action;
    if (!initialized) {
      initialized = true;
      loadChatFromStorage(store);
    }
    if (type === 'chat/message') {
      // Normalize the payload
      const batch = Array.isArray(payload) ? payload : [payload];
      chatRenderer.processBatch(batch);
      return;
    }
    if (type === loadChat.type) {
      next(action);
      const page = selectCurrentChatPage(store.getState());
      chatRenderer.changePage(page);
      chatRenderer.onStateLoaded();
      loaded = true;
      return;
    }
    if (type === changeChatPage.type
        || type === addChatPage.type
        || type === removeChatPage.type
        || type === toggleAcceptedType.type) {
      next(action);
      const page = selectCurrentChatPage(store.getState());
      chatRenderer.changePage(page);
      return;
    }
    if (type === rebuildChat.type) {
      chatRenderer.rebuildChat();
      return next(action);
    }
    if (type === updateSettings.type || type === loadSettings.type) {
      next(action);
      const settings = selectSettings(store.getState());
      chatRenderer.setHighlight(
        settings.highlightText,
        settings.highlightColor,
        settings.matchWord,
        settings.matchCase);
      return;
    }
    if (type === 'roundrestart') {
      // Save chat as soon as possible
      saveChatToStorage(store);
      return next(action);
    }
    if (type === saveChatToDisk.type) {
      chatRenderer.saveToDisk();
      return;
    }
    return next(action);
  };
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { createUuid } from 'common/uuid';
import { MESSAGE_TYPES, MESSAGE_TYPE_INTERNAL } from './constants';

export const canPageAcceptType = (page, type) => (
  type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type]
);

export const createPage = obj => ({
  id: createUuid(),
  name: 'New Tab',
  acceptedTypes: {},
  unreadCount: 0,
  createdAt: Date.now(),
  ...obj,
});

export const createMainPage = () => {
  const acceptedTypes = {};
  for (let typeDef of MESSAGE_TYPES) {
    acceptedTypes[typeDef.type] = true;
  }
  return createPage({
    name: 'Main',
    acceptedTypes,
  });
};

export const createMessage = payload => ({
  createdAt: Date.now(),
  ...payload,
});

export const serializeMessage = message => ({
  type: message.type,
  text: message.text,
  html: message.html,
  times: message.times,
  createdAt: message.createdAt,
});

export const isSameMessage = (a, b) => (
  typeof a.text === 'string' && a.text === b.text
  || typeof a.html === 'string' && a.html === b.html
);


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { addChatPage, changeChatPage, loadChat, removeChatPage, toggleAcceptedType, updateChatPage, updateMessageCount, changeScrollTracking } from './actions';
import { canPageAcceptType, createMainPage } from './model';

const mainPage = createMainPage();

export const initialState = {
  version: 5,
  currentPageId: mainPage.id,
  scrollTracking: true,
  pages: [
    mainPage.id,
  ],
  pageById: {
    [mainPage.id]: mainPage,
  },
};

export const chatReducer = (state = initialState, action) => {
  const { type, payload } = action;
  if (type === loadChat.type) {
    // Validate version and/or migrate state
    if (payload?.version !== state.version) {
      return state;
    }
    // Reset page message counts
    // NOTE: We are mutably changing the payload on the assumption
    // that it is a copy that comes straight from the web storage.
    for (let id of Object.keys(payload.pageById)) {
      const page = payload.pageById[id];
      page.unreadCount = 0;
    }
    return {
      ...state,
      ...payload,
    };
  }
  if (type === changeScrollTracking.type) {
    const scrollTracking = payload;
    const nextState = {
      ...state,
      scrollTracking,
    };
    if (scrollTracking) {
      const pageId = state.currentPageId;
      const page = {
        ...state.pageById[pageId],
        unreadCount: 0,
      };
      nextState.pageById = {
        ...state.pageById,
        [pageId]: page,
      };
    }
    return nextState;
  }
  if (type === updateMessageCount.type) {
    const countByType = payload;
    const pages = state.pages.map(id => state.pageById[id]);
    const currentPage = state.pageById[state.currentPageId];
    const nextPageById = { ...state.pageById };
    for (let page of pages) {
      let unreadCount = 0;
      for (let type of Object.keys(countByType)) {
        // Message does not belong here
        if (!canPageAcceptType(page, type)) {
          continue;
        }
        // Current page is scroll tracked
        if (page === currentPage && state.scrollTracking) {
          continue;
        }
        // This page received the same message which we can read
        // on the current page.
        if (page !== currentPage && canPageAcceptType(currentPage, type)) {
          continue;
        }
        unreadCount += countByType[type];
      }
      if (unreadCount > 0) {
        nextPageById[page.id] = {
          ...page,
          unreadCount: page.unreadCount + unreadCount,
        };
      }
    }
    return {
      ...state,
      pageById: nextPageById,
    };
  }
  if (type === addChatPage.type) {
    return {
      ...state,
      currentPageId: payload.id,
      pages: [...state.pages, payload.id],
      pageById: {
        ...state.pageById,
        [payload.id]: payload,
      },
    };
  }
  if (type === changeChatPage.type) {
    const { pageId } = payload;
    const page = {
      ...state.pageById[pageId],
      unreadCount: 0,
    };
    return {
      ...state,
      currentPageId: pageId,
      pageById: {
        ...state.pageById,
        [pageId]: page,
      },
    };
  }
  if (type === updateChatPage.type) {
    const { pageId, ...update } = payload;
    const page = {
      ...state.pageById[pageId],
      ...update,
    };
    return {
      ...state,
      pageById: {
        ...state.pageById,
        [pageId]: page,
      },
    };
  }
  if (type === toggleAcceptedType.type) {
    const { pageId, type } = payload;
    const page = { ...state.pageById[pageId] };
    page.acceptedTypes = { ...page.acceptedTypes };
    page.acceptedTypes[type] = !page.acceptedTypes[type];
    return {
      ...state,
      pageById: {
        ...state.pageById,
        [pageId]: page,
      },
    };
  }
  if (type === removeChatPage.type) {
    const { pageId } = payload;
    const nextState = {
      ...state,
      pages: [...state.pages],
      pageById: {
        ...state.pageById,
      },
    };
    delete nextState.pageById[pageId];
    nextState.pages = nextState.pages.filter(id => id !== pageId);
    if (nextState.pages.length === 0) {
      nextState.pages.push(mainPage.id);
      nextState.pageById[mainPage.id] = mainPage;
      nextState.currentPageId = mainPage.id;
    }
    if (!nextState.currentPageId || nextState.currentPageId === pageId) {
      nextState.currentPageId = nextState.pages[0];
    }
    return nextState;
  }
  return state;
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { EventEmitter } from 'common/events';
import { classes } from 'common/react';
import { createLogger } from 'tgui/logging';
import { COMBINE_MAX_MESSAGES, COMBINE_MAX_TIME_WINDOW, IMAGE_RETRY_DELAY, IMAGE_RETRY_LIMIT, IMAGE_RETRY_MESSAGE_AGE, MAX_PERSISTED_MESSAGES, MAX_VISIBLE_MESSAGES, MESSAGE_PRUNE_INTERVAL, MESSAGE_TYPES, MESSAGE_TYPE_INTERNAL, MESSAGE_TYPE_UNKNOWN } from './constants';
import { canPageAcceptType, createMessage, isSameMessage } from './model';
import { highlightNode, linkifyNode } from './replaceInTextNode';

const logger = createLogger('chatRenderer');

// We consider this as the smallest possible scroll offset
// that is still trackable.
const SCROLL_TRACKING_TOLERANCE = 24;

const findNearestScrollableParent = startingNode => {
  const body = document.body;
  let node = startingNode;
  while (node && node !== body) {
    // This definitely has a vertical scrollbar, because it reduces
    // scrollWidth of the element. Might not work if element uses
    // overflow: hidden.
    if (node.scrollWidth < node.offsetWidth) {
      return node;
    }
    node = node.parentNode;
  }
  return window;
};

const createHighlightNode = (text, color) => {
  const node = document.createElement('span');
  node.className = 'Chat__highlight';
  node.setAttribute('style', 'background-color:' + color);
  node.textContent = text;
  return node;
};

const createMessageNode = () => {
  const node = document.createElement('div');
  node.className = 'ChatMessage';
  return node;
};

const createReconnectedNode = () => {
  const node = document.createElement('div');
  node.className = 'Chat__reconnected';
  return node;
};

const handleImageError = e => {
  setTimeout(() => {
    /** @type {HTMLImageElement} */
    const node = e.target;
    const attempts = parseInt(node.getAttribute('data-reload-n'), 10) || 0;
    if (attempts >= IMAGE_RETRY_LIMIT) {
      logger.error(`failed to load an image after ${attempts} attempts`);
      return;
    }
    const src = node.src;
    node.src = null;
    node.src = src + '#' + attempts;
    node.setAttribute('data-reload-n', attempts + 1);
  }, IMAGE_RETRY_DELAY);
};

/**
 * Assigns a "times-repeated" badge to the message.
 */
const updateMessageBadge = message => {
  const { node, times } = message;
  if (!node || !times) {
    // Nothing to update
    return;
  }
  const foundBadge = node.querySelector('.Chat__badge');
  const badge = foundBadge || document.createElement('div');
  badge.textContent = times;
  badge.className = classes([
    'Chat__badge',
    'Chat__badge--animate',
  ]);
  requestAnimationFrame(() => {
    badge.className = 'Chat__badge';
  });
  if (!foundBadge) {
    node.appendChild(badge);
  }
};

class ChatRenderer {
  constructor() {
    /** @type {HTMLElement} */
    this.loaded = false;
    /** @type {HTMLElement} */
    this.rootNode = null;
    this.queue = [];
    this.messages = [];
    this.visibleMessages = [];
    this.page = null;
    this.events = new EventEmitter();
    // Scroll handler
    /** @type {HTMLElement} */
    this.scrollNode = null;
    this.scrollTracking = true;
    this.handleScroll = type => {
      const node = this.scrollNode;
      const height = node.scrollHeight;
      const bottom = node.scrollTop + node.offsetHeight;
      const scrollTracking = (
        Math.abs(height - bottom) < SCROLL_TRACKING_TOLERANCE
      );
      if (scrollTracking !== this.scrollTracking) {
        this.scrollTracking = scrollTracking;
        this.events.emit('scrollTrackingChanged', scrollTracking);
        logger.debug('tracking', this.scrollTracking);
      }
    };
    this.ensureScrollTracking = () => {
      if (this.scrollTracking) {
        this.scrollToBottom();
      }
    };
    // Periodic message pruning
    setInterval(() => this.pruneMessages(), MESSAGE_PRUNE_INTERVAL);
  }

  isReady() {
    return this.loaded && this.rootNode && this.page;
  }

  mount(node) {
    // Mount existing root node on top of the new node
    if (this.rootNode) {
      node.appendChild(this.rootNode);
    }
    // Initialize the root node
    else {
      this.rootNode = node;
    }
    // Find scrollable parent
    this.scrollNode = findNearestScrollableParent(this.rootNode);
    this.scrollNode.addEventListener('scroll', this.handleScroll);
    setImmediate(() => {
      this.scrollToBottom();
    });
    // Flush the queue
    this.tryFlushQueue();
  }

  onStateLoaded() {
    this.loaded = true;
    this.tryFlushQueue();
  }

  tryFlushQueue() {
    if (this.isReady() && this.queue.length > 0) {
      this.processBatch(this.queue);
      this.queue = [];
    }
  }

  assignStyle(style = {}) {
    for (let key of Object.keys(style)) {
      this.rootNode.style.setProperty(key, style[key]);
    }
  }

  setHighlight(text, color, matchWord, matchCase) {
    if (!text || !color) {
      this.highlightRegex = null;
      this.highlightColor = null;
      return;
    }
    const lines = String(text)
      .split(',')
      // eslint-disable-next-line no-useless-escape
      .map(str => str.trim().replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'))
      .filter(str => (
        // Must be longer than one character
        str && str.length > 1
      ));
    // Nothing to match, reset highlighting
    if (lines.length === 0) {
      this.highlightRegex = null;
      this.highlightColor = null;
      return;
    }
    const pattern = `${(matchWord ? '\\b' : '')}(${lines.join('|')})${(matchWord ? '\\b' : '')}`;
    const flags = 'g' + (matchCase ? '' : 'i');
    this.highlightRegex = new RegExp(pattern, flags);
    this.highlightColor = color;
  }

  scrollToBottom() {
    // scrollHeight is always bigger than scrollTop and is
    // automatically clamped to the valid range.
    this.scrollNode.scrollTop = this.scrollNode.scrollHeight;
  }

  changePage(page) {
    if (!this.isReady()) {
      this.page = page;
      this.tryFlushQueue();
      return;
    }
    this.page = page;
    // Fast clear of the root node
    this.rootNode.textContent = '';
    this.visibleMessages = [];
    // Re-add message nodes
    const fragment = document.createDocumentFragment();
    let node;
    for (let message of this.messages) {
      if (canPageAcceptType(page, message.type)) {
        node = message.node;
        fragment.appendChild(node);
        this.visibleMessages.push(message);
      }
    }
    if (node) {
      this.rootNode.appendChild(fragment);
      node.scrollIntoView();
    }
  }

  getCombinableMessage(predicate) {
    const now = Date.now();
    const len = this.visibleMessages.length;
    const from = len - 1;
    const to = Math.max(0, len - COMBINE_MAX_MESSAGES);
    for (let i = from; i >= to; i--) {
      const message = this.visibleMessages[i];
      const matches = (
        // Is not an internal message
        !message.type.startsWith(MESSAGE_TYPE_INTERNAL)
        // Text payload must fully match
        && isSameMessage(message, predicate)
        // Must land within the specified time window
        && now < message.createdAt + COMBINE_MAX_TIME_WINDOW
      );
      if (matches) {
        return message;
      }
    }
    return null;
  }

  processBatch(batch, options = {}) {
    const {
      prepend,
      notifyListeners = true,
    } = options;
    const now = Date.now();
    // Queue up messages until chat is ready
    if (!this.isReady()) {
      if (prepend) {
        this.queue = [...batch, ...this.queue];
      }
      else {
        this.queue = [...this.queue, ...batch];
      }
      return;
    }
    // Insert messages
    const fragment = document.createDocumentFragment();
    const countByType = {};
    let node;
    for (let payload of batch) {
      const message = createMessage(payload);
      // Combine messages
      const combinable = this.getCombinableMessage(message);
      if (combinable) {
        combinable.times = (combinable.times || 1) + 1;
        updateMessageBadge(combinable);
        continue;
      }
      // Reuse message node
      if (message.node) {
        node = message.node;
      }
      // Reconnected
      else if (message.type === 'internal/reconnected') {
        node = createReconnectedNode();
      }
      // Create message node
      else {
        node = createMessageNode();
        // Payload is plain text
        if (message.text) {
          node.textContent = message.text;
        }
        // Payload is HTML
        else if (message.html) {
          node.innerHTML = message.html;
        }
        else {
          logger.error('Error: message is missing text payload', message);
        }
        // Highlight text
        if (!message.avoidHighlighting && this.highlightRegex) {
          const highlighted = highlightNode(node,
            this.highlightRegex,
            text => (
              createHighlightNode(text, this.highlightColor)
            ));
          if (highlighted) {
            node.className += ' ChatMessage--highlighted';
          }
        }
        // Linkify text
        const linkifyNodes = node.querySelectorAll('.linkify');
        for (let i = 0; i < linkifyNodes.length; ++i) {
          linkifyNode(linkifyNodes[i]);
        }
        // Assign an image error handler
        if (now < message.createdAt + IMAGE_RETRY_MESSAGE_AGE) {
          const imgNodes = node.querySelectorAll('img');
          for (let i = 0; i < imgNodes.length; i++) {
            const imgNode = imgNodes[i];
            imgNode.addEventListener('error', handleImageError);
          }
        }
      }
      // Store the node in the message
      message.node = node;
      // Query all possible selectors to find out the message type
      if (!message.type) {
        // IE8: Does not support querySelector on elements that
        // are not yet in the document.
        const typeDef = !Byond.IS_LTE_IE8 && MESSAGE_TYPES
          .find(typeDef => (
            typeDef.selector && node.querySelector(typeDef.selector)
          ));
        message.type = typeDef?.type || MESSAGE_TYPE_UNKNOWN;
      }
      updateMessageBadge(message);
      if (!countByType[message.type]) {
        countByType[message.type] = 0;
      }
      countByType[message.type] += 1;
      // TODO: Detect duplicates
      this.messages.push(message);
      if (canPageAcceptType(this.page, message.type)) {
        fragment.appendChild(node);
        this.visibleMessages.push(message);
      }
    }
    if (node) {
      const firstChild = this.rootNode.childNodes[0];
      if (prepend && firstChild) {
        this.rootNode.insertBefore(fragment, firstChild);
      }
      else {
        this.rootNode.appendChild(fragment);
      }
      if (this.scrollTracking) {
        setImmediate(() => this.scrollToBottom());
      }
    }
    // Notify listeners that we have processed the batch
    if (notifyListeners) {
      this.events.emit('batchProcessed', countByType);
    }
  }

  pruneMessages() {
    if (!this.isReady()) {
      return;
    }
    // Delay pruning because user is currently interacting
    // with chat history
    if (!this.scrollTracking) {
      logger.debug('pruning delayed');
      return;
    }
    // Visible messages
    {
      const messages = this.visibleMessages;
      const fromIndex = Math.max(0,
        messages.length - MAX_VISIBLE_MESSAGES);
      if (fromIndex > 0) {
        this.visibleMessages = messages.slice(fromIndex);
        for (let i = 0; i < fromIndex; i++) {
          const message = messages[i];
          this.rootNode.removeChild(message.node);
          // Mark this message as pruned
          message.node = 'pruned';
        }
        // Remove pruned messages from the message array
        this.messages = this.messages.filter(message => (
          message.node !== 'pruned'
        ));
        logger.log(`pruned ${fromIndex} visible messages`);
      }
    }
    // All messages
    {
      const fromIndex = Math.max(0,
        this.messages.length - MAX_PERSISTED_MESSAGES);
      if (fromIndex > 0) {
        this.messages = this.messages.slice(fromIndex);
        logger.log(`pruned ${fromIndex} stored messages`);
      }
    }
  }

  rebuildChat() {
    if (!this.isReady()) {
      return;
    }
    // Make a copy of messages
    const fromIndex = Math.max(0,
      this.messages.length - MAX_PERSISTED_MESSAGES);
    const messages = this.messages.slice(fromIndex);
    // Remove existing nodes
    for (let message of messages) {
      message.node = undefined;
    }
    // Fast clear of the root node
    this.rootNode.textContent = '';
    this.messages = [];
    this.visibleMessages = [];
    // Repopulate the chat log
    this.processBatch(messages, {
      notifyListeners: false,
    });
  }

  saveToDisk() {
    // Allow only on IE11
    if (Byond.IS_LTE_IE10) {
      return;
    }
    // Compile currently loaded stylesheets as CSS text
    let cssText = '';
    const styleSheets = document.styleSheets;
    for (let i = 0; i < styleSheets.length; i++) {
      const cssRules = styleSheets[i].cssRules;
      for (let i = 0; i < cssRules.length; i++) {
        const rule = cssRules[i];
        cssText += rule.cssText + '\n';
      }
    }
    cssText += 'body, html { background-color: #141414 }\n';
    // Compile chat log as HTML text
    let messagesHtml = '';
    for (let message of this.visibleMessages) {
      if (message.node) {
        messagesHtml += message.node.outerHTML + '\n';
      }
    }
    // Create a page
    const pageHtml = '<!doctype html>\n'
      + '\n'
      + '\n'
      + '<title>SS13 Chat Log</title>\n'
      + '\n'
      + '\n'
      + '\n'
      + '
\n'
      + messagesHtml
      + '
\n'
      + '\n'
      + '\n';
    // Create and send a nice blob
    const blob = new Blob([pageHtml]);
    const timestamp = new Date()
      .toISOString()
      .substring(0, 19)
      .replace(/[-:]/g, '')
      .replace('T', '-');
    window.navigator.msSaveBlob(blob, `ss13-chatlog-${timestamp}.html`);
  }
}

// Make chat renderer global so that we can continue using the same
// instance after hot code replacement.
if (!window.__chatRenderer__) {
  window.__chatRenderer__ = new ChatRenderer();
}

/** @type {ChatRenderer} */
export const chatRenderer = window.__chatRenderer__;


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

/**
 * Replaces text matching a regular expression with a custom node.
 */
export const replaceInTextNode = (regex, createNode) => node => {
  const text = node.textContent;
  const textLength = text.length;
  let match;
  let lastIndex = 0;
  let fragment;
  let n = 0;
  // eslint-disable-next-line no-cond-assign
  while (match = regex.exec(text)) {
    n += 1;
    // Lazy init fragment
    if (!fragment) {
      fragment = document.createDocumentFragment();
    }
    const matchText = match[0];
    const matchLength = matchText.length;
    const matchIndex = match.index;
    // Insert previous unmatched chunk
    if (lastIndex < matchIndex) {
      fragment.appendChild(document.createTextNode(
        text.substring(lastIndex, matchIndex)));
    }
    lastIndex = matchIndex + matchLength;
    // Create a wrapper node
    fragment.appendChild(createNode(matchText));
  }
  if (fragment) {
    // Insert the remaining unmatched chunk
    if (lastIndex < textLength) {
      fragment.appendChild(document.createTextNode(
        text.substring(lastIndex, textLength)));
    }
    // Commit the fragment
    node.parentNode.replaceChild(fragment, node);
  }
  return n;
};

// Highlight
// --------------------------------------------------------

/**
 * Default highlight node.
 */
const createHighlightNode = text => {
  const node = document.createElement('span');
  node.setAttribute('style',
    'background-color:#fd4;color:#000');
  node.textContent = text;
  return node;
};

/**
 * Highlights the text in the node based on the provided regular expression.
 *
 * @param {Node} node Node which you want to process
 * @param {RegExp} regex Regular expression to highlight
 * @param {(text: string) => Node} createNode Highlight node creator
 * @returns {number} Number of matches
 */
export const highlightNode = (
  node,
  regex,
  createNode = createHighlightNode,
) => {
  if (!createNode) {
    createNode = createHighlightNode;
  }
  let n = 0;
  const childNodes = node.childNodes;
  for (let i = 0; i < childNodes.length; i++) {
    const node = childNodes[i];
    // Is a text node
    if (node.nodeType === 3) {
      n += replaceInTextNode(regex, createNode)(node);
    }
    else {
      n += highlightNode(node, regex, createNode);
    }
  }
  return n;
};

// Linkify
// --------------------------------------------------------

const URL_REGEX = /(?:(?:https?:\/\/)|(?:www\.))(?:[^ ]*?\.[^ ]*?)+[-A-Za-z0-9+&@#/%?=~_|$!:,.;()]+/ig;

/**
 * Highlights the text in the node based on the provided regular expression.
 *
 * @param {Node} node Node which you want to process
 * @returns {number} Number of matches
 */
export const linkifyNode = node => {
  let n = 0;
  const childNodes = node.childNodes;
  for (let i = 0; i < childNodes.length; i++) {
    const node = childNodes[i];
    const tag = String(node.nodeName).toLowerCase();
    // Is a text node
    if (node.nodeType === 3) {
      n += linkifyTextNode(node);
    }
    else if (tag !== 'a') {
      n += linkifyNode(node);
    }
  }
  return n;
};

const linkifyTextNode = replaceInTextNode(URL_REGEX, text => {
  const node = document.createElement('a');
  node.href = text;
  node.textContent = text;
  return node;
});


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { map } from 'common/collections';

export const selectChat = state => state.chat;

export const selectChatPages = state => (
  map(id => state.chat.pageById[id])(state.chat.pages)
);

export const selectCurrentChatPage = state => (
  state.chat.pageById[state.chat.currentPageId]
);

export const selectChatPageById = id => state => (
  state.chat.pageById[id]
);


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { createAction } from 'common/redux';

export const roundRestarted = createAction('roundrestart');
export const connectionLost = createAction('game/connectionLost');
export const connectionRestored = createAction('game/connectionRestored');


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export const CONNECTION_LOST_AFTER = 15000;


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { useSelector } from 'common/redux';
import { selectGame } from './selectors';

export const useGame = context => {
  return useSelector(context, selectGame);
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export { useGame } from './hooks';
export { gameMiddleware } from './middleware';
export { gameReducer } from './reducer';


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { pingSuccess } from '../ping/actions';
import { connectionLost, connectionRestored, roundRestarted } from './actions';
import { selectGame } from './selectors';
import { CONNECTION_LOST_AFTER } from './constants';

const withTimestamp = action => ({
  ...action,
  meta: {
    ...action.meta,
    now: Date.now(),
  },
});

export const gameMiddleware = store => {
  let lastPingedAt;
  setInterval(() => {
    const state = store.getState();
    if (!state) {
      return;
    }
    const game = selectGame(state);
    const pingsAreFailing = lastPingedAt
      && Date.now() >= lastPingedAt + CONNECTION_LOST_AFTER;
    if (!game.connectionLostAt && pingsAreFailing) {
      store.dispatch(withTimestamp(connectionLost()));
    }
    if (game.connectionLostAt && !pingsAreFailing) {
      store.dispatch(withTimestamp(connectionRestored()));
    }
  }, 1000);
  return next => action => {
    const { type, payload, meta } = action;
    if (type === pingSuccess.type) {
      lastPingedAt = meta.now;
      return next(action);
    }
    if (type === roundRestarted.type) {
      return next(withTimestamp(action));
    }
    return next(action);
  };
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { connectionLost } from './actions';
import { connectionRestored } from './actions';

const initialState = {
  // TODO: This is where round info should be.
  roundId: null,
  roundTime: null,
  roundRestartedAt: null,
  connectionLostAt: null,
};

export const gameReducer = (state = initialState, action) => {
  const { type, payload, meta } = action;
  if (type === 'roundrestart') {
    return {
      ...state,
      roundRestartedAt: meta.now,
    };
  }
  if (type === connectionLost.type) {
    return {
      ...state,
      connectionLostAt: meta.now,
    };
  }
  if (type === connectionRestored.type) {
    return {
      ...state,
      connectionLostAt: null,
    };
  }
  return state;
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export const selectGame = state => state.game;


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

// Themes
import './styles/main.scss';
import './styles/themes/light.scss';

import { perf } from 'common/perf';
import { combineReducers } from 'common/redux';
import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
import { setupGlobalEvents } from 'tgui/events';
import { captureExternalLinks } from 'tgui/links';
import { createRenderer } from 'tgui/renderer';
import { configureStore, StoreProvider } from 'tgui/store';
import { audioMiddleware, audioReducer } from './audio';
import { chatMiddleware, chatReducer } from './chat';
import { gameMiddleware, gameReducer } from './game';
import { setupPanelFocusHacks } from './panelFocus';
import { pingMiddleware, pingReducer } from './ping';
import { settingsMiddleware, settingsReducer } from './settings';
import { telemetryMiddleware } from './telemetry';

perf.mark('inception', window.performance?.timing?.navigationStart);
perf.mark('init');

const store = configureStore({
  reducer: combineReducers({
    audio: audioReducer,
    chat: chatReducer,
    game: gameReducer,
    ping: pingReducer,
    settings: settingsReducer,
  }),
  middleware: {
    pre: [
      chatMiddleware,
      pingMiddleware,
      telemetryMiddleware,
      settingsMiddleware,
      audioMiddleware,
      gameMiddleware,
    ],
  },
});

const renderApp = createRenderer(() => {
  const { Panel } = require('./Panel');
  return (
    <StoreProvider store={store}>
      <Panel />
    </StoreProvider>
  );
});

const setupApp = () => {
  // Delay setup
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', setupApp);
    return;
  }

  setupGlobalEvents({
    ignoreWindowFocus: true,
  });
  setupPanelFocusHacks();
  captureExternalLinks();

  // Subscribe for Redux state updates
  store.subscribe(renderApp);

  // Subscribe for bankend updates
  window.update = msg => store.dispatch(Byond.parseJson(msg));

  // Process the early update queue
  while (true) {
    const msg = window.__updateQueue__.shift();
    if (!msg) {
      break;
    }
    window.update(msg);
  }

  // Unhide the panel
  Byond.winset('output', {
    'is-visible': false,
  });
  Byond.winset('browseroutput', {
    'is-visible': true,
    'is-disabled': false,
    'pos': '0x0',
    'size': '0x0',
  });

  // Resize the panel to match the non-browser output
  Byond.winget('output').then(output => {
    Byond.winset('browseroutput', {
      'size': output.size,
    });
  });

  // Enable hot module reloading
  if (module.hot) {
    setupHotReloading();
    module.hot.accept([
      './audio',
      './chat',
      './game',
      './Notifications',
      './Panel',
      './ping',
      './settings',
      './telemetry',
    ], () => {
      renderApp();
    });
  }
};

setupApp();


code:
/**
 * Basically, hacks from goonchat which try to keep the map focused at all
 * times, except for when some meaningful action happens o
 *
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { vecLength, vecSubtract } from 'common/vector';
import { canStealFocus, globalEvents } from 'tgui/events';
import { focusMap } from 'tgui/focus';

// Empyrically determined number for the smallest possible
// text you can select with the mouse.
const MIN_SELECTION_DISTANCE = 10;

const deferredFocusMap = () => setImmediate(() => focusMap());

export const setupPanelFocusHacks = () => {
  let focusStolen = false;
  let clickStartPos = null;
  window.addEventListener('focusin', e => {
    focusStolen = canStealFocus(e.target);
  });
  window.addEventListener('mousedown', e => {
    clickStartPos = [e.screenX, e.screenY];
  });
  window.addEventListener('mouseup', e => {
    if (clickStartPos) {
      const clickEndPos = [e.screenX, e.screenY];
      const dist = vecLength(vecSubtract(clickEndPos, clickStartPos));
      if (dist >= MIN_SELECTION_DISTANCE) {
        focusStolen = true;
      }
    }
    if (!focusStolen) {
      deferredFocusMap();
    }
  });
  globalEvents.on('keydown', key => {
    if (key.isModifierKey()) {
      return;
    }
    deferredFocusMap();
  });
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { Color } from 'common/color';
import { toFixed } from 'common/math';
import { useSelector } from 'common/redux';
import { Box } from 'tgui/components';
import { selectPing } from './selectors';

export const PingIndicator = (props, context) => {
  const ping = useSelector(context, selectPing);
  const color = Color.lookup(ping.networkQuality, [
    new Color(220, 40, 40),
    new Color(220, 200, 40),
    new Color(60, 220, 40),
  ]);
  const roundtrip = ping.roundtrip
    ? toFixed(ping.roundtrip)
    : '--';
  return (
    <div className="Ping">
      <Box
        className="Ping__indicator"
        backgroundColor={color} />
      {roundtrip}
    </div>
  );
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { createAction } from 'common/redux';

export const pingSuccess = createAction(
  'ping/success',
  ping => {
    const now = Date.now();
    const roundtrip = (now - ping.sentAt) * 0.5;
    return {
      payload: {
        lastId: ping.id,
        roundtrip,
      },
      meta: { now },
    };
  }
);

export const pingFail = createAction('ping/fail');
export const pingReply = createAction('ping/reply');


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export const PING_INTERVAL = 2500;
export const PING_TIMEOUT = 2000;
export const PING_MAX_FAILS = 3;
export const PING_QUEUE_SIZE = 8;
export const PING_ROUNDTRIP_BEST = 50;
export const PING_ROUNDTRIP_WORST = 200;


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export { pingMiddleware } from './middleware';
export { PingIndicator } from './PingIndicator';
export { pingReducer } from './reducer';


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { sendMessage } from 'tgui/backend';
import { pingFail, pingSuccess } from './actions';
import { PING_INTERVAL, PING_QUEUE_SIZE, PING_TIMEOUT } from './constants';

export const pingMiddleware = store => {
  let initialized = false;
  let index = 0;
  let interval;
  const pings = [];
  const sendPing = () => {
    for (let i = 0; i < PING_QUEUE_SIZE; i++) {
      const ping = pings[i];
      if (ping && Date.now() - ping.sentAt > PING_TIMEOUT) {
        pings[i] = null;
        store.dispatch(pingFail());
      }
    }
    const ping = { index, sentAt: Date.now() };
    pings[index] = ping;
    sendMessage({
      type: 'ping',
      payload: { index },
    });
    index = (index + 1) % PING_QUEUE_SIZE;
  };
  return next => action => {
    const { type, payload } = action;
    if (!initialized) {
      initialized = true;
      interval = setInterval(sendPing, PING_INTERVAL);
      sendPing();
    }
    if (type === 'roundrestart') {
      // Stop pinging because dreamseeker is currently reconnecting.
      // Topic calls in the middle of reconnect will crash the connection.
      clearInterval(interval);
      return next(action);
    }
    if (type === 'pingReply') {
      const { index } = payload;
      const ping = pings[index];
      // Received a timed out ping
      if (!ping) {
        return;
      }
      pings[index] = null;
      return next(pingSuccess(ping));
    }
    return next(action);
  };
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { clamp01, scale } from 'common/math';
import { pingFail, pingSuccess } from './actions';
import { PING_MAX_FAILS, PING_ROUNDTRIP_BEST, PING_ROUNDTRIP_WORST } from './constants';

export const pingReducer = (state = {}, action) => {
  const { type, payload } = action;
  if (type === pingSuccess.type) {
    const { roundtrip } = payload;
    const prevRoundtrip = state.roundtripAvg || roundtrip;
    const roundtripAvg = Math.round(prevRoundtrip * 0.4 + roundtrip * 0.6);
    const networkQuality = 1 - scale(roundtripAvg,
      PING_ROUNDTRIP_BEST, PING_ROUNDTRIP_WORST);
    return {
      roundtrip,
      roundtripAvg,
      failCount: 0,
      networkQuality,
    };
  }
  if (type === pingFail.type) {
    const { failCount = 0 } = state;
    const networkQuality = clamp01(state.networkQuality
      - failCount / PING_MAX_FAILS);
    const nextState = {
      ...state,
      failCount: failCount + 1,
      networkQuality,
    };
    if (failCount > PING_MAX_FAILS) {
      nextState.roundtrip = undefined;
      nextState.roundtripAvg = undefined;
    }
    return nextState;
  }
  return state;
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export const selectPing = state => state.ping;


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { toFixed } from 'common/math';
import { useLocalState } from 'tgui/backend';
import { useDispatch, useSelector } from 'common/redux';
import { Box, Button, ColorBox, Divider, Dropdown, Flex, Input, LabeledList, NumberInput, Section, Stack, Tabs, TextArea } from 'tgui/components';
import { ChatPageSettings } from '../chat';
import { rebuildChat, saveChatToDisk } from '../chat/actions';
import { THEMES } from '../themes';
import { changeSettingsTab, updateSettings } from './actions';
import { FONTS, SETTINGS_TABS } from './constants';
import { selectActiveTab, selectSettings } from './selectors';

export const SettingsPanel = (props, context) => {
  const activeTab = useSelector(context, selectActiveTab);
  const dispatch = useDispatch(context);
  return (
    <Stack fill>
      <Stack.Item>
        <Section fitted fill minHeight="8em">
          <Tabs vertical>
            {SETTINGS_TABS.map(tab => (
              <Tabs.Tab
                key={tab.id}
                selected={tab.id === activeTab}
                onClick={() => dispatch(changeSettingsTab({
                  tabId: tab.id,
                }))}>
                {tab.name}
              </Tabs.Tab>
            ))}
          </Tabs>
        </Section>
      </Stack.Item>
      <Stack.Item grow={1} basis={0}>
        {activeTab === 'general' && (
          <SettingsGeneral />
        )}
        {activeTab === 'chatPage' && (
          <ChatPageSettings />
        )}
      </Stack.Item>
    </Stack>
  );
};

export const SettingsGeneral = (props, context) => {
  const {
    theme,
    fontFamily,
    fontSize,
    lineHeight,
    highlightText,
    highlightColor,
    matchWord,
    matchCase,
  } = useSelector(context, selectSettings);
  const dispatch = useDispatch(context);
  const [freeFont, setFreeFont] = useLocalState(context, "freeFont", false);
  return (
    <Section>
      <LabeledList>
        <LabeledList.Item label="Theme">
          <Dropdown
            selected={theme}
            options={THEMES}
            onSelected={value => dispatch(updateSettings({
              theme: value,
            }))} />
        </LabeledList.Item>
        <LabeledList.Item label="Font style">
          <Stack inline align="baseline">
            <Stack.Item>
              {!freeFont && (
                <Dropdown
                  selected={fontFamily}
                  options={FONTS}
                  onSelected={value => dispatch(updateSettings({
                    fontFamily: value,
                  }))} />
              ) || (
                <Input
                  value={fontFamily}
                  onChange={(e, value) => dispatch(updateSettings({
                    fontFamily: value,
                  }))}
                />
              )}
            </Stack.Item>
            <Stack.Item>
              <Button
                content="Custom font"
                icon={freeFont? "lock-open" : "lock"}
                color={freeFont? "good" : "bad"}
                ml={1}
                onClick={() => {
                  setFreeFont(!freeFont);
                }}
              />
            </Stack.Item>
          </Stack>
        </LabeledList.Item>
        <LabeledList.Item label="Font size">
          <NumberInput
            width="4em"
            step={1}
            stepPixelSize={10}
            minValue={8}
            maxValue={32}
            value={fontSize}
            unit="px"
            format={value => toFixed(value)}
            onChange={(e, value) => dispatch(updateSettings({
              fontSize: value,
            }))} />
        </LabeledList.Item>
        <LabeledList.Item label="Line height">
          <NumberInput
            width="4em"
            step={0.01}
            stepPixelSize={2}
            minValue={0.8}
            maxValue={5}
            value={lineHeight}
            format={value => toFixed(value, 2)}
            onDrag={(e, value) => dispatch(updateSettings({
              lineHeight: value,
            }))} />
        </LabeledList.Item>
      </LabeledList>
      <Divider />
      <Box>
        <Flex mb={1} color="label" align="baseline">
          <Flex.Item grow={1}>
            Highlight text (comma separated):
          </Flex.Item>
          <Flex.Item shrink={0}>
            <ColorBox mr={1} color={highlightColor} />
            <Input
              width="5em"
              monospace
              placeholder="#ffffff"
              value={highlightColor}
              onInput={(e, value) => dispatch(updateSettings({
                highlightColor: value,
              }))} />
          </Flex.Item>
        </Flex>
        <TextArea
          height="3em"
          value={highlightText}
          onChange={(e, value) => dispatch(updateSettings({
            highlightText: value,
          }))} />
        <Button.Checkbox
          checked={matchWord}
          tooltipPosition="bottom-start"
          tooltip="Not compatible with punctuation."
          onClick={() => dispatch(updateSettings({
            matchWord: !matchWord,
          }))}>
          Match word
        </Button.Checkbox>
        <Button.Checkbox
          checked={matchCase}
          onClick={() => dispatch(updateSettings({
            matchCase: !matchCase,
          }))}>
          Match case
        </Button.Checkbox>
      </Box>
      <Divider />
      <Box>
        <Button
          icon="check"
          onClick={() => dispatch(rebuildChat())}>
          Apply now
        </Button>
        <Box inline fontSize="0.9em" ml={1} color="label">
          Can freeze the chat for a while.
        </Box>
      </Box>
      <Divider />
      <Button
        icon="save"
        onClick={() => dispatch(saveChatToDisk())}>
        Save chat log
      </Button>
    </Section>
  );
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { createAction } from 'common/redux';

export const updateSettings = createAction('settings/update');
export const loadSettings = createAction('settings/load');
export const changeSettingsTab = createAction('settings/changeTab');
export const toggleSettings = createAction('settings/toggle');
export const openChatSettings = createAction('settings/openChatTab');


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export const SETTINGS_TABS = [
  {
    id: 'general',
    name: 'General',
  },
  {
    id: 'chatPage',
    name: 'Chat Tabs',
  },
];

export const FONTS_DISABLED = "Default";

export const FONTS = [
  FONTS_DISABLED,
  'Courier New',
  'Verdana',
  'Arial',
  'Arial Black',
  'Comic Sans MS',
  'Impact',
  'Lucida Sans Unicode',
  'Tahoma',
  'Trebuchet MS',
  'Lucida Console',
];


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { useDispatch, useSelector } from 'common/redux';
import { updateSettings, toggleSettings } from './actions';
import { selectSettings } from './selectors';

export const useSettings = context => {
  const settings = useSelector(context, selectSettings);
  const dispatch = useDispatch(context);
  return {
    ...settings,
    visible: settings.view.visible,
    toggle: () => dispatch(toggleSettings()),
    update: obj => dispatch(updateSettings(obj)),
  };
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export { useSettings } from './hooks';
export { settingsMiddleware } from './middleware';
export { settingsReducer } from './reducer';
export { SettingsPanel } from './SettingsPanel';


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { storage } from 'common/storage';
import { setClientTheme } from '../themes';
import { loadSettings, updateSettings } from './actions';
import { selectSettings } from './selectors';
import { FONTS_DISABLED } from './constants';

const setGlobalFontSize = fontSize => {
  document.documentElement.style
    .setProperty('font-size', fontSize + 'px');
  document.body.style
    .setProperty('font-size', fontSize + 'px');
};

const setGlobalFontFamily = fontFamily => {
  if (fontFamily === FONTS_DISABLED) fontFamily = null;

  document.documentElement.style
    .setProperty('font-family', fontFamily);
  document.body.style
    .setProperty('font-family', fontFamily);
};

export const settingsMiddleware = store => {
  let initialized = false;
  return next => action => {
    const { type, payload } = action;
    if (!initialized) {
      initialized = true;
      storage.get('panel-settings').then(settings => {
        store.dispatch(loadSettings(settings));
      });
    }
    if (type === updateSettings.type || type === loadSettings.type) {
      // Set client theme
      const theme = payload?.theme;
      if (theme) {
        setClientTheme('dark');
      }
      // Pass action to get an updated state
      next(action);
      const settings = selectSettings(store.getState());
      // Update global UI font size
      setGlobalFontSize(settings.fontSize);
      setGlobalFontFamily(settings.fontFamily);
      // Save settings to the web storage
      storage.set('panel-settings', settings);
      return;
    }
    return next(action);
  };
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

import { changeSettingsTab, loadSettings, openChatSettings, toggleSettings, updateSettings } from './actions';
import { FONTS, SETTINGS_TABS } from './constants';

const initialState = {
  version: 1,
  fontSize: 13,
  fontFamily: 'Courier New',
  lineHeight: 1.2,
  theme: 'dark',
  adminMusicVolume: 0.5,
  highlightText: '',
  highlightColor: '#ffdd44',
  matchWord: false,
  matchCase: false,
  view: {
    visible: false,
    activeTab: SETTINGS_TABS[0].id,
  },
};

export const settingsReducer = (state = initialState, action) => {
  const { type, payload } = action;
  if (type === updateSettings.type) {
    return {
      ...state,
      ...payload,
    };
  }
  if (type === loadSettings.type) {
    // Validate version and/or migrate state
    if (!payload?.version) {
      return state;
    }
    delete payload.view;
    return {
      ...state,
      ...payload,
    };
  }
  if (type === toggleSettings.type) {
    return {
      ...state,
      view: {
        ...state.view,
        visible: !state.view.visible,
      },
    };
  }
  if (type === openChatSettings.type) {
    return {
      ...state,
      view: {
        ...state.view,
        visible: true,
        activeTab: 'chatPage',
      },
    };
  }
  if (type === changeSettingsTab.type) {
    const { tabId } = payload;
    return {
      ...state,
      view: {
        ...state.view,
        activeTab: tabId,
      },
    };
  }
  return state;
};


code:
/**
 * @file
 * @copyright 2020 Aleksej Komarov
 * @license MIT
 */

export const selectSettings = state => state.settings;
export const selectActiveTab = state => state.settings.view.activeTab;


code:
/**
 * Copyright (c) 2020 Aleksej Komarov
 * SPDX-License-Identifier: MIT
 */

@use 'sass:color';
@use 'sass:math';
@use '~tgui/styles/base.scss';
@use '~tgui/styles/colors.scss';

$text-color: #abc6ec !default;
$color-bg-section: base.$color-bg-section !default;

.Chat {
  color: $text-color;
}

.Chat__badge {
  display: inline-block;
  min-width: 0.5em;
  font-size: 0.7em;
  padding: 0.2em 0.3em;
  line-height: 1;
  color: white;
  text-align: center;
  white-space: nowrap;
  vertical-align: middle;
  background-color: crimson;
  border-radius: 10px;
  transition: font-size 200ms ease-out;

  &:before {
    content: 'x';
  }
}

.Chat__badge--animate {
  font-size: 0.9em;
  transition: font-size 0ms;
}

.Chat__scrollButton {
  position: fixed;
  right: 2em;
  bottom: 1em;
}

.Chat__reconnected {
  font-size: 0.85em;
  text-align: center;
  margin: 1em 0 2em;

  &:before {
    content: 'Reconnected';
    display: inline-block;
    border-radius: 1em;
    padding: 0 0.7em;
    color: colors.$red;
    background-color: $color-bg-section;
  }

  &:after {
    content: '';
    display: block;
    margin-top: -0.75em;
    border-bottom: math.div(1em, 6) solid colors.$red;
  }
}

.Chat__highlight {
  color: #000;
}

.Chat__highlight--restricted {
  color: #fff;
  background-color: #a00;
  font-weight: bold;
}

.ChatMessage {
  word-wrap: break-word;
}

.ChatMessage--highlighted {
  position: relative;
  border-left: math.div(1em, 6) solid rgba(255, 221, 68);
  padding-left: 0.5em;

  &:after {
    content: '';
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    background-color: rgba(255, 221, 68, 0.1);
    // Make this click-through since this is an overlay
    pointer-events: none;
  }
}


code:
/**
 * Copyright (c) 2020 Aleksej Komarov
 * SPDX-License-Identifier: MIT
 */

.Notifications {
  position: absolute;
  bottom: 1em;
  left: 1em;
  right: 2em;
}

.Notification {
  color: #fff;
  background-color: crimson;
  padding: 0.5em;
  margin: 1em 0;

  &:first-child {
    margin-top: 0;
  }

  &:last-child {
    margin-bottom: 0;
  }
}


code:
/**
 * Copyright (c) 2020 Aleksej Komarov
 * SPDX-License-Identifier: MIT
 */

@use 'sass:math';

$border-color: rgba(140, 140, 140, 0.5) !default;

.Ping {
  position: relative;
  padding: 0.125em 0.25em;
  border: math.div(1em, 12) solid $border-color;
  border-radius: 0.25em;
  width: 3.75em;
  text-align: right;
}

.Ping__indicator {
  content: '';
  position: absolute;
  top: 0.5em;
  left: 0.5em;
  width: 0.5em;
  height: 0.5em;
  background-color: #888;
  border-radius: 0.25em;
}