mirror of
https://github.com/CHOMPStation2/CHOMPStation2.git
synced 2025-12-10 02:09:41 +00:00
[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:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
26
code/__defines/chat.dm
Normal 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"
|
||||
2
code/__defines/logging.dm
Normal file
2
code/__defines/logging.dm
Normal file
@@ -0,0 +1,2 @@
|
||||
#define SEND_TEXT(target, text) DIRECT_OUTPUT(target, text)
|
||||
#define WRITE_FILE(file, text) DIRECT_OUTPUT(file, text)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
code/_global_vars/_regexes.dm
Normal file
2
code/_global_vars/_regexes.dm
Normal 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?://"))
|
||||
@@ -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]")
|
||||
|
||||
|
||||
36
code/_helpers/logging/ui.dm
Normal file
36
code/_helpers/logging/ui.dm
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
|
||||
9
code/modules/asset_cache/assets/fontawesome.dm
Normal file
9
code/modules/asset_cache/assets/fontawesome.dm
Normal 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'
|
||||
)
|
||||
4
code/modules/asset_cache/assets/jquery.dm
Normal file
4
code/modules/asset_cache/assets/jquery.dm
Normal file
@@ -0,0 +1,4 @@
|
||||
/datum/asset/simple/jquery
|
||||
assets = list(
|
||||
"jquery.min.js" = 'code/modules/tooltip/jquery.min.js',
|
||||
)
|
||||
6
code/modules/asset_cache/assets/tgfont.dm
Normal file
6
code/modules/asset_cache/assets/tgfont.dm
Normal 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"),
|
||||
)
|
||||
15
code/modules/asset_cache/assets/tgui.dm
Normal file
15
code/modules/asset_cache/assets/tgui.dm
Normal 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"),
|
||||
)
|
||||
*/
|
||||
@@ -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
6
interface/fonts.dm
Normal 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
|
||||
28
tgui/packages/tgui-panel/Notifications.js
Normal file
28
tgui/packages/tgui-panel/Notifications.js
Normal 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;
|
||||
128
tgui/packages/tgui-panel/Panel.js
Normal file
128
tgui/packages/tgui-panel/Panel.js
Normal 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>
|
||||
);
|
||||
};
|
||||
109
tgui/packages/tgui-panel/audio/NowPlayingWidget.js
Normal file
109
tgui/packages/tgui-panel/audio/NowPlayingWidget.js
Normal 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>
|
||||
);
|
||||
};
|
||||
17
tgui/packages/tgui-panel/audio/hooks.js
Normal file
17
tgui/packages/tgui-panel/audio/hooks.js
Normal 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' }),
|
||||
};
|
||||
};
|
||||
10
tgui/packages/tgui-panel/audio/index.js
Normal file
10
tgui/packages/tgui-panel/audio/index.js
Normal 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';
|
||||
37
tgui/packages/tgui-panel/audio/middleware.js
Normal file
37
tgui/packages/tgui-panel/audio/middleware.js
Normal 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);
|
||||
};
|
||||
};
|
||||
117
tgui/packages/tgui-panel/audio/player.js
Normal file
117
tgui/packages/tgui-panel/audio/player.js
Normal 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);
|
||||
}
|
||||
}
|
||||
50
tgui/packages/tgui-panel/audio/reducer.js
Normal file
50
tgui/packages/tgui-panel/audio/reducer.js
Normal 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;
|
||||
};
|
||||
7
tgui/packages/tgui-panel/audio/selectors.js
Normal file
7
tgui/packages/tgui-panel/audio/selectors.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectAudio = (state) => state.audio;
|
||||
89
tgui/packages/tgui-panel/chat/ChatPageSettings.js
Normal file
89
tgui/packages/tgui-panel/chat/ChatPageSettings.js
Normal 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>
|
||||
);
|
||||
};
|
||||
73
tgui/packages/tgui-panel/chat/ChatPanel.js
Normal file
73
tgui/packages/tgui-panel/chat/ChatPanel.js
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
68
tgui/packages/tgui-panel/chat/ChatTabs.js
Normal file
68
tgui/packages/tgui-panel/chat/ChatTabs.js
Normal 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>
|
||||
);
|
||||
};
|
||||
21
tgui/packages/tgui-panel/chat/actions.js
Normal file
21
tgui/packages/tgui-panel/chat/actions.js
Normal 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');
|
||||
148
tgui/packages/tgui-panel/chat/constants.js
Normal file
148
tgui/packages/tgui-panel/chat/constants.js
Normal 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,
|
||||
},
|
||||
];
|
||||
11
tgui/packages/tgui-panel/chat/index.js
Normal file
11
tgui/packages/tgui-panel/chat/index.js
Normal 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';
|
||||
178
tgui/packages/tgui-panel/chat/middleware.js
Normal file
178
tgui/packages/tgui-panel/chat/middleware.js
Normal 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);
|
||||
};
|
||||
};
|
||||
56
tgui/packages/tgui-panel/chat/model.js
Normal file
56
tgui/packages/tgui-panel/chat/model.js
Normal 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);
|
||||
183
tgui/packages/tgui-panel/chat/reducer.js
Normal file
183
tgui/packages/tgui-panel/chat/reducer.js
Normal 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;
|
||||
};
|
||||
608
tgui/packages/tgui-panel/chat/renderer.js
Normal file
608
tgui/packages/tgui-panel/chat/renderer.js
Normal 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__;
|
||||
204
tgui/packages/tgui-panel/chat/replaceInTextNode.js
Normal file
204
tgui/packages/tgui-panel/chat/replaceInTextNode.js
Normal 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;
|
||||
});
|
||||
17
tgui/packages/tgui-panel/chat/selectors.js
Normal file
17
tgui/packages/tgui-panel/chat/selectors.js
Normal 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];
|
||||
11
tgui/packages/tgui-panel/game/actions.js
Normal file
11
tgui/packages/tgui-panel/game/actions.js
Normal 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');
|
||||
7
tgui/packages/tgui-panel/game/constants.js
Normal file
7
tgui/packages/tgui-panel/game/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const CONNECTION_LOST_AFTER = 20000;
|
||||
12
tgui/packages/tgui-panel/game/hooks.js
Normal file
12
tgui/packages/tgui-panel/game/hooks.js
Normal 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);
|
||||
};
|
||||
9
tgui/packages/tgui-panel/game/index.js
Normal file
9
tgui/packages/tgui-panel/game/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { useGame } from './hooks';
|
||||
export { gameMiddleware } from './middleware';
|
||||
export { gameReducer } from './reducer';
|
||||
53
tgui/packages/tgui-panel/game/middleware.js
Normal file
53
tgui/packages/tgui-panel/game/middleware.js
Normal 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);
|
||||
};
|
||||
};
|
||||
39
tgui/packages/tgui-panel/game/reducer.js
Normal file
39
tgui/packages/tgui-panel/game/reducer.js
Normal 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;
|
||||
};
|
||||
7
tgui/packages/tgui-panel/game/selectors.js
Normal file
7
tgui/packages/tgui-panel/game/selectors.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectGame = (state) => state.game;
|
||||
114
tgui/packages/tgui-panel/index.js
Normal file
114
tgui/packages/tgui-panel/index.js
Normal 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();
|
||||
13
tgui/packages/tgui-panel/package.json
Normal file
13
tgui/packages/tgui-panel/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
47
tgui/packages/tgui-panel/panelFocus.js
Normal file
47
tgui/packages/tgui-panel/panelFocus.js
Normal 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();
|
||||
});
|
||||
};
|
||||
27
tgui/packages/tgui-panel/ping/PingIndicator.js
Normal file
27
tgui/packages/tgui-panel/ping/PingIndicator.js
Normal 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>
|
||||
);
|
||||
};
|
||||
25
tgui/packages/tgui-panel/ping/actions.js
Normal file
25
tgui/packages/tgui-panel/ping/actions.js
Normal 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');
|
||||
11
tgui/packages/tgui-panel/ping/constants.js
Normal file
11
tgui/packages/tgui-panel/ping/constants.js
Normal 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;
|
||||
9
tgui/packages/tgui-panel/ping/index.js
Normal file
9
tgui/packages/tgui-panel/ping/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { pingMiddleware } from './middleware';
|
||||
export { PingIndicator } from './PingIndicator';
|
||||
export { pingReducer } from './reducer';
|
||||
60
tgui/packages/tgui-panel/ping/middleware.js
Normal file
60
tgui/packages/tgui-panel/ping/middleware.js
Normal 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);
|
||||
};
|
||||
};
|
||||
46
tgui/packages/tgui-panel/ping/reducer.js
Normal file
46
tgui/packages/tgui-panel/ping/reducer.js
Normal 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;
|
||||
};
|
||||
7
tgui/packages/tgui-panel/ping/selectors.js
Normal file
7
tgui/packages/tgui-panel/ping/selectors.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectPing = (state) => state.ping;
|
||||
37
tgui/packages/tgui-panel/reconnect.tsx
Normal file
37
tgui/packages/tgui-panel/reconnect.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
311
tgui/packages/tgui-panel/settings/SettingsPanel.js
Normal file
311
tgui/packages/tgui-panel/settings/SettingsPanel.js
Normal 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>
|
||||
);
|
||||
};
|
||||
26
tgui/packages/tgui-panel/settings/actions.js
Normal file
26
tgui/packages/tgui-panel/settings/actions.js
Normal 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'
|
||||
);
|
||||
39
tgui/packages/tgui-panel/settings/constants.js
Normal file
39
tgui/packages/tgui-panel/settings/constants.js
Normal 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;
|
||||
20
tgui/packages/tgui-panel/settings/hooks.js
Normal file
20
tgui/packages/tgui-panel/settings/hooks.js
Normal 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)),
|
||||
};
|
||||
};
|
||||
10
tgui/packages/tgui-panel/settings/index.js
Normal file
10
tgui/packages/tgui-panel/settings/index.js
Normal 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';
|
||||
83
tgui/packages/tgui-panel/settings/middleware.js
Normal file
83
tgui/packages/tgui-panel/settings/middleware.js
Normal 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);
|
||||
};
|
||||
};
|
||||
20
tgui/packages/tgui-panel/settings/model.js
Normal file
20
tgui/packages/tgui-panel/settings/model.js
Normal 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,
|
||||
});
|
||||
175
tgui/packages/tgui-panel/settings/reducer.js
Normal file
175
tgui/packages/tgui-panel/settings/reducer.js
Normal 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;
|
||||
};
|
||||
12
tgui/packages/tgui-panel/settings/selectors.js
Normal file
12
tgui/packages/tgui-panel/settings/selectors.js
Normal 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;
|
||||
100
tgui/packages/tgui-panel/styles/components/Chat.scss
Normal file
100
tgui/packages/tgui-panel/styles/components/Chat.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
28
tgui/packages/tgui-panel/styles/components/Ping.scss
Normal file
28
tgui/packages/tgui-panel/styles/components/Ping.scss
Normal 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;
|
||||
}
|
||||
62
tgui/packages/tgui-panel/styles/main.scss
Normal file
62
tgui/packages/tgui-panel/styles/main.scss
Normal 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');
|
||||
1111
tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss
Normal file
1111
tgui/packages/tgui-panel/styles/tgchat/chat-dark.scss
Normal file
File diff suppressed because it is too large
Load Diff
1144
tgui/packages/tgui-panel/styles/tgchat/chat-light.scss
Normal file
1144
tgui/packages/tgui-panel/styles/tgchat/chat-light.scss
Normal file
File diff suppressed because it is too large
Load Diff
89
tgui/packages/tgui-panel/styles/themes/light.scss
Normal file
89
tgui/packages/tgui-panel/styles/themes/light.scss
Normal 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');
|
||||
}
|
||||
90
tgui/packages/tgui-panel/telemetry.js
Normal file
90
tgui/packages/tgui-panel/telemetry.js
Normal 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);
|
||||
};
|
||||
};
|
||||
138
tgui/packages/tgui-panel/themes.js
Normal file
138
tgui/packages/tgui-panel/themes.js
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
2
tgui/public/tgui-panel.bundle.css
Normal file
2
tgui/public/tgui-panel.bundle.css
Normal file
File diff suppressed because one or more lines are too long
1
tgui/public/tgui-panel.bundle.js
Normal file
1
tgui/public/tgui-panel.bundle.js
Normal file
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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user