[MIRROR] tgchat part 1 (#7273)

Co-authored-by: Heroman3003 <31296024+Heroman3003@users.noreply.github.com>
Co-authored-by: Selis <selis@xynolabs.com>
This commit is contained in:
CHOMPStation2
2023-11-25 05:36:55 -07:00
committed by GitHub
parent 5e15494def
commit eb65b5721c
88 changed files with 6467 additions and 78 deletions

9
.gitignore vendored
View File

@@ -37,6 +37,7 @@ vchat.db*
*.lk
*.int
*.backup
### https://raw.github.com/github/gitignore/cc542de017c606138a87ee4880e5f06b3a306def/Global/Linux.gitignore
*~
@@ -117,6 +118,14 @@ $RECYCLE.BIN/
#Sublime
*.sublime-project
*.sublime-workspace
*.before
*.pyc
*.pid
cfg/
#Ignore everything in datafolder and subdirectories
/data/**/*
/tmp/**/*
#Visual studio stuff
*.vscode/*

26
code/__defines/chat.dm Normal file
View File

@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
#define MESSAGE_TYPE_SYSTEM "system"
#define MESSAGE_TYPE_LOCALCHAT "localchat"
#define MESSAGE_TYPE_PLOCALCHAT "plocalchat"
#define MESSAGE_TYPE_RADIO "radio"
#define MESSAGE_TYPE_NIF "nif"
#define MESSAGE_TYPE_INFO "info"
#define MESSAGE_TYPE_WARNING "warning"
#define MESSAGE_TYPE_DEADCHAT "deadchat"
#define MESSAGE_TYPE_OOC "ooc"
#define MESSAGE_TYPE_LOOC "looc"
#define MESSAGE_TYPE_ADMINPM "adminpm"
#define MESSAGE_TYPE_MENTORPM "mentorpm"
#define MESSAGE_TYPE_COMBAT "combat"
#define MESSAGE_TYPE_ADMINCHAT "adminchat"
#define MESSAGE_TYPE_PRAYER "prayer"
#define MESSAGE_TYPE_MODCHAT "modchat"
#define MESSAGE_TYPE_RLOOC "rlooc"
#define MESSAGE_TYPE_EVENTCHAT "eventchat"
#define MESSAGE_TYPE_ADMINLOG "adminlog"
#define MESSAGE_TYPE_ATTACKLOG "attacklog"
#define MESSAGE_TYPE_DEBUG "debug"

View File

@@ -0,0 +1,2 @@
#define SEND_TEXT(target, text) DIRECT_OUTPUT(target, text)
#define WRITE_FILE(file, text) DIRECT_OUTPUT(file, text)

View File

@@ -49,6 +49,26 @@
var/global/list/runlevel_flags = list(RUNLEVEL_LOBBY, RUNLEVEL_SETUP, RUNLEVEL_GAME, RUNLEVEL_POSTGAME)
#define RUNLEVEL_FLAG_TO_INDEX(flag) (log(2, flag) + 1) // Convert from the runlevel bitfield constants to index in runlevel_flags list
//! ### SS initialization hints
/**
* Negative values incidate a failure or warning of some kind, positive are good.
* 0 and 1 are unused so that TRUE and FALSE are guarenteed to be invalid values.
*/
/// Subsystem failed to initialize entirely. Print a warning, log, and disable firing.
#define SS_INIT_FAILURE -2
/// The default return value which must be overriden. Will succeed with a warning.
#define SS_INIT_NONE -1
/// Subsystem initialized sucessfully.
#define SS_INIT_SUCCESS 2
/// Successful, but don't print anything. Useful if subsystem was disabled.
#define SS_INIT_NO_NEED 3
//! ### SS initialization load orders
// Subsystem init_order, from highest priority to lowest priority
// Subsystems shutdown in the reverse of the order they initialize in
// The numbers just define the ordering, they are meaningless otherwise.
@@ -105,6 +125,7 @@ var/global/list/runlevel_flags = list(RUNLEVEL_LOBBY, RUNLEVEL_SETUP, RUNLEVEL_G
#define FIRE_PRIORITY_ORBIT 7
#define FIRE_PRIORITY_VOTE 8
#define FIRE_PRIORITY_INSTRUMENTS 9
#define FIRE_PRIORITY_PING 10
#define FIRE_PRIORITY_AI 10
#define FIRE_PRIORITY_GARBAGE 15
#define FIRE_PRIORITY_ALARM 20

View File

@@ -4,9 +4,9 @@
#define TGUI_WINDOW_HARD_LIMIT 9
/// Maximum ping timeout allowed to detect zombie windows
#define TGUI_PING_TIMEOUT 4 SECONDS
#define TGUI_PING_TIMEOUT (4 SECONDS)
/// Used for rate-limiting to prevent DoS by excessively refreshing a TGUI window
#define TGUI_REFRESH_FULL_UPDATE_COOLDOWN 5 SECONDS
#define TGUI_REFRESH_FULL_UPDATE_COOLDOWN (1 SECONDS)
/// Window does not exist
#define TGUI_WINDOW_CLOSED 0
@@ -36,4 +36,4 @@
#define TGUI_MODAL_OPEN 1
#define TGUI_MODAL_DELEGATE 2
#define TGUI_MODAL_ANSWER 3
#define TGUI_MODAL_CLOSE 4
#define TGUI_MODAL_CLOSE 4

View File

@@ -0,0 +1,2 @@
//These are a bunch of regex datums for use /((any|every|no|some|head|foot)where(wolf)?\sand\s)+(\.[\.\s]+\s?where\?)?/i
GLOBAL_DATUM_INIT(is_http_protocol, /regex, regex("^https?://"))

View File

@@ -270,10 +270,6 @@
if(Debug2)
WRITE_LOG(diary, "TOPIC: [text]")
/proc/log_href(text)
// Configs are checked by caller
WRITE_LOG(href_logfile, "HREF: [text]")
/proc/log_unit_test(text)
to_world_log("## UNIT_TEST: [text]")
@@ -283,22 +279,6 @@
#define log_reftracker(msg)
#endif
/proc/log_tgui(user_or_client, text)
if(!text)
stack_trace("Pointless log_tgui message")
return
var/entry = ""
if(!user_or_client)
entry += "no user"
// else if(istype(user_or_client, /mob)) //CHOMP Edit commenting out these blocks because it just seems to do nothing except spam the logs with... nothing.
// var/mob/user = user_or_client
// entry += "[user.ckey] (as [user])"
// else if(istype(user_or_client, /client))
// var/client/client = user_or_client
// entry += "[client.ckey]"
entry += ":\n[text]"
WRITE_LOG(diary, entry)
/proc/log_asset(text)
WRITE_LOG(diary, "ASSET: [text]")

View File

@@ -0,0 +1,36 @@
/proc/log_href(text)
//WRITE_LOG(GLOB.world_href_log, "HREF: [text]")
WRITE_LOG(href_logfile, "HREF: [text]")
/**
* Appends a tgui-related log entry. All arguments are optional.
*/
/proc/log_tgui(user, message, context,
datum/tgui_window/window,
datum/src_object)
var/entry = ""
// Insert user info
if(!user)
entry += "<nobody>"
else if(istype(user, /mob))
var/mob/mob = user
entry += "[mob.ckey] (as [mob] at [mob.x],[mob.y],[mob.z])"
else if(istype(user, /client))
var/client/client = user
entry += "[client.ckey]"
// Insert context
if(context)
entry += " in [context]"
else if(window)
entry += " in [window.id]"
// Resolve src_object
if(!src_object && window?.locked_by)
src_object = window.locked_by.src_object
// Insert src_object info
if(src_object)
entry += "\nUsing: [src_object.type] [REF(src_object)]"
// Insert message
if(message)
entry += "\n[message]"
//WRITE_LOG(GLOB.tgui_log, entry)
WRITE_LOG(diary, entry)

View File

@@ -1,13 +1,4 @@
//DEFINITIONS FOR ASSET DATUMS START HERE.
/datum/asset/simple/tgui
// keep_local_name = TRUE
assets = list(
"tgui.bundle.js" = file("tgui/public/tgui.bundle.js"),
"tgui.bundle.css" = file("tgui/public/tgui.bundle.css"),
)
/datum/asset/simple/headers
assets = list(
"alarm_green.gif" = 'icons/program_icons/alarm_green.gif',
@@ -156,11 +147,6 @@
// /datum/asset/simple/fontawesome
// )
/datum/asset/simple/jquery
assets = list(
"jquery.min.js" = 'code/modules/tooltip/jquery.min.js',
)
// /datum/asset/simple/goonchat
// assets = list(
// "json2.min.js" = 'code/modules/goonchat/browserassets/js/json2.min.js',
@@ -169,24 +155,6 @@
// "browserOutput_white.css" = 'code/modules/goonchat/browserassets/css/browserOutput_white.css',
// )
/datum/asset/simple/fontawesome
assets = list(
"fa-regular-400.eot" = 'html/font-awesome/webfonts/fa-regular-400.eot',
"fa-regular-400.woff" = 'html/font-awesome/webfonts/fa-regular-400.woff',
"fa-solid-900.eot" = 'html/font-awesome/webfonts/fa-solid-900.eot',
"fa-solid-900.woff" = 'html/font-awesome/webfonts/fa-solid-900.woff',
"font-awesome.css" = 'html/font-awesome/css/all.min.css',
"v4shim.css" = 'html/font-awesome/css/v4-shims.min.css'
)
/datum/asset/simple/tgfont
assets = list(
"tgfont.eot" = file("tgui/packages/tgfont/dist/tgfont.eot"),
"tgfont.woff2" = file("tgui/packages/tgfont/dist/tgfont.woff2"),
"tgfont.css" = file("tgui/packages/tgfont/dist/tgfont.css"),
)
// /datum/asset/spritesheet/goonchat
// name = "chat"

View File

@@ -0,0 +1,9 @@
/datum/asset/simple/fontawesome
assets = list(
"fa-regular-400.eot" = 'html/font-awesome/webfonts/fa-regular-400.eot',
"fa-regular-400.woff" = 'html/font-awesome/webfonts/fa-regular-400.woff',
"fa-solid-900.eot" = 'html/font-awesome/webfonts/fa-solid-900.eot',
"fa-solid-900.woff" = 'html/font-awesome/webfonts/fa-solid-900.woff',
"font-awesome.css" = 'html/font-awesome/css/all.min.css',
"v4shim.css" = 'html/font-awesome/css/v4-shims.min.css'
)

View File

@@ -0,0 +1,4 @@
/datum/asset/simple/jquery
assets = list(
"jquery.min.js" = 'code/modules/tooltip/jquery.min.js',
)

View File

@@ -0,0 +1,6 @@
/datum/asset/simple/tgfont
assets = list(
"tgfont.eot" = file("tgui/packages/tgfont/dist/tgfont.eot"),
"tgfont.woff2" = file("tgui/packages/tgfont/dist/tgfont.woff2"),
"tgfont.css" = file("tgui/packages/tgfont/dist/tgfont.css"),
)

View File

@@ -0,0 +1,15 @@
/datum/asset/simple/tgui
// keep_local_name = TRUE
assets = list(
"tgui.bundle.js" = file("tgui/public/tgui.bundle.js"),
"tgui.bundle.css" = file("tgui/public/tgui.bundle.css"),
)
/* Comment will be removed in later part
/datum/asset/simple/tgui_panel
// keep_local_name = TRUE
assets = list(
"tgui-panel.bundle.js" = file("tgui/public/tgui-panel.bundle.js"),
"tgui-panel.bundle.css" = file("tgui/public/tgui-panel.bundle.css"),
)
*/

View File

@@ -1,11 +1,42 @@
/**
* Client datum
*
* A datum that is created whenever a user joins a BYOND world, one will exist for every active connected
* player
*
* when they first connect, this client object is created and [/client/New] is called
*
* When they disconnect, this client object is deleted and [/client/Del] is called
*
* All client topic calls go through [/client/Topic] first, so a lot of our specialised
* topic handling starts here
*/
/client
//////////////////////
//BLACK MAGIC THINGS//
//////////////////////
/**
* This line makes clients parent type be a datum
*
* By default in byond if you define a proc on datums, that proc will exist on nearly every single type
* from icons to images to atoms to mobs to objs to turfs to areas, it won't however, appear on client
*
* instead by default they act like their own independent type so while you can do isdatum(icon)
* and have it return true, you can't do isdatum(client), it will always return false.
*
* This makes writing oo code hard, when you have to consider this extra special case
*
* This line prevents that, and has never appeared to cause any ill effects, while saving us an extra
* pain to think about
*
* This line is widely considered black fucking magic, and the fact it works is a puzzle to everyone
* involved, including the current engine developer, lummox
*
* If you are a future developer and the engine source is now available and you can explain why this
* is the way it is, please do update this comment
*/
parent_type = /datum
////////////////
//ADMIN THINGS//
////////////////
///Contains admin info. Null if client is not an admin.
var/datum/admins/holder = null
var/datum/admins/deadmin_holder = null
var/buildmode = 0
@@ -18,6 +49,7 @@
/////////
//OTHER//
/////////
///Player preferences datum for the client
var/datum/preferences/prefs = null
var/moving = null
var/adminobs = null

6
interface/fonts.dm Normal file
View File

@@ -0,0 +1,6 @@
/// A font datum, it exists to define a custom font to use in a span style later.
/datum/font
/// Font name, just so people know what to put in their span style.
var/name
/// The font file we link to.
var/font_family

View File

@@ -0,0 +1,28 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { Flex } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
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;

View File

@@ -0,0 +1,128 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { Button, Section, Stack } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
import { Pane } from 'tgui_ch/layouts'; // CHOMPEdit - tgui_ch
import { NowPlayingWidget, useAudio } from './audio';
import { ChatPanel, ChatTabs } from './chat';
import { useGame } from './game';
import { Notifications } from './Notifications';
import { PingIndicator } from './ping';
import { ReconnectButton } from './reconnect';
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_ch/debug'); // CHOMPEdit - tgui_ch
const debug = useDebug(context);
if (debug.kitchenSink) {
return <KitchenSink panel />;
}
}
return (
<Pane theme={settings.theme}>
<Stack fill vertical>
<Stack.Item>
<Section fitted>
<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>
</Section>
</Stack.Item>
{audio.visible && (
<Stack.Item>
<Section>
<NowPlayingWidget />
</Section>
</Stack.Item>
)}
{settings.visible && (
<Stack.Item>
<SettingsPanel />
</Stack.Item>
)}
<Stack.Item grow>
<Section fill fitted position="relative">
<Pane.Content scrollable>
<ChatPanel lineHeight={settings.lineHeight} />
</Pane.Content>
<Notifications>
{game.connectionLostAt && (
<Notifications.Item rightSlot={<ReconnectButton />}>
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>
</Section>
</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>
);
};

View File

@@ -0,0 +1,109 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { toFixed } from 'common/math';
import { useDispatch, useSelector } from 'common/redux';
import { Button, Collapsible, Flex, Knob, Section } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
import { useSettings } from '../settings';
import { selectAudio } from './selectors';
export const NowPlayingWidget = (props, context) => {
const audio = useSelector(context, selectAudio),
dispatch = useDispatch(context),
settings = useSettings(context),
title = audio.meta?.title,
URL = audio.meta?.link,
Artist = audio.meta?.artist || 'Unknown Artist',
upload_date = audio.meta?.upload_date || 'Unknown Date',
album = audio.meta?.album || 'Unknown Album',
duration = audio.meta?.duration,
date = !isNaN(upload_date)
? upload_date?.substring(0, 4) +
'-' +
upload_date?.substring(4, 6) +
'-' +
upload_date?.substring(6, 8)
: upload_date;
return (
<Flex align="center">
{(audio.playing && (
<Flex.Item
mx={0.5}
grow={1}
style={{
'white-space': 'nowrap',
'overflow': 'hidden',
'text-overflow': 'ellipsis',
}}>
{
<Collapsible title={title || 'Unknown Track'} color={'blue'}>
<Section>
{URL !== 'Song Link Hidden' && (
<Flex.Item grow={1} color="label">
URL: {URL}
</Flex.Item>
)}
<Flex.Item grow={1} color="label">
Duration: {duration}
</Flex.Item>
{Artist !== 'Song Artist Hidden' &&
Artist !== 'Unknown Artist' && (
<Flex.Item grow={1} color="label">
Artist: {Artist}
</Flex.Item>
)}
{album !== 'Song Album Hidden' && album !== 'Unknown Album' && (
<Flex.Item grow={1} color="label">
Album: {album}
</Flex.Item>
)}
{upload_date !== 'Song Upload Date Hidden' &&
upload_date !== 'Unknown Date' && (
<Flex.Item grow={1} color="label">
Uploaded: {date}
</Flex.Item>
)}
</Section>
</Collapsible>
}
</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>
);
};

View File

@@ -0,0 +1,17 @@
/**
* @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' }),
};
};

View File

@@ -0,0 +1,10 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export { useAudio } from './hooks';
export { audioMiddleware } from './middleware';
export { NowPlayingWidget } from './NowPlayingWidget';
export { audioReducer } from './reducer';

View File

@@ -0,0 +1,37 @@
/**
* @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);
};
};

View File

@@ -0,0 +1,117 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { createLogger } from 'tgui_ch/logging'; // CHOMPEdit - tgui_ch
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);
}
}

View File

@@ -0,0 +1,50 @@
/**
* @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;
};

View File

@@ -0,0 +1,7 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const selectAudio = (state) => state.audio;

View File

@@ -0,0 +1,89 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { useDispatch, useSelector } from 'common/redux';
import { Button, Collapsible, Divider, Input, Section, Stack } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
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 (
<Section>
<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 />
<Section title="Messages to display" level={2}>
{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>
</Section>
</Section>
);
};

View File

@@ -0,0 +1,73 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { shallowDiffers } from 'common/react';
import { Component, createRef } from 'inferno';
import { Button } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
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>
)}
</>
);
}
}

View File

@@ -0,0 +1,68 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { useDispatch, useSelector } from 'common/redux';
import { Box, Tabs, Flex, Button } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
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>
);
};

View File

@@ -0,0 +1,21 @@
/**
* @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');

View File

@@ -0,0 +1,148 @@
/**
* @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_PRAYER = 'prayer';
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, .abductor, .alien, .changeling',
},
{
type: MESSAGE_TYPE_INFO,
name: 'Info',
description: 'Non-urgent messages from the game and items',
selector:
'.notice:not(.pm), .adminnotice, .info, .sinister, .cult, .infoplain, .announce, .hear, .smallnotice, .holoparasite, .boldnotice',
},
{
type: MESSAGE_TYPE_WARNING,
name: 'Warnings',
description: 'Urgent messages from the game and items',
selector:
'.warning:not(.pm), .critical, .userdanger, .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, .adminobserverooc, .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',
},
{
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_PRAYER,
name: 'Prayers',
description: 'Prayers from players',
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,
},
];

View File

@@ -0,0 +1,11 @@
/**
* @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';

View File

@@ -0,0 +1,178 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import DOMPurify from 'dompurify';
import { storage } from 'common/storage';
import { loadSettings, updateSettings, addHighlightSetting, removeHighlightSetting, updateHighlightSetting } 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;
const sequences = [];
const sequences_requested = [];
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') {
let payload_obj;
try {
payload_obj = JSON.parse(payload);
} catch (err) {
return;
}
const sequence = payload_obj.sequence;
if (sequences.includes(sequence)) {
return;
}
const sequence_count = sequences.length;
seq_check: if (sequence_count > 0) {
if (sequences_requested.includes(sequence)) {
sequences_requested.splice(sequences_requested.indexOf(sequence), 1);
// if we are receiving a message we requested, we can stop reliability checks
break seq_check;
}
// cannot do reliability if we don't have any messages
const expected_sequence = sequences[sequence_count - 1] + 1;
if (sequence !== expected_sequence) {
for (
let requesting = expected_sequence;
requesting < sequence;
requesting++
) {
requested_sequences.push(requesting);
Byond.sendMessage('chat/resend', requesting);
}
}
}
chatRenderer.processBatch([payload_obj.content]);
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 ||
type === addHighlightSetting.type ||
type === removeHighlightSetting.type ||
type === updateHighlightSetting.type
) {
next(action);
const settings = selectSettings(store.getState());
chatRenderer.setHighlight(
settings.highlightSettings,
settings.highlightSettingById
);
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);
};
};

View File

@@ -0,0 +1,56 @@
/**
* @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) => {
let acceptedTypes = {};
for (let typeDef of MESSAGE_TYPES) {
acceptedTypes[typeDef.type] = !!typeDef.important;
}
return {
id: createUuid(),
name: 'New Tab',
acceptedTypes: 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);

View File

@@ -0,0 +1,183 @@
/**
* @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;
}
// Enable any filters that are not explicitly set, that are
// enabled by default on the main page.
// NOTE: This mutates acceptedTypes on the state.
for (let id of Object.keys(payload.pageById)) {
const page = payload.pageById[id];
const filters = page.acceptedTypes;
const defaultFilters = mainPage.acceptedTypes;
for (let type of Object.keys(defaultFilters)) {
if (filters[type] === undefined) {
filters[type] = defaultFilters[type];
}
}
}
// 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;
};

View File

@@ -0,0 +1,608 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { EventEmitter } from 'common/events';
import { classes } from 'common/react';
import { createLogger } from 'tgui_ch/logging'; // CHOMPEdit - tgui_ch
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 { render } from 'inferno';
import { canPageAcceptType, createMessage, isSameMessage } from './model';
import { highlightNode, linkifyNode } from './replaceInTextNode';
import { Tooltip } from '../../tgui_ch/components'; // CHOMPEdit - tgui_ch
const logger = createLogger('chatRenderer');
// We consider this as the smallest possible scroll offset
// that is still trackable.
const SCROLL_TRACKING_TOLERANCE = 24;
// List of injectable component names to the actual type
export const TGUI_CHAT_COMPONENTS = {
Tooltip,
};
// List of injectable attibute names mapped to their proper prop
// We need this because attibutes don't support lowercase names
export const TGUI_CHAT_ATTRIBUTES_TO_PROPS = {
'position': 'position',
'content': 'content',
};
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(highlightSettings, highlightSettingById) {
this.highlightParsers = null;
if (!highlightSettings) {
return;
}
highlightSettings.map((id) => {
const setting = highlightSettingById[id];
const text = setting.highlightText;
const highlightColor = setting.highlightColor;
const highlightWholeMessage = setting.highlightWholeMessage;
const matchWord = setting.matchWord;
const matchCase = setting.matchCase;
const allowedRegex = /^[a-z0-9_\-$/^[\s\]\\]+$/gi;
const regexEscapeCharacters = /[!#$%^&*)(+=.<>{}[\]:;'"|~`_\-\\/]/g;
const lines = String(text)
.split(',')
.map((str) => str.trim())
.filter(
(str) =>
// Must be longer than one character
str &&
str.length > 1 &&
// Must be alphanumeric (with some punctuation)
allowedRegex.test(str) &&
// Reset lastIndex so it does not mess up the next word
((allowedRegex.lastIndex = 0) || true)
);
let highlightWords;
let highlightRegex;
// Nothing to match, reset highlighting
if (lines.length === 0) {
return;
}
let regexExpressions = [];
// Organize each highlight entry into regex expressions and words
for (let line of lines) {
// Regex expression syntax is /[exp]/
if (line.charAt(0) === '/' && line.charAt(line.length - 1) === '/') {
const expr = line.substring(1, line.length - 1);
// Check if this is more than one character
if (/^(\[.*\]|\\.|.)$/.test(expr)) {
continue;
}
regexExpressions.push(expr);
} else {
// Lazy init
if (!highlightWords) {
highlightWords = [];
}
// We're not going to let regex characters fuck up our RegEx operation.
line = line.replace(regexEscapeCharacters, '\\$&');
highlightWords.push(line);
}
}
const regexStr = regexExpressions.join('|');
const flags = 'g' + (matchCase ? '' : 'i');
// We wrap this in a try-catch to ensure that broken regex doesn't break
// the entire chat.
try {
// setting regex overrides matchword
if (regexStr) {
highlightRegex = new RegExp('(' + regexStr + ')', flags);
} else {
const pattern = `${matchWord ? '\\b' : ''}(${highlightWords.join(
'|'
)})${matchWord ? '\\b' : ''}`;
highlightRegex = new RegExp(pattern, flags);
}
} catch {
// We just reset it if it's invalid.
highlightRegex = null;
}
// Lazy init
if (!this.highlightParsers) {
this.highlightParsers = [];
}
this.highlightParsers.push({
highlightWords,
highlightRegex,
highlightColor,
highlightWholeMessage,
});
});
}
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];
// prettier-ignore
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);
}
// Get all nodes in this message that want to be rendered like jsx
const nodes = node.querySelectorAll('[data-component]');
for (let i = 0; i < nodes.length; i++) {
const childNode = nodes[i];
const targetName = childNode.getAttribute('data-component');
// Let's pull out the attibute info we need
let outputProps = {};
for (let j = 0; j < childNode.attributes.length; j++) {
const attribute = childNode.attributes[j];
let working_value = attribute.nodeValue;
// We can't do the "if it has no value it's truthy" trick
// Because getAttribute returns "", not null. Hate IE
if (working_value === '$true') {
working_value = true;
} else if (working_value === '$false') {
working_value = false;
} else if (!isNaN(working_value)) {
const parsed_float = parseFloat(working_value);
if (!isNaN(parsed_float)) {
working_value = parsed_float;
}
}
let canon_name = attribute.nodeName.replace('data-', '');
// html attributes don't support upper case chars, so we need to map
canon_name = TGUI_CHAT_ATTRIBUTES_TO_PROPS[canon_name];
outputProps[canon_name] = working_value;
}
const oldHtml = { __html: childNode.innerHTML };
while (childNode.firstChild) {
childNode.removeChild(childNode.firstChild);
}
const Element = TGUI_CHAT_COMPONENTS[targetName];
/* eslint-disable react/no-danger */
render(
<Element {...outputProps}>
<span dangerouslySetInnerHTML={oldHtml} />
</Element>,
childNode
);
/* eslint-enable react/no-danger */
}
// Highlight text
if (!message.avoidHighlighting && this.highlightParsers) {
this.highlightParsers.map((parser) => {
const highlighted = highlightNode(
node,
parser.highlightRegex,
parser.highlightWords,
(text) => createHighlightNode(text, parser.highlightColor)
);
if (highlighted && parser.highlightWholeMessage) {
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.
// prettier-ignore
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
// prettier-ignore
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];
if (rule && typeof rule.cssText === 'string') {
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
// prettier-ignore
const pageHtml = '<!doctype html>\n'
+ '<html>\n'
+ '<head>\n'
+ '<title>SS13 Chat Log</title>\n'
+ '<style>\n' + cssText + '</style>\n'
+ '</head>\n'
+ '<body>\n'
+ '<div class="Chat">\n'
+ messagesHtml
+ '</div>\n'
+ '</body>\n'
+ '</html>\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__;

View File

@@ -0,0 +1,204 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Replaces text matching a regular expression with a custom node.
*/
const regexParseNode = (params) => {
const { node, regex, createNode, captureAdjust } = params;
const text = node.textContent;
const textLength = text.length;
let nodes;
let new_node;
let match;
let lastIndex = 0;
let fragment;
let n = 0;
let count = 0;
// eslint-disable-next-line no-cond-assign
while ((match = regex.exec(text))) {
n += 1;
// Safety check to prevent permanent
// client crashing
if (++count > 9999) {
return {};
}
// Lazy init fragment
if (!fragment) {
fragment = document.createDocumentFragment();
}
// Lazy init nodes
if (!nodes) {
nodes = [];
}
const matchText = captureAdjust ? captureAdjust(match[0]) : match[0];
const matchLength = matchText.length;
// If matchText is set to be a substring nested within the original
// matched text make sure to properly offset the index
const matchIndex = match.index + match[0].indexOf(matchText);
// Insert previous unmatched chunk
if (lastIndex < matchIndex) {
new_node = document.createTextNode(text.substring(lastIndex, matchIndex));
nodes.push(new_node);
fragment.appendChild(new_node);
}
lastIndex = matchIndex + matchLength;
// Create a wrapper node
new_node = createNode(matchText);
nodes.push(new_node);
fragment.appendChild(new_node);
}
if (fragment) {
// Insert the remaining unmatched chunk
if (lastIndex < textLength) {
new_node = document.createTextNode(text.substring(lastIndex, textLength));
nodes.push(new_node);
fragment.appendChild(new_node);
}
// Commit the fragment
node.parentNode.replaceChild(fragment, node);
}
return {
nodes: nodes,
n: n,
};
};
/**
* Replace text of a node with custom nades if they match
* a regex expression or are in a word list
*/
export const replaceInTextNode = (regex, words, createNode) => (node) => {
let nodes;
let result;
let n = 0;
if (regex) {
result = regexParseNode({
node: node,
regex: regex,
createNode: createNode,
});
nodes = result.nodes;
n += result.n;
}
if (words) {
let i = 0;
let wordRegexStr = '(';
for (let word of words) {
// Capture if the word is at the beginning, end, middle,
// or by itself in a message
wordRegexStr += `^${word}\\W|\\W${word}\\W|\\W${word}$|^${word}$`;
// Make sure the last character for the expression is NOT '|'
if (++i !== words.length) {
wordRegexStr += '|';
}
}
wordRegexStr += ')';
const wordRegex = new RegExp(wordRegexStr, 'gi');
if (regex && nodes) {
for (let a_node of nodes) {
result = regexParseNode({
node: a_node,
regex: wordRegex,
createNode: createNode,
captureAdjust: (str) => str.replace(/^\W|\W$/g, ''),
});
n += result.n;
}
} else {
result = regexParseNode({
node: node,
regex: wordRegex,
createNode: createNode,
captureAdjust: (str) => str.replace(/^\W|\W$/g, ''),
});
n += result.n;
}
}
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,
words,
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, words, createNode)(node);
} else {
n += highlightNode(node, regex, words, createNode);
}
}
return n;
};
// Linkify
// --------------------------------------------------------
// prettier-ignore
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, null, (text) => {
const node = document.createElement('a');
node.href = text;
node.textContent = text;
return node;
});

View File

@@ -0,0 +1,17 @@
/**
* @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];

View File

@@ -0,0 +1,11 @@
/**
* @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');

View File

@@ -0,0 +1,7 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const CONNECTION_LOST_AFTER = 20000;

View File

@@ -0,0 +1,12 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { useSelector } from 'common/redux';
import { selectGame } from './selectors';
export const useGame = (context) => {
return useSelector(context, selectGame);
};

View File

@@ -0,0 +1,9 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export { useGame } from './hooks';
export { gameMiddleware } from './middleware';
export { gameReducer } from './reducer';

View File

@@ -0,0 +1,53 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { pingSoft, 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 } = action;
if (type === pingSuccess.type || type === pingSoft.type) {
lastPingedAt = Date.now();
return next(action);
}
if (type === roundRestarted.type) {
return next(withTimestamp(action));
}
return next(action);
};
};

View File

@@ -0,0 +1,39 @@
/**
* @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;
};

View File

@@ -0,0 +1,7 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const selectGame = (state) => state.game;

View File

@@ -0,0 +1,114 @@
/**
* @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_ch/events'; // CHOMPEdit - tgui_ch
import { captureExternalLinks } from 'tgui_ch/links'; // CHOMPEdit - tgui_ch
import { createRenderer } from 'tgui_ch/renderer'; // CHOMPEdit - tgui_ch
import { configureStore, StoreProvider } from 'tgui_ch/store'; // CHOMPEdit - tgui_ch
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();
// Re-render UI on store updates
store.subscribe(renderApp);
// Dispatch incoming messages as store actions
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
// 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();
// prettier-ignore
module.hot.accept([
'./audio',
'./chat',
'./game',
'./Notifications',
'./Panel',
'./ping',
'./settings',
'./telemetry',
], () => {
renderApp();
});
}
};
setupApp();

View File

@@ -0,0 +1,13 @@
{
"private": true,
"name": "tgui-panel",
"version": "4.3.1",
"dependencies": {
"common": "workspace:*",
"dompurify": "^2.3.1",
"inferno": "^7.4.8",
"tgui-dev-server": "workspace:*",
"tgui-polyfill": "workspace:*",
"tgui_ch": "workspace:*"
}
}

View File

@@ -0,0 +1,47 @@
/**
* 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_ch/events'; // CHOMPEdit - tgui_ch
import { focusMap } from 'tgui_ch/focus'; // CHOMPEdit - tgui_ch
// 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();
});
};

View File

@@ -0,0 +1,27 @@
/**
* @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_ch/components'; // CHOMPEdit - tgui_ch
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>
);
};

View File

@@ -0,0 +1,25 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { createAction } from 'common/redux';
export const pingReply = createAction('ping/reply');
/**
* Soft ping from the server.
* It's intended to send periodic server-side metadata about the client,
* e.g. its AFK status.
*/
export const pingSoft = createAction('ping/soft');
export const pingSuccess = createAction('ping/success', (ping) => ({
payload: {
lastId: ping.id,
roundtrip: (Date.now() - ping.sentAt) * 0.5,
},
}));
export const pingFail = createAction('ping/fail');

View File

@@ -0,0 +1,11 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
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;

View File

@@ -0,0 +1,9 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export { pingMiddleware } from './middleware';
export { PingIndicator } from './PingIndicator';
export { pingReducer } from './reducer';

View File

@@ -0,0 +1,60 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { pingFail, pingReply, pingSoft, pingSuccess } from './actions';
import { PING_QUEUE_SIZE, PING_TIMEOUT } from './constants';
export const pingMiddleware = (store) => {
let initialized = false;
let index = 0;
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;
Byond.sendMessage('ping', { index });
index = (index + 1) % PING_QUEUE_SIZE;
};
return (next) => (action) => {
const { type, payload } = action;
if (!initialized) {
initialized = true;
sendPing();
}
if (type === pingSoft.type) {
const { afk } = payload;
// On each soft ping where client is not flagged as afk,
// initiate a new ping.
if (!afk) {
sendPing();
}
return next(action);
}
if (type === pingReply.type) {
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);
};
};

View File

@@ -0,0 +1,46 @@
/**
* @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;
};

View File

@@ -0,0 +1,7 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const selectPing = (state) => state.ping;

View File

@@ -0,0 +1,37 @@
import { Button } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
let url: string | null = null;
setInterval(() => {
Byond.winget('', 'url').then((currentUrl) => {
// Sometimes, for whatever reason, BYOND will give an IP with a :0 port.
if (currentUrl && !currentUrl.match(/:0$/)) {
url = currentUrl;
}
});
}, 5000);
export const ReconnectButton = () => {
if (!url) {
return null;
}
return (
<>
<Button
color="white"
onClick={() => {
Byond.command('.reconnect');
}}>
Reconnect
</Button>
<Button
color="white"
onClick={() => {
location.href = `byond://${url}`;
Byond.command('.quit');
}}>
Relaunch game
</Button>
</>
);
};

View File

@@ -0,0 +1,311 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { toFixed } from 'common/math';
import { useLocalState } from 'tgui_ch/backend'; // CHOMPEdit - tgui_ch
import { useDispatch, useSelector } from 'common/redux';
import { Box, Button, ColorBox, Divider, Dropdown, Flex, Input, LabeledList, NumberInput, Section, Stack, Tabs, TextArea } from 'tgui_ch/components'; // CHOMPEdit - tgui_ch
import { ChatPageSettings } from '../chat';
import { rebuildChat, saveChatToDisk } from '../chat/actions';
import { THEMES } from '../themes';
import { changeSettingsTab, updateSettings, addHighlightSetting, removeHighlightSetting, updateHighlightSetting } from './actions';
import { SETTINGS_TABS, FONTS, MAX_HIGHLIGHT_SETTINGS } from './constants';
import { selectActiveTab, selectSettings, selectHighlightSettings, selectHighlightSettingById } 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 />}
{activeTab === 'textHighlight' && <TextHighlightSettings />}
</Stack.Item>
</Stack>
);
};
export const SettingsGeneral = (props, context) => {
const { theme, fontFamily, fontSize, lineHeight } = 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 />
<Button icon="save" onClick={() => dispatch(saveChatToDisk())}>
Save chat log
</Button>
</Section>
);
};
const TextHighlightSettings = (props, context) => {
const highlightSettings = useSelector(context, selectHighlightSettings);
const dispatch = useDispatch(context);
return (
<Section fill scrollable height="200px">
<Section p={0}>
<Flex direction="column">
{highlightSettings.map((id, i) => (
<TextHighlightSetting
key={i}
id={id}
mb={i + 1 === highlightSettings.length ? 0 : '10px'}
/>
))}
{highlightSettings.length < MAX_HIGHLIGHT_SETTINGS && (
<Flex.Item>
<Button
color="transparent"
icon="plus"
content="Add Highlight Setting"
onClick={() => {
dispatch(addHighlightSetting());
}}
/>
</Flex.Item>
)}
</Flex>
</Section>
<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>
</Section>
);
};
const TextHighlightSetting = (props, context) => {
const { id, ...rest } = props;
const highlightSettingById = useSelector(context, selectHighlightSettingById);
const dispatch = useDispatch(context);
const {
highlightColor,
highlightText,
highlightWholeMessage,
matchWord,
matchCase,
} = highlightSettingById[id];
return (
<Flex.Item {...rest}>
<Flex mb={1} color="label" align="baseline">
<Flex.Item grow>
<Button
content="Delete"
color="transparent"
icon="times"
onClick={() =>
dispatch(
removeHighlightSetting({
id: id,
})
)
}
/>
</Flex.Item>
<Flex.Item>
<Button.Checkbox
checked={highlightWholeMessage}
content="Whole Message"
tooltip="If this option is selected, the entire message will be highlighted in yellow."
mr="5px"
onClick={() =>
dispatch(
updateHighlightSetting({
id: id,
highlightWholeMessage: !highlightWholeMessage,
})
)
}
/>
</Flex.Item>
<Flex.Item>
<Button.Checkbox
content="Exact"
checked={matchWord}
tooltipPosition="bottom-start"
tooltip="If this option is selected, only exact matches (no extra letters before or after) will trigger. Not compatible with punctuation. Overriden if regex is used."
onClick={() =>
dispatch(
updateHighlightSetting({
id: id,
matchWord: !matchWord,
})
)
}
/>
</Flex.Item>
<Flex.Item>
<Button.Checkbox
content="Case"
tooltip="If this option is selected, the highlight will be case-sensitive."
checked={matchCase}
onClick={() =>
dispatch(
updateHighlightSetting({
id: id,
matchCase: !matchCase,
})
)
}
/>
</Flex.Item>
<Flex.Item shrink={0}>
<ColorBox mr={1} color={highlightColor} />
<Input
width="5em"
monospace
placeholder="#ffffff"
value={highlightColor}
onInput={(e, value) =>
dispatch(
updateHighlightSetting({
id: id,
highlightColor: value,
})
)
}
/>
</Flex.Item>
</Flex>
<TextArea
height="3em"
value={highlightText}
placeholder="Put words to highlight here. Separate terms with commas, i.e. (term1, term2, term3)"
onChange={(e, value) =>
dispatch(
updateHighlightSetting({
id: id,
highlightText: value,
})
)
}
/>
</Flex.Item>
);
};

View File

@@ -0,0 +1,26 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { createAction } from 'common/redux';
import { createHighlightSetting } from './model';
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');
export const addHighlightSetting = createAction(
'settings/addHighlightSetting',
() => ({
payload: createHighlightSetting(),
})
);
export const removeHighlightSetting = createAction(
'settings/removeHighlightSetting'
);
export const updateHighlightSetting = createAction(
'settings/updateHighlightSetting'
);

View File

@@ -0,0 +1,39 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const SETTINGS_TABS = [
{
id: 'general',
name: 'General',
},
{
id: 'textHighlight',
name: 'Text Highlights',
},
{
id: 'chatPage',
name: 'Chat Tabs',
},
];
export const FONTS_DISABLED = 'Default';
export const FONTS = [
FONTS_DISABLED,
'Verdana',
'Arial',
'Arial Black',
'Comic Sans MS',
'Impact',
'Lucida Sans Unicode',
'Tahoma',
'Trebuchet MS',
'Courier New',
'Lucida Console',
];
export const MAX_HIGHLIGHT_SETTINGS = 10;

View File

@@ -0,0 +1,20 @@
/**
* @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)),
};
};

View File

@@ -0,0 +1,10 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export { useSettings } from './hooks';
export { settingsMiddleware } from './middleware';
export { settingsReducer } from './reducer';
export { SettingsPanel } from './SettingsPanel';

View File

@@ -0,0 +1,83 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { storage } from 'common/storage';
import { setClientTheme } from '../themes';
import { loadSettings, updateSettings, addHighlightSetting, removeHighlightSetting, updateHighlightSetting } from './actions';
import { selectSettings } from './selectors';
import { FONTS_DISABLED } from './constants';
let overrideRule = null;
let overrideFontFamily = null;
let overrideFontSize = null;
const updateGlobalOverrideRule = () => {
let fontFamily = '';
if (overrideFontFamily !== null) {
fontFamily = `font-family: ${overrideFontFamily} !important;`;
}
const constructedRule = `body * :not(.Icon) {
${fontFamily}
}`;
if (overrideRule === null) {
overrideRule = document.createElement('style');
document.querySelector('head').append(overrideRule);
}
// no other way to force a CSS refresh other than to update its innerText
overrideRule.innerText = constructedRule;
document.body.style.setProperty('font-size', overrideFontSize);
};
const setGlobalFontSize = (fontSize) => {
overrideFontSize = `${fontSize}px`;
};
const setGlobalFontFamily = (fontFamily) => {
if (fontFamily === FONTS_DISABLED) fontFamily = null;
overrideFontFamily = 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 ||
type === addHighlightSetting.type ||
type === removeHighlightSetting.type ||
type === updateHighlightSetting.type
) {
// Set client theme
const theme = payload?.theme;
if (theme) {
setClientTheme(theme);
}
// 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);
updateGlobalOverrideRule();
// Save settings to the web storage
storage.set('panel-settings', settings);
return;
}
return next(action);
};
};

View File

@@ -0,0 +1,20 @@
/**
* @file
*/
import { createUuid } from 'common/uuid';
export const createHighlightSetting = (obj) => ({
id: createUuid(),
highlightText: '',
highlightColor: '#ffdd44',
highlightWholeMessage: true,
matchWord: false,
matchCase: false,
...obj,
});
export const createDefaultHighlightSetting = (obj) =>
createHighlightSetting({
id: 'default',
...obj,
});

View File

@@ -0,0 +1,175 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { changeSettingsTab, loadSettings, openChatSettings, toggleSettings, updateSettings, addHighlightSetting, removeHighlightSetting, updateHighlightSetting } from './actions';
import { createDefaultHighlightSetting } from './model';
import { SETTINGS_TABS, FONTS, MAX_HIGHLIGHT_SETTINGS } from './constants';
const defaultHighlightSetting = createDefaultHighlightSetting();
const initialState = {
version: 1,
fontSize: 13,
fontFamily: FONTS[0],
lineHeight: 1.2,
theme: 'light',
adminMusicVolume: 0.5,
// Keep these two state vars for compatibility with other servers
highlightText: '',
highlightColor: '#ffdd44',
// END compatibility state vars
highlightSettings: [defaultHighlightSetting.id],
highlightSettingById: {
[defaultHighlightSetting.id]: defaultHighlightSetting,
},
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;
const nextState = {
...state,
...payload,
};
// Lazy init the list for compatibility reasons
if (!nextState.highlightSettings) {
nextState.highlightSettings = [defaultHighlightSetting.id];
nextState.highlightSettingById[defaultHighlightSetting.id] =
defaultHighlightSetting;
}
// Compensating for mishandling of default highlight settings
else if (!nextState.highlightSettingById[defaultHighlightSetting.id]) {
nextState.highlightSettings = [
defaultHighlightSetting.id,
...nextState.highlightSettings,
];
nextState.highlightSettingById[defaultHighlightSetting.id] =
defaultHighlightSetting;
}
// Update the highlight settings for default highlight
// settings compatibility
const highlightSetting =
nextState.highlightSettingById[defaultHighlightSetting.id];
highlightSetting.highlightColor = nextState.highlightColor;
highlightSetting.highlightText = nextState.highlightText;
return nextState;
}
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,
},
};
}
if (type === addHighlightSetting.type) {
const highlightSetting = payload;
if (state.highlightSettings.length >= MAX_HIGHLIGHT_SETTINGS) {
return state;
}
return {
...state,
highlightSettings: [...state.highlightSettings, highlightSetting.id],
highlightSettingById: {
...state.highlightSettingById,
[highlightSetting.id]: highlightSetting,
},
};
}
if (type === removeHighlightSetting.type) {
const { id } = payload;
const nextState = {
...state,
highlightSettings: [...state.highlightSettings],
highlightSettingById: {
...state.highlightSettingById,
},
};
if (id === defaultHighlightSetting.id) {
nextState.highlightSettings[defaultHighlightSetting.id] =
defaultHighlightSetting;
} else {
delete nextState.highlightSettingById[id];
nextState.highlightSettings = nextState.highlightSettings.filter(
(sid) => sid !== id
);
if (!nextState.highlightSettings.length) {
nextState.highlightSettings.push(defaultHighlightSetting.id);
nextState.highlightSettingById[defaultHighlightSetting.id] =
defaultHighlightSetting;
}
}
return nextState;
}
if (type === updateHighlightSetting.type) {
const { id, ...settings } = payload;
const nextState = {
...state,
highlightSettings: [...state.highlightSettings],
highlightSettingById: {
...state.highlightSettingById,
},
};
// Transfer this data from the default highlight setting
// so they carry over to other servers
if (id === defaultHighlightSetting.id) {
if (settings.highlightText) {
nextState.highlightText = settings.highlightText;
}
if (settings.highlightColor) {
nextState.highlightColor = settings.highlightColor;
}
}
if (nextState.highlightSettingById[id]) {
nextState.highlightSettingById[id] = {
...nextState.highlightSettingById[id],
...settings,
};
}
return nextState;
}
return state;
};

View File

@@ -0,0 +1,12 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const selectSettings = (state) => state.settings;
export const selectActiveTab = (state) => state.settings.view.activeTab;
export const selectHighlightSettings = (state) =>
state.settings.highlightSettings;
export const selectHighlightSettingById = (state) =>
state.settings.highlightSettingById;

View File

@@ -0,0 +1,100 @@
/**
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
@use 'sass:color';
@use 'sass:math';
@use '~tgui_ch/styles/base.scss'; // CHOMPEdit - tgui_ch
@use '~tgui_ch/styles/colors.scss'; // CHOMPEdit - tgui_ch
$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;
}
}

View File

@@ -0,0 +1,26 @@
/**
* 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;
}
}

View File

@@ -0,0 +1,28 @@
/**
* 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;
}

View File

@@ -0,0 +1,62 @@
/**
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
@use 'sass:meta';
@use 'sass:color';
// CHOMPEdit Start - tgui_ch
@use '~tgui_ch/styles/colors.scss';
@use '~tgui_ch/styles/base.scss' with (
$color-bg: #202020,
$color-bg-section: color.adjust(#202020, $lightness: -5%),
$color-bg-grad-spread: 0%,
);
// Core styles
@include meta.load-css('~tgui_ch/styles/reset.scss');
// Atomic classes
@include meta.load-css('~tgui_ch/styles/atomic/candystripe.scss');
@include meta.load-css('~tgui_ch/styles/atomic/color.scss');
@include meta.load-css('~tgui_ch/styles/atomic/debug-layout.scss');
@include meta.load-css('~tgui_ch/styles/atomic/outline.scss');
@include meta.load-css('~tgui_ch/styles/atomic/text.scss');
// Components
@include meta.load-css('~tgui_ch/styles/components/BlockQuote.scss');
@include meta.load-css('~tgui_ch/styles/components/Button.scss');
@include meta.load-css('~tgui_ch/styles/components/ColorBox.scss');
@include meta.load-css('~tgui_ch/styles/components/Dimmer.scss');
@include meta.load-css('~tgui_ch/styles/components/Divider.scss');
@include meta.load-css('~tgui_ch/styles/components/Dropdown.scss');
@include meta.load-css('~tgui_ch/styles/components/Flex.scss');
@include meta.load-css('~tgui_ch/styles/components/Input.scss');
@include meta.load-css('~tgui_ch/styles/components/Knob.scss');
@include meta.load-css('~tgui_ch/styles/components/LabeledList.scss');
@include meta.load-css('~tgui_ch/styles/components/Modal.scss');
@include meta.load-css('~tgui_ch/styles/components/NoticeBox.scss');
@include meta.load-css('~tgui_ch/styles/components/NumberInput.scss');
@include meta.load-css('~tgui_ch/styles/components/ProgressBar.scss');
@include meta.load-css('~tgui_ch/styles/components/Section.scss');
@include meta.load-css('~tgui_ch/styles/components/Slider.scss');
@include meta.load-css('~tgui_ch/styles/components/Stack.scss');
@include meta.load-css('~tgui_ch/styles/components/Table.scss');
@include meta.load-css('~tgui_ch/styles/components/Tabs.scss');
@include meta.load-css('~tgui_ch/styles/components/TextArea.scss');
@include meta.load-css('~tgui_ch/styles/components/Tooltip.scss');
// Components specific to tgui-panel
@include meta.load-css('./components/Chat.scss');
@include meta.load-css('./components/Ping.scss');
@include meta.load-css('./components/Notifications.scss');
// Layouts
@include meta.load-css('~tgui_ch/styles/layouts/Layout.scss');
// @include meta.load-css('~tgui_ch/styles/layouts/TitleBar.scss');
@include meta.load-css('~tgui_ch/styles/layouts/Window.scss');
// CHOMPEdit End
// tgchat styles
@include meta.load-css('./tgchat/chat-dark.scss');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
/**
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
@use 'sass:color';
@use 'sass:meta';
// CHOMPEdit Start - tgui_ch
@use '~tgui_ch/styles/colors.scss' with (
$primary: #ffffff,
$bg-lightness: -25%,
$fg-lightness: -10%,
$label: #3b3b3b,
// Makes button look actually grey due to weird maths.
$grey: #ffffff,
// Commenting out color maps will adjust all colors based on the lightness
// settings above, but will add extra 10KB to the theme.
// $fg-map-keys: (),
// $bg-map-keys: (),
);
@use '~tgui_ch/styles/base.scss' with (
$color-fg: #000000,
$color-bg: #eeeeee,
$color-bg-section: #ffffff,
$color-bg-grad-spread: 0%,
);
// A fat warning to anyone who wants to use this: this only half works.
// It was made almost purely for the nuke ui, and requires a good amount of manual hacks to get it working as intended.
.theme-light {
// Atomic classes
@include meta.load-css('~tgui_ch/styles/atomic/color.scss');
// Components
@include meta.load-css(
'~tgui_ch/styles/components/Tabs.scss',
$with: ('text-color': rgba(0, 0, 0, 0.5), 'color-default': rgba(0, 0, 0, 1))
);
@include meta.load-css('~tgui_ch/styles/components/Section.scss');
@include meta.load-css(
'~tgui_ch/styles/components/Button.scss',
$with: (
'color-default': #bbbbbb,
'color-disabled': #363636,
'color-selected': #0668b8,
'color-caution': #be6209,
'color-danger': #9a9d00,
'color-transparent-text': rgba(0, 0, 0, 0.5)
)
);
@include meta.load-css(
'~tgui_ch/styles/components/Input.scss',
$with: (
'border-color': colors.fg(colors.$label),
'background-color': #ffffff
)
);
@include meta.load-css('~tgui_ch/styles/components/NumberInput.scss');
@include meta.load-css('~tgui_ch/styles/components/TextArea.scss');
@include meta.load-css('~tgui_ch/styles/components/Knob.scss');
@include meta.load-css('~tgui_ch/styles/components/Slider.scss');
@include meta.load-css('~tgui_ch/styles/components/ProgressBar.scss');
// Components specific to tgui-panel
@include meta.load-css(
'../components/Chat.scss',
$with: ('text-color': #000000)
);
// Layouts
@include meta.load-css(
'~tgui_ch/styles/layouts/Layout.scss',
$with: ('scrollbar-color-multiplier': -1)
);
@include meta.load-css('~tgui_ch/styles/layouts/Window.scss');
@include meta.load-css(
'~tgui_ch/styles/layouts/TitleBar.scss',
$with: (
'text-color': rgba(0, 0, 0, 0.75),
'background-color': base.$color-bg,
'shadow-color-core': rgba(0, 0, 0, 0.25)
)
);
// CHOMPEdit End
// tgchat styles
@include meta.load-css('../tgchat/chat-light.scss');
}

View File

@@ -0,0 +1,90 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { storage } from 'common/storage';
import { createLogger } from 'tgui_ch/logging'; // CHOMPEdit - tgui_ch
const logger = createLogger('telemetry');
const MAX_CONNECTIONS_STORED = 10;
// prettier-ignore
const connectionsMatch = (a, b) => (
a.ckey === b.ckey
&& a.address === b.address
&& a.computer_id === b.computer_id
);
export const telemetryMiddleware = (store) => {
let telemetry;
let wasRequestedWithPayload;
return (next) => (action) => {
const { type, payload } = action;
// Handle telemetry requests
if (type === 'telemetry/request') {
// Defer telemetry request until we have the actual telemetry
if (!telemetry) {
logger.debug('deferred');
wasRequestedWithPayload = payload;
return;
}
logger.debug('sending');
const limits = payload?.limits || {};
// Trim connections according to the server limit
const connections = telemetry.connections.slice(0, limits.connections);
Byond.sendMessage('telemetry', { connections });
return;
}
// Keep telemetry up to date
if (type === 'backend/update') {
next(action);
(async () => {
// Extract client data
const client = payload?.config?.client;
if (!client) {
logger.error('backend/update payload is missing client data!');
return;
}
// Load telemetry
if (!telemetry) {
telemetry = (await storage.get('telemetry')) || {};
if (!telemetry.connections) {
telemetry.connections = [];
}
logger.debug('retrieved telemetry from storage', telemetry);
}
// Append a connection record
let telemetryMutated = false;
// prettier-ignore
const duplicateConnection = telemetry.connections
.find(conn => connectionsMatch(conn, client));
if (!duplicateConnection) {
telemetryMutated = true;
telemetry.connections.unshift(client);
if (telemetry.connections.length > MAX_CONNECTIONS_STORED) {
telemetry.connections.pop();
}
}
// Save telemetry
if (telemetryMutated) {
logger.debug('saving telemetry to storage', telemetry);
storage.set('telemetry', telemetry);
}
// Continue deferred telemetry requests
if (wasRequestedWithPayload) {
const payload = wasRequestedWithPayload;
wasRequestedWithPayload = null;
store.dispatch({
type: 'telemetry/request',
payload,
});
}
})();
return;
}
return next(action);
};
};

View File

@@ -0,0 +1,138 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const THEMES = ['light', 'dark'];
const COLOR_DARK_BG = '#202020';
const COLOR_DARK_BG_DARKER = '#171717';
const COLOR_DARK_TEXT = '#a4bad6';
let setClientThemeTimer = null;
/**
* Darkmode preference, originally by Kmc2000.
*
* This lets you switch client themes by using winset.
*
* If you change ANYTHING in interface/skin.dmf you need to change it here.
*
* There's no way round it. We're essentially changing the skin by hand.
* It's painful but it works, and is the way Lummox suggested.
*/
export const setClientTheme = (name) => {
// Transmit once for fast updates and again in a little while in case we won
// the race against statbrowser init.
clearInterval(setClientThemeTimer);
Byond.command(`.output statbrowser:set_theme ${name}`);
setClientThemeTimer = setTimeout(() => {
Byond.command(`.output statbrowser:set_theme ${name}`);
}, 1500);
if (name === 'light') {
return Byond.winset({
// Main windows
'infowindow.background-color': 'none',
'infowindow.text-color': '#000000',
'info.background-color': 'none',
'info.text-color': '#000000',
'browseroutput.background-color': 'none',
'browseroutput.text-color': '#000000',
'outputwindow.background-color': 'none',
'outputwindow.text-color': '#000000',
'mainwindow.background-color': 'none',
'split.background-color': 'none',
// Buttons
'changelog.background-color': 'none',
'changelog.text-color': '#000000',
'rules.background-color': 'none',
'rules.text-color': '#000000',
'wiki.background-color': 'none',
'wiki.text-color': '#000000',
'forum.background-color': 'none',
'forum.text-color': '#000000',
'github.background-color': 'none',
'github.text-color': '#000000',
'report-issue.background-color': 'none',
'report-issue.text-color': '#000000',
// Status and verb tabs
'output.background-color': 'none',
'output.text-color': '#000000',
'statwindow.background-color': 'none',
'statwindow.text-color': '#000000',
'stat.background-color': '#FFFFFF',
'stat.tab-background-color': 'none',
'stat.text-color': '#000000',
'stat.tab-text-color': '#000000',
'stat.prefix-color': '#000000',
'stat.suffix-color': '#000000',
// Say, OOC, me Buttons etc.
'saybutton.background-color': 'none',
'saybutton.text-color': '#000000',
'oocbutton.background-color': 'none',
'oocbutton.text-color': '#000000',
'mebutton.background-color': 'none',
'mebutton.text-color': '#000000',
'asset_cache_browser.background-color': 'none',
'asset_cache_browser.text-color': '#000000',
'tooltip.background-color': 'none',
'tooltip.text-color': '#000000',
'input.background-color': '#FFFFFF',
'input.text-color': '#000000',
});
}
if (name === 'dark') {
Byond.winset({
// Main windows
'infowindow.background-color': COLOR_DARK_BG,
'infowindow.text-color': COLOR_DARK_TEXT,
'info.background-color': COLOR_DARK_BG,
'info.text-color': COLOR_DARK_TEXT,
'browseroutput.background-color': COLOR_DARK_BG,
'browseroutput.text-color': COLOR_DARK_TEXT,
'outputwindow.background-color': COLOR_DARK_BG,
'outputwindow.text-color': COLOR_DARK_TEXT,
'mainwindow.background-color': COLOR_DARK_BG,
'split.background-color': COLOR_DARK_BG,
// Buttons
'changelog.background-color': '#494949',
'changelog.text-color': COLOR_DARK_TEXT,
'rules.background-color': '#494949',
'rules.text-color': COLOR_DARK_TEXT,
'wiki.background-color': '#494949',
'wiki.text-color': COLOR_DARK_TEXT,
'forum.background-color': '#494949',
'forum.text-color': COLOR_DARK_TEXT,
'github.background-color': '#3a3a3a',
'github.text-color': COLOR_DARK_TEXT,
'report-issue.background-color': '#492020',
'report-issue.text-color': COLOR_DARK_TEXT,
// Status and verb tabs
'output.background-color': COLOR_DARK_BG_DARKER,
'output.text-color': COLOR_DARK_TEXT,
'statwindow.background-color': COLOR_DARK_BG_DARKER,
'statwindow.text-color': COLOR_DARK_TEXT,
'stat.background-color': COLOR_DARK_BG_DARKER,
'stat.tab-background-color': COLOR_DARK_BG,
'stat.text-color': COLOR_DARK_TEXT,
'stat.tab-text-color': COLOR_DARK_TEXT,
'stat.prefix-color': COLOR_DARK_TEXT,
'stat.suffix-color': COLOR_DARK_TEXT,
// Say, OOC, me Buttons etc.
'saybutton.background-color': COLOR_DARK_BG,
'saybutton.text-color': COLOR_DARK_TEXT,
'oocbutton.background-color': COLOR_DARK_BG,
'oocbutton.text-color': COLOR_DARK_TEXT,
'mebutton.background-color': COLOR_DARK_BG,
'mebutton.text-color': COLOR_DARK_TEXT,
'asset_cache_browser.background-color': COLOR_DARK_BG,
'asset_cache_browser.text-color': COLOR_DARK_TEXT,
'tooltip.background-color': COLOR_DARK_BG,
'tooltip.text-color': COLOR_DARK_TEXT,
'input.background-color': COLOR_DARK_BG_DARKER,
'input.text-color': COLOR_DARK_TEXT,
});
}
};

View File

@@ -89,6 +89,14 @@ export const backendReducer = (state = initialState, action) => {
};
}
if (type === 'byond/ctrldown') {
globalEvents.emit('byond/ctrldown');
}
if (type === 'byond/ctrlup') {
globalEvents.emit('byond/ctrlup');
}
if (type === 'backend/suspendStart') {
return {
...state,

View File

@@ -100,6 +100,7 @@ export class Section extends Component<SectionProps> {
{/* Vorestation Edit Start */}
<div
ref={this.scrollableRef}
onScroll={onScroll}
className={classes([
'Section__content',
!!stretchContents && 'Section__content--stretchContents',

View File

@@ -95,11 +95,31 @@ $separator-color: colors.$primary !default;
overflow-y: hidden;
& > .Section__rest > .Section__content {
overflow-y: auto;
overflow-y: scroll;
overflow-x: hidden;
}
}
.Section--scrollableHorizontal {
overflow-x: hidden;
overflow-y: hidden;
& > .Section__rest > .Section__content {
overflow-y: hidden;
overflow-x: scroll;
}
}
.Section--scrollable.Section--scrollableHorizontal {
overflow-x: hidden;
overflow-y: hidden;
& > .Section__rest > .Section__content {
overflow-y: scroll;
overflow-x: scroll;
}
}
// Nested sections
.Section .Section {
background-color: transparent;

View File

@@ -52,7 +52,8 @@ $scrollbar-color-multiplier: 1 !default;
}
.Layout__content--scrollable {
overflow-y: auto; // VOREStation Edit: Use auto, not scroll.
overflow-y: scroll;
margin-bottom: 0;
}
// VOREStation Addition Start

View File

@@ -89,6 +89,14 @@ export const backendReducer = (state = initialState, action) => {
};
}
if (type === 'byond/ctrldown') {
globalEvents.emit('byond/ctrldown');
}
if (type === 'byond/ctrlup') {
globalEvents.emit('byond/ctrlup');
}
if (type === 'backend/suspendStart') {
return {
...state,

View File

@@ -100,6 +100,7 @@ export class Section extends Component<SectionProps> {
{/* Vorestation Edit Start */}
<div
ref={this.scrollableRef}
onScroll={onScroll}
className={classes([
'Section__content',
!!stretchContents && 'Section__content--stretchContents',

View File

@@ -95,11 +95,31 @@ $separator-color: colors.$primary !default;
overflow-y: hidden;
& > .Section__rest > .Section__content {
overflow-y: auto;
overflow-y: scroll;
overflow-x: hidden;
}
}
.Section--scrollableHorizontal {
overflow-x: hidden;
overflow-y: hidden;
& > .Section__rest > .Section__content {
overflow-y: hidden;
overflow-x: scroll;
}
}
.Section--scrollable.Section--scrollableHorizontal {
overflow-x: hidden;
overflow-y: hidden;
& > .Section__rest > .Section__content {
overflow-y: scroll;
overflow-x: scroll;
}
}
// Nested sections
.Section .Section {
background-color: transparent;

View File

@@ -52,7 +52,8 @@ $scrollbar-color-multiplier: 1 !default;
}
.Layout__content--scrollable {
overflow-y: auto; // VOREStation Edit: Use auto, not scroll.
overflow-y: scroll;
margin-bottom: 0;
}
// VOREStation Addition Start

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,6 +39,10 @@ module.exports = (env = {}, argv) => {
// './packages/tgui',
'./packages/tgui_ch',
],
'tgui-panel': [
'./packages/tgui-polyfill',
'./packages/tgui-panel',
],
},
output: {
path: argv.useTmpFolder

View File

@@ -4118,6 +4118,13 @@ __metadata:
languageName: node
linkType: hard
"dompurify@npm:^2.3.1":
version: 2.4.7
resolution: "dompurify@npm:2.4.7"
checksum: 13c047e772a1998348191554dda403950d45ef2ec75fa0b9915cc179ccea0a39ef780d283109bd72cf83a2a085af6c77664281d4d0106a737bc5f39906364efe
languageName: node
linkType: hard
"dompurify@npm:^2.3.8":
version: 2.4.5
resolution: "dompurify@npm:2.4.5"
@@ -9456,6 +9463,19 @@ __metadata:
languageName: unknown
linkType: soft
"tgui-panel@workspace:packages/tgui-panel":
version: 0.0.0-use.local
resolution: "tgui-panel@workspace:packages/tgui-panel"
dependencies:
common: "workspace:*"
dompurify: ^2.3.1
inferno: ^7.4.8
tgui-dev-server: "workspace:*"
tgui-polyfill: "workspace:*"
tgui_ch: "workspace:*"
languageName: unknown
linkType: soft
"tgui-polyfill@workspace:*, tgui-polyfill@workspace:packages/tgui-polyfill":
version: 0.0.0-use.local
resolution: "tgui-polyfill@workspace:packages/tgui-polyfill"
@@ -9531,7 +9551,7 @@ __metadata:
languageName: unknown
linkType: soft
"tgui_ch@workspace:packages/tgui_ch":
"tgui_ch@workspace:*, tgui_ch@workspace:packages/tgui_ch":
version: 0.0.0-use.local
resolution: "tgui_ch@workspace:packages/tgui_ch"
dependencies:

View File

@@ -183,8 +183,8 @@ export const TguiTarget = new Juke.Target({
outputs: [
'tgui/public/tgui.bundle.css',
'tgui/public/tgui.bundle.js',
//'tgui/public/tgui-panel.bundle.css',
//'tgui/public/tgui-panel.bundle.js',
'tgui/public/tgui-panel.bundle.css',
'tgui/public/tgui-panel.bundle.js',
//'tgui/public/tgui-say.bundle.css',
//'tgui/public/tgui-say.bundle.js',
],

View File

@@ -38,6 +38,7 @@
#include "code\__defines\belly_modes_ch.dm"
#include "code\__defines\belly_modes_vr.dm"
#include "code\__defines\callbacks.dm"
#include "code\__defines\chat.dm"
#include "code\__defines\chemistry.dm"
#include "code\__defines\chemistry_vr.dm"
#include "code\__defines\color.dm"
@@ -61,6 +62,7 @@
#include "code\__defines\lighting.dm"
#include "code\__defines\lighting_vr.dm"
#include "code\__defines\lum_ch.dm"
#include "code\__defines\logging.dm"
#include "code\__defines\machinery.dm"
#include "code\__defines\map.dm"
#include "code\__defines\materials.dm"
@@ -120,6 +122,7 @@
#include "code\__defines\dcs\helpers.dm"
#include "code\__defines\dcs\signals.dm"
#include "code\__defines\dcs\signals_ch.dm"
#include "code\_global_vars\_regexes.dm"
#include "code\_global_vars\bitfields.dm"
#include "code\_global_vars\misc.dm"
#include "code\_global_vars\mobs.dm"
@@ -163,6 +166,7 @@
#include "code\_helpers\view.dm"
#include "code\_helpers\visual_filters.dm"
#include "code\_helpers\widelists_ch.dm"
#include "code\_helpers\logging\ui.dm"
#include "code\_helpers\sorts\__main.dm"
#include "code\_helpers\sorts\comparators.dm"
#include "code\_helpers\sorts\TimSort.dm"
@@ -1951,6 +1955,10 @@
#include "code\modules\asset_cache\asset_cache_item.dm"
#include "code\modules\asset_cache\asset_list.dm"
#include "code\modules\asset_cache\asset_list_items.dm"
#include "code\modules\asset_cache\assets\fontawesome.dm"
#include "code\modules\asset_cache\assets\jquery.dm"
#include "code\modules\asset_cache\assets\tgfont.dm"
#include "code\modules\asset_cache\assets\tgui.dm"
#include "code\modules\awaymissions\bluespaceartillery.dm"
#include "code\modules\awaymissions\corpse.dm"
#include "code\modules\awaymissions\exile.dm"
@@ -4550,6 +4558,7 @@
#include "code\ZAS\Turf.dm"
#include "code\ZAS\Variable Settings.dm"
#include "code\ZAS\Zone.dm"
#include "interface\fonts.dm"
#include "interface\interface.dm"
#include "interface\skin.dmf"
#include "maps\atoll\atoll_decals.dm"