mirror of
https://github.com/CHOMPStation2/CHOMPStation2.git
synced 2026-08-22 12:47:16 +01: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:
co-authored by
Heroman3003
Selis
parent
5e15494def
commit
eb65b5721c
@@ -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/*
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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]")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
/datum/asset/simple/jquery
|
||||
assets = list(
|
||||
"jquery.min.js" = 'code/modules/tooltip/jquery.min.js',
|
||||
)
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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' }),
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectAudio = (state) => state.audio;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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__;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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');
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const CONNECTION_LOST_AFTER = 20000;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { useGame } from './hooks';
|
||||
export { gameMiddleware } from './middleware';
|
||||
export { gameReducer } from './reducer';
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectGame = (state) => state.game;
|
||||
@@ -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();
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { pingMiddleware } from './middleware';
|
||||
export { PingIndicator } from './PingIndicator';
|
||||
export { pingReducer } from './reducer';
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectPing = (state) => state.ping;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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)),
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2020 Aleksej Komarov
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
@use 'sass:meta';
|
||||
@use 'sass:color';
|
||||
|
||||
// CHOMPEdit Start - tgui_ch
|
||||
@use '~tgui_ch/styles/colors.scss';
|
||||
@use '~tgui_ch/styles/base.scss' with (
|
||||
$color-bg: #202020,
|
||||
$color-bg-section: color.adjust(#202020, $lightness: -5%),
|
||||
$color-bg-grad-spread: 0%,
|
||||
);
|
||||
|
||||
// Core styles
|
||||
@include meta.load-css('~tgui_ch/styles/reset.scss');
|
||||
|
||||
// Atomic classes
|
||||
@include meta.load-css('~tgui_ch/styles/atomic/candystripe.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/atomic/color.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/atomic/debug-layout.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/atomic/outline.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/atomic/text.scss');
|
||||
|
||||
// Components
|
||||
@include meta.load-css('~tgui_ch/styles/components/BlockQuote.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Button.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/ColorBox.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Dimmer.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Divider.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Dropdown.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Flex.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Input.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Knob.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/LabeledList.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Modal.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/NoticeBox.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/NumberInput.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/ProgressBar.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Section.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Slider.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Stack.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Table.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Tabs.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/TextArea.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/components/Tooltip.scss');
|
||||
|
||||
// Components specific to tgui-panel
|
||||
@include meta.load-css('./components/Chat.scss');
|
||||
@include meta.load-css('./components/Ping.scss');
|
||||
@include meta.load-css('./components/Notifications.scss');
|
||||
|
||||
// Layouts
|
||||
@include meta.load-css('~tgui_ch/styles/layouts/Layout.scss');
|
||||
// @include meta.load-css('~tgui_ch/styles/layouts/TitleBar.scss');
|
||||
@include meta.load-css('~tgui_ch/styles/layouts/Window.scss');
|
||||
// CHOMPEdit End
|
||||
|
||||
// tgchat styles
|
||||
@include meta.load-css('./tgchat/chat-dark.scss');
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
-12
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
|
||||
|
||||
+21
-1
@@ -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