mirror of
https://github.com/VOREStation/VOREStation.git
synced 2026-08-27 14:08:44 +01:00
Merge pull request #13106 from ItsSelis/tgui-panel-removal
Removes tgui-panel
This commit is contained in:
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Flex } from 'tgui/components';
|
||||
|
||||
export const Notifications = props => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<div className="Notifications">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationsItem = props => {
|
||||
const {
|
||||
rightSlot,
|
||||
children,
|
||||
} = props;
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
className="Notification">
|
||||
<Flex.Item
|
||||
className="Notification__content"
|
||||
grow={1}>
|
||||
{children}
|
||||
</Flex.Item>
|
||||
{rightSlot && (
|
||||
<Flex.Item className="Notification__rightSlot">
|
||||
{rightSlot}
|
||||
</Flex.Item>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
Notifications.Item = NotificationsItem;
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Button, Section, Stack } from 'tgui/components';
|
||||
import { Pane } from 'tgui/layouts';
|
||||
import { NowPlayingWidget, useAudio } from './audio';
|
||||
import { ChatPanel, ChatTabs } from './chat';
|
||||
import { useGame } from './game';
|
||||
import { Notifications } from './Notifications';
|
||||
import { PingIndicator } from './ping';
|
||||
import { SettingsPanel, useSettings } from './settings';
|
||||
|
||||
export const Panel = (props, context) => {
|
||||
// IE8-10: Needs special treatment due to missing Flex support
|
||||
if (Byond.IS_LTE_IE10) {
|
||||
return (
|
||||
<HoboPanel />
|
||||
);
|
||||
}
|
||||
const audio = useAudio(context);
|
||||
const settings = useSettings(context);
|
||||
const game = useGame(context);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const { useDebug, KitchenSink } = require('tgui/debug');
|
||||
const debug = useDebug(context);
|
||||
if (debug.kitchenSink) {
|
||||
return (
|
||||
<KitchenSink panel />
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Pane theme={settings.theme}>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item>
|
||||
<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={(
|
||||
<Button
|
||||
color="white"
|
||||
onClick={() => Byond.command('.reconnect')}>
|
||||
Reconnect
|
||||
</Button>
|
||||
)}>
|
||||
You are either AFK, experiencing lag or the connection
|
||||
has closed.
|
||||
</Notifications.Item>
|
||||
)}
|
||||
{game.roundRestartedAt && (
|
||||
<Notifications.Item>
|
||||
The connection has been closed because the server is
|
||||
restarting. Please wait while you automatically reconnect.
|
||||
</Notifications.Item>
|
||||
)}
|
||||
</Notifications>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { toFixed } from 'common/math';
|
||||
import { useDispatch, useSelector } from 'common/redux';
|
||||
import { Button, Flex, Knob } from 'tgui/components';
|
||||
import { useSettings } from '../settings';
|
||||
import { selectAudio } from './selectors';
|
||||
|
||||
export const NowPlayingWidget = (props, context) => {
|
||||
const audio = useSelector(context, selectAudio);
|
||||
const dispatch = useDispatch(context);
|
||||
const settings = useSettings(context);
|
||||
const title = audio.meta?.title;
|
||||
return (
|
||||
<Flex align="center">
|
||||
{audio.playing && (
|
||||
<>
|
||||
<Flex.Item
|
||||
shrink={0}
|
||||
mx={0.5}
|
||||
color="label">
|
||||
Now playing:
|
||||
</Flex.Item>
|
||||
<Flex.Item
|
||||
mx={0.5}
|
||||
grow={1}
|
||||
style={{
|
||||
'white-space': 'nowrap',
|
||||
'overflow': 'hidden',
|
||||
'text-overflow': 'ellipsis',
|
||||
}}>
|
||||
{title || 'Unknown Track'}
|
||||
</Flex.Item>
|
||||
</>
|
||||
) || (
|
||||
<Flex.Item grow={1} color="label">
|
||||
Nothing to play.
|
||||
</Flex.Item>
|
||||
)}
|
||||
{audio.playing && (
|
||||
<Flex.Item mx={0.5} fontSize="0.9em">
|
||||
<Button
|
||||
tooltip="Stop"
|
||||
icon="stop"
|
||||
onClick={() => dispatch({
|
||||
type: 'audio/stopMusic',
|
||||
})} />
|
||||
</Flex.Item>
|
||||
)}
|
||||
<Flex.Item mx={0.5} fontSize="0.9em">
|
||||
<Knob
|
||||
minValue={0}
|
||||
maxValue={1}
|
||||
value={settings.adminMusicVolume}
|
||||
step={0.0025}
|
||||
stepPixelSize={1}
|
||||
format={value => toFixed(value * 100) + '%'}
|
||||
onDrag={(e, value) => settings.update({
|
||||
adminMusicVolume: value,
|
||||
})} />
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @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' }),
|
||||
};
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { useAudio } from './hooks';
|
||||
export { audioMiddleware } from './middleware';
|
||||
export { NowPlayingWidget } from './NowPlayingWidget';
|
||||
export { audioReducer } from './reducer';
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
};
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { createLogger } from 'tgui/logging';
|
||||
|
||||
const logger = createLogger('AudioPlayer');
|
||||
|
||||
export class AudioPlayer {
|
||||
constructor() {
|
||||
// Doesn't support HTMLAudioElement
|
||||
if (Byond.IS_LTE_IE9) {
|
||||
return;
|
||||
}
|
||||
// Set up the HTMLAudioElement node
|
||||
this.node = document.createElement('audio');
|
||||
this.node.style.setProperty('display', 'none');
|
||||
document.body.appendChild(this.node);
|
||||
// Set up other properties
|
||||
this.playing = false;
|
||||
this.volume = 1;
|
||||
this.options = {};
|
||||
this.onPlaySubscribers = [];
|
||||
this.onStopSubscribers = [];
|
||||
// Listen for playback start events
|
||||
this.node.addEventListener('canplaythrough', () => {
|
||||
logger.log('canplaythrough');
|
||||
this.playing = true;
|
||||
this.node.playbackRate = this.options.pitch || 1;
|
||||
this.node.currentTime = this.options.start || 0;
|
||||
this.node.volume = this.volume;
|
||||
this.node.play();
|
||||
for (let subscriber of this.onPlaySubscribers) {
|
||||
subscriber();
|
||||
}
|
||||
});
|
||||
// Listen for playback stop events
|
||||
this.node.addEventListener('ended', () => {
|
||||
logger.log('ended');
|
||||
this.stop();
|
||||
});
|
||||
// Listen for playback errors
|
||||
this.node.addEventListener('error', e => {
|
||||
if (this.playing) {
|
||||
logger.log('playback error', e.error);
|
||||
this.stop();
|
||||
}
|
||||
});
|
||||
// Check every second to stop the playback at the right time
|
||||
this.playbackInterval = setInterval(() => {
|
||||
if (!this.playing) {
|
||||
return;
|
||||
}
|
||||
const shouldStop = this.options.end > 0
|
||||
&& this.node.currentTime >= this.options.end;
|
||||
if (shouldStop) {
|
||||
this.stop();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.node) {
|
||||
return;
|
||||
}
|
||||
this.node.stop();
|
||||
document.removeChild(this.node);
|
||||
clearInterval(this.playbackInterval);
|
||||
}
|
||||
|
||||
play(url, options = {}) {
|
||||
if (!this.node) {
|
||||
return;
|
||||
}
|
||||
logger.log('playing', url, options);
|
||||
this.options = options;
|
||||
this.node.src = url;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.node) {
|
||||
return;
|
||||
}
|
||||
if (this.playing) {
|
||||
for (let subscriber of this.onStopSubscribers) {
|
||||
subscriber();
|
||||
}
|
||||
}
|
||||
logger.log('stopping');
|
||||
this.playing = false;
|
||||
this.node.src = '';
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
if (!this.node) {
|
||||
return;
|
||||
}
|
||||
this.volume = volume;
|
||||
this.node.volume = volume;
|
||||
}
|
||||
|
||||
onPlay(subscriber) {
|
||||
if (!this.node) {
|
||||
return;
|
||||
}
|
||||
this.onPlaySubscribers.push(subscriber);
|
||||
}
|
||||
|
||||
onStop(subscriber) {
|
||||
if (!this.node) {
|
||||
return;
|
||||
}
|
||||
this.onStopSubscribers.push(subscriber);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectAudio = state => state.audio;
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { useDispatch, useSelector } from 'common/redux';
|
||||
import { Button, Collapsible, Divider, Input, Section, Stack } from 'tgui/components';
|
||||
import { removeChatPage, toggleAcceptedType, updateChatPage } from './actions';
|
||||
import { MESSAGE_TYPES } from './constants';
|
||||
import { selectCurrentChatPage } from './selectors';
|
||||
|
||||
export const ChatPageSettings = (props, context) => {
|
||||
const page = useSelector(context, selectCurrentChatPage);
|
||||
const dispatch = useDispatch(context);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { shallowDiffers } from 'common/react';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { Button } from 'tgui/components';
|
||||
import { chatRenderer } from './renderer';
|
||||
|
||||
export class ChatPanel extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.ref = createRef();
|
||||
this.state = {
|
||||
scrollTracking: true,
|
||||
};
|
||||
this.handleScrollTrackingChange = value => this.setState({
|
||||
scrollTracking: value,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
chatRenderer.mount(this.ref.current);
|
||||
chatRenderer.events.on('scrollTrackingChanged',
|
||||
this.handleScrollTrackingChange);
|
||||
this.componentDidUpdate();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
chatRenderer.events.off('scrollTrackingChanged',
|
||||
this.handleScrollTrackingChange);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
requestAnimationFrame(() => {
|
||||
chatRenderer.ensureScrollTracking();
|
||||
});
|
||||
const shouldUpdateStyle = (
|
||||
!prevProps || shallowDiffers(this.props, prevProps)
|
||||
);
|
||||
if (shouldUpdateStyle) {
|
||||
chatRenderer.assignStyle({
|
||||
'width': '100%',
|
||||
'white-space': 'pre-wrap',
|
||||
'font-size': this.props.fontSize,
|
||||
'line-height': this.props.lineHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
scrollTracking,
|
||||
} = this.state;
|
||||
return (
|
||||
<>
|
||||
<div className="Chat" ref={this.ref} />
|
||||
{!scrollTracking && (
|
||||
<Button
|
||||
className="Chat__scrollButton"
|
||||
icon="arrow-down"
|
||||
onClick={() => chatRenderer.scrollToBottom()}>
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { useDispatch, useSelector } from 'common/redux';
|
||||
import { Box, Tabs, Flex, Button } from 'tgui/components';
|
||||
import { changeChatPage, addChatPage } from './actions';
|
||||
import { selectChatPages, selectCurrentChatPage } from './selectors';
|
||||
import { openChatSettings } from '../settings/actions';
|
||||
|
||||
const UnreadCountWidget = ({ value }) => (
|
||||
<Box
|
||||
style={{
|
||||
'font-size': '0.7em',
|
||||
'border-radius': '0.25em',
|
||||
'width': '1.7em',
|
||||
'line-height': '1.55em',
|
||||
'background-color': 'crimson',
|
||||
'color': '#fff',
|
||||
}}>
|
||||
{Math.min(value, 99)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const ChatTabs = (props, context) => {
|
||||
const pages = useSelector(context, selectChatPages);
|
||||
const currentPage = useSelector(context, selectCurrentChatPage);
|
||||
const dispatch = useDispatch(context);
|
||||
return (
|
||||
<Flex align="center">
|
||||
<Flex.Item>
|
||||
<Tabs textAlign="center">
|
||||
{pages.map(page => (
|
||||
<Tabs.Tab
|
||||
key={page.id}
|
||||
selected={page === currentPage}
|
||||
rightSlot={page.unreadCount > 0 && (
|
||||
<UnreadCountWidget value={page.unreadCount} />
|
||||
)}
|
||||
onClick={() => dispatch(changeChatPage({
|
||||
pageId: page.id,
|
||||
}))}>
|
||||
{page.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
</Flex.Item>
|
||||
<Flex.Item ml={1}>
|
||||
<Button
|
||||
color="transparent"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dispatch(addChatPage());
|
||||
dispatch(openChatSettings());
|
||||
}} />
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const MAX_VISIBLE_MESSAGES = 2500;
|
||||
export const MAX_PERSISTED_MESSAGES = 1000;
|
||||
export const MESSAGE_SAVE_INTERVAL = 10000;
|
||||
export const MESSAGE_PRUNE_INTERVAL = 60000;
|
||||
export const COMBINE_MAX_MESSAGES = 5;
|
||||
export const COMBINE_MAX_TIME_WINDOW = 5000;
|
||||
export const IMAGE_RETRY_DELAY = 250;
|
||||
export const IMAGE_RETRY_LIMIT = 10;
|
||||
export const IMAGE_RETRY_MESSAGE_AGE = 60000;
|
||||
|
||||
// Default message type
|
||||
export const MESSAGE_TYPE_UNKNOWN = 'unknown';
|
||||
|
||||
// Internal message type
|
||||
export const MESSAGE_TYPE_INTERNAL = 'internal';
|
||||
|
||||
// Must match the set of defines in code/__DEFINES/chat.dm
|
||||
export const MESSAGE_TYPE_SYSTEM = 'system';
|
||||
export const MESSAGE_TYPE_LOCALCHAT = 'localchat';
|
||||
export const MESSAGE_TYPE_RADIO = 'radio';
|
||||
export const MESSAGE_TYPE_INFO = 'info';
|
||||
export const MESSAGE_TYPE_WARNING = 'warning';
|
||||
export const MESSAGE_TYPE_DEADCHAT = 'deadchat';
|
||||
export const MESSAGE_TYPE_OOC = 'ooc';
|
||||
export const MESSAGE_TYPE_ADMINPM = 'adminpm';
|
||||
export const MESSAGE_TYPE_COMBAT = 'combat';
|
||||
export const MESSAGE_TYPE_ADMINCHAT = 'adminchat';
|
||||
export const MESSAGE_TYPE_MODCHAT = 'modchat';
|
||||
export const MESSAGE_TYPE_EVENTCHAT = 'eventchat';
|
||||
export const MESSAGE_TYPE_ADMINLOG = 'adminlog';
|
||||
export const MESSAGE_TYPE_ATTACKLOG = 'attacklog';
|
||||
export const MESSAGE_TYPE_DEBUG = 'debug';
|
||||
|
||||
// Metadata for each message type
|
||||
export const MESSAGE_TYPES = [
|
||||
// Always-on types
|
||||
{
|
||||
type: MESSAGE_TYPE_SYSTEM,
|
||||
name: 'System Messages',
|
||||
description: 'Messages from your client, always enabled',
|
||||
selector: '.boldannounce',
|
||||
important: true,
|
||||
},
|
||||
// Basic types
|
||||
{
|
||||
type: MESSAGE_TYPE_LOCALCHAT,
|
||||
name: 'Local',
|
||||
description: 'In-character local messages (say, emote, etc)',
|
||||
selector: '.say, .emote',
|
||||
},
|
||||
{
|
||||
type: MESSAGE_TYPE_RADIO,
|
||||
name: 'Radio',
|
||||
description: 'All departments of radio messages',
|
||||
selector: '.alert, .minorannounce, .syndradio, .centcomradio, .aiprivradio, .comradio, .secradio, .engradio, .medradio, .sciradio, .suppradio, .servradio, .radio, .deptradio, .binarysay, .newscaster, .resonate',
|
||||
},
|
||||
{
|
||||
type: MESSAGE_TYPE_INFO,
|
||||
name: 'Info',
|
||||
description: 'Non-urgent messages from the game and items',
|
||||
selector: '.notice:not(.pm), .adminnotice, .info, .sinister, .cult, .infoplain, .announce, .hear, .smallnotice, .holoparasite',
|
||||
},
|
||||
{
|
||||
type: MESSAGE_TYPE_WARNING,
|
||||
name: 'Warnings',
|
||||
description: 'Urgent messages from the game and items',
|
||||
selector: '.warning:not(.pm), .critical, .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, .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_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,
|
||||
},
|
||||
];
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* @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';
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import { storage } from 'common/storage';
|
||||
import { loadSettings, updateSettings } from '../settings/actions';
|
||||
import { selectSettings } from '../settings/selectors';
|
||||
import { addChatPage, changeChatPage, changeScrollTracking, loadChat, rebuildChat, removeChatPage, saveChatToDisk, toggleAcceptedType, updateMessageCount } from './actions';
|
||||
import { MAX_PERSISTED_MESSAGES, MESSAGE_SAVE_INTERVAL } from './constants';
|
||||
import { createMessage, serializeMessage } from './model';
|
||||
import { chatRenderer } from './renderer';
|
||||
import { selectChat, selectCurrentChatPage } from './selectors';
|
||||
|
||||
// List of blacklisted tags
|
||||
const FORBID_TAGS = [
|
||||
'a',
|
||||
'iframe',
|
||||
'link',
|
||||
'video',
|
||||
];
|
||||
|
||||
const saveChatToStorage = async store => {
|
||||
const state = selectChat(store.getState());
|
||||
const fromIndex = Math.max(0,
|
||||
chatRenderer.messages.length - MAX_PERSISTED_MESSAGES);
|
||||
const messages = chatRenderer.messages
|
||||
.slice(fromIndex)
|
||||
.map(message => serializeMessage(message));
|
||||
storage.set('chat-state', state);
|
||||
storage.set('chat-messages', messages);
|
||||
};
|
||||
|
||||
const loadChatFromStorage = async store => {
|
||||
const [state, messages] = await Promise.all([
|
||||
storage.get('chat-state'),
|
||||
storage.get('chat-messages'),
|
||||
]);
|
||||
// Discard incompatible versions
|
||||
if (state && state.version <= 4) {
|
||||
store.dispatch(loadChat());
|
||||
return;
|
||||
}
|
||||
if (messages) {
|
||||
for (let message of messages) {
|
||||
if (message.html) {
|
||||
message.html = DOMPurify.sanitize(message.html, {
|
||||
FORBID_TAGS,
|
||||
});
|
||||
}
|
||||
}
|
||||
const batch = [
|
||||
...messages,
|
||||
createMessage({
|
||||
type: 'internal/reconnected',
|
||||
}),
|
||||
];
|
||||
chatRenderer.processBatch(batch, {
|
||||
prepend: true,
|
||||
});
|
||||
}
|
||||
store.dispatch(loadChat(state));
|
||||
};
|
||||
|
||||
export const chatMiddleware = store => {
|
||||
let initialized = false;
|
||||
let loaded = false;
|
||||
chatRenderer.events.on('batchProcessed', countByType => {
|
||||
// Use this flag to workaround unread messages caused by
|
||||
// loading them from storage. Side effect of that, is that
|
||||
// message count can not be trusted, only unread count.
|
||||
if (loaded) {
|
||||
store.dispatch(updateMessageCount(countByType));
|
||||
}
|
||||
});
|
||||
chatRenderer.events.on('scrollTrackingChanged', scrollTracking => {
|
||||
store.dispatch(changeScrollTracking(scrollTracking));
|
||||
});
|
||||
setInterval(() => saveChatToStorage(store), MESSAGE_SAVE_INTERVAL);
|
||||
return next => action => {
|
||||
const { type, payload } = action;
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
loadChatFromStorage(store);
|
||||
}
|
||||
if (type === 'chat/message') {
|
||||
// Normalize the payload
|
||||
const batch = Array.isArray(payload) ? payload : [payload];
|
||||
chatRenderer.processBatch(batch);
|
||||
return;
|
||||
}
|
||||
if (type === loadChat.type) {
|
||||
next(action);
|
||||
const page = selectCurrentChatPage(store.getState());
|
||||
chatRenderer.changePage(page);
|
||||
chatRenderer.onStateLoaded();
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
if (type === changeChatPage.type
|
||||
|| type === addChatPage.type
|
||||
|| type === removeChatPage.type
|
||||
|| type === toggleAcceptedType.type) {
|
||||
next(action);
|
||||
const page = selectCurrentChatPage(store.getState());
|
||||
chatRenderer.changePage(page);
|
||||
return;
|
||||
}
|
||||
if (type === rebuildChat.type) {
|
||||
chatRenderer.rebuildChat();
|
||||
return next(action);
|
||||
}
|
||||
if (type === updateSettings.type || type === loadSettings.type) {
|
||||
next(action);
|
||||
const settings = selectSettings(store.getState());
|
||||
chatRenderer.setHighlight(
|
||||
settings.highlightText,
|
||||
settings.highlightColor);
|
||||
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);
|
||||
};
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { createUuid } from 'common/uuid';
|
||||
import { MESSAGE_TYPES, MESSAGE_TYPE_INTERNAL } from './constants';
|
||||
|
||||
export const canPageAcceptType = (page, type) => (
|
||||
type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type]
|
||||
);
|
||||
|
||||
export const createPage = obj => ({
|
||||
id: createUuid(),
|
||||
name: 'New Tab',
|
||||
acceptedTypes: {},
|
||||
unreadCount: 0,
|
||||
createdAt: Date.now(),
|
||||
...obj,
|
||||
});
|
||||
|
||||
export const createMainPage = () => {
|
||||
const acceptedTypes = {};
|
||||
for (let typeDef of MESSAGE_TYPES) {
|
||||
acceptedTypes[typeDef.type] = true;
|
||||
}
|
||||
return createPage({
|
||||
name: 'Main',
|
||||
acceptedTypes,
|
||||
});
|
||||
};
|
||||
|
||||
export const createMessage = payload => ({
|
||||
createdAt: Date.now(),
|
||||
...payload,
|
||||
});
|
||||
|
||||
export const serializeMessage = message => ({
|
||||
type: message.type,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
times: message.times,
|
||||
createdAt: message.createdAt,
|
||||
});
|
||||
|
||||
export const isSameMessage = (a, b) => (
|
||||
typeof a.text === 'string' && a.text === b.text
|
||||
|| typeof a.html === 'string' && a.html === b.html
|
||||
);
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { addChatPage, changeChatPage, loadChat, removeChatPage, toggleAcceptedType, updateChatPage, updateMessageCount, changeScrollTracking } from './actions';
|
||||
import { canPageAcceptType, createMainPage } from './model';
|
||||
|
||||
const mainPage = createMainPage();
|
||||
|
||||
export const initialState = {
|
||||
version: 5,
|
||||
currentPageId: mainPage.id,
|
||||
scrollTracking: true,
|
||||
pages: [
|
||||
mainPage.id,
|
||||
],
|
||||
pageById: {
|
||||
[mainPage.id]: mainPage,
|
||||
},
|
||||
};
|
||||
|
||||
export const chatReducer = (state = initialState, action) => {
|
||||
const { type, payload } = action;
|
||||
if (type === loadChat.type) {
|
||||
// Validate version and/or migrate state
|
||||
if (payload?.version !== state.version) {
|
||||
return state;
|
||||
}
|
||||
// Reset page message counts
|
||||
// NOTE: We are mutably changing the payload on the assumption
|
||||
// that it is a copy that comes straight from the web storage.
|
||||
for (let id of Object.keys(payload.pageById)) {
|
||||
const page = payload.pageById[id];
|
||||
page.unreadCount = 0;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
if (type === changeScrollTracking.type) {
|
||||
const scrollTracking = payload;
|
||||
const nextState = {
|
||||
...state,
|
||||
scrollTracking,
|
||||
};
|
||||
if (scrollTracking) {
|
||||
const pageId = state.currentPageId;
|
||||
const page = {
|
||||
...state.pageById[pageId],
|
||||
unreadCount: 0,
|
||||
};
|
||||
nextState.pageById = {
|
||||
...state.pageById,
|
||||
[pageId]: page,
|
||||
};
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
if (type === updateMessageCount.type) {
|
||||
const countByType = payload;
|
||||
const pages = state.pages.map(id => state.pageById[id]);
|
||||
const currentPage = state.pageById[state.currentPageId];
|
||||
const nextPageById = { ...state.pageById };
|
||||
for (let page of pages) {
|
||||
let unreadCount = 0;
|
||||
for (let type of Object.keys(countByType)) {
|
||||
// Message does not belong here
|
||||
if (!canPageAcceptType(page, type)) {
|
||||
continue;
|
||||
}
|
||||
// Current page is scroll tracked
|
||||
if (page === currentPage && state.scrollTracking) {
|
||||
continue;
|
||||
}
|
||||
// This page received the same message which we can read
|
||||
// on the current page.
|
||||
if (page !== currentPage && canPageAcceptType(currentPage, type)) {
|
||||
continue;
|
||||
}
|
||||
unreadCount += countByType[type];
|
||||
}
|
||||
if (unreadCount > 0) {
|
||||
nextPageById[page.id] = {
|
||||
...page,
|
||||
unreadCount: page.unreadCount + unreadCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
pageById: nextPageById,
|
||||
};
|
||||
}
|
||||
if (type === addChatPage.type) {
|
||||
return {
|
||||
...state,
|
||||
currentPageId: payload.id,
|
||||
pages: [...state.pages, payload.id],
|
||||
pageById: {
|
||||
...state.pageById,
|
||||
[payload.id]: payload,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === changeChatPage.type) {
|
||||
const { pageId } = payload;
|
||||
const page = {
|
||||
...state.pageById[pageId],
|
||||
unreadCount: 0,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
currentPageId: pageId,
|
||||
pageById: {
|
||||
...state.pageById,
|
||||
[pageId]: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === updateChatPage.type) {
|
||||
const { pageId, ...update } = payload;
|
||||
const page = {
|
||||
...state.pageById[pageId],
|
||||
...update,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
pageById: {
|
||||
...state.pageById,
|
||||
[pageId]: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === toggleAcceptedType.type) {
|
||||
const { pageId, type } = payload;
|
||||
const page = { ...state.pageById[pageId] };
|
||||
page.acceptedTypes = { ...page.acceptedTypes };
|
||||
page.acceptedTypes[type] = !page.acceptedTypes[type];
|
||||
return {
|
||||
...state,
|
||||
pageById: {
|
||||
...state.pageById,
|
||||
[pageId]: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === removeChatPage.type) {
|
||||
const { pageId } = payload;
|
||||
const nextState = {
|
||||
...state,
|
||||
pages: [...state.pages],
|
||||
pageById: {
|
||||
...state.pageById,
|
||||
},
|
||||
};
|
||||
delete nextState.pageById[pageId];
|
||||
nextState.pages = nextState.pages.filter(id => id !== pageId);
|
||||
if (nextState.pages.length === 0) {
|
||||
nextState.pages.push(mainPage.id);
|
||||
nextState.pageById[mainPage.id] = mainPage;
|
||||
nextState.currentPageId = mainPage.id;
|
||||
}
|
||||
if (!nextState.currentPageId || nextState.currentPageId === pageId) {
|
||||
nextState.currentPageId = nextState.pages[0];
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
@@ -1,488 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'common/events';
|
||||
import { classes } from 'common/react';
|
||||
import { createLogger } from 'tgui/logging';
|
||||
import { COMBINE_MAX_MESSAGES, COMBINE_MAX_TIME_WINDOW, IMAGE_RETRY_DELAY, IMAGE_RETRY_LIMIT, IMAGE_RETRY_MESSAGE_AGE, MAX_PERSISTED_MESSAGES, MAX_VISIBLE_MESSAGES, MESSAGE_PRUNE_INTERVAL, MESSAGE_TYPES, MESSAGE_TYPE_INTERNAL, MESSAGE_TYPE_UNKNOWN } from './constants';
|
||||
import { canPageAcceptType, createMessage, isSameMessage } from './model';
|
||||
import { highlightNode, linkifyNode } from './replaceInTextNode';
|
||||
|
||||
const logger = createLogger('chatRenderer');
|
||||
|
||||
// We consider this as the smallest possible scroll offset
|
||||
// that is still trackable.
|
||||
const SCROLL_TRACKING_TOLERANCE = 24;
|
||||
|
||||
const findNearestScrollableParent = startingNode => {
|
||||
const body = document.body;
|
||||
let node = startingNode;
|
||||
while (node && node !== body) {
|
||||
// This definitely has a vertical scrollbar, because it reduces
|
||||
// scrollWidth of the element. Might not work if element uses
|
||||
// overflow: hidden.
|
||||
if (node.scrollWidth < node.offsetWidth) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
return window;
|
||||
};
|
||||
|
||||
const createHighlightNode = (text, color) => {
|
||||
const node = document.createElement('span');
|
||||
node.className = 'Chat__highlight';
|
||||
node.setAttribute('style', 'background-color:' + color);
|
||||
node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
const createMessageNode = () => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'ChatMessage';
|
||||
return node;
|
||||
};
|
||||
|
||||
const createReconnectedNode = () => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'Chat__reconnected';
|
||||
return node;
|
||||
};
|
||||
|
||||
const handleImageError = e => {
|
||||
setTimeout(() => {
|
||||
/** @type {HTMLImageElement} */
|
||||
const node = e.target;
|
||||
const attempts = parseInt(node.getAttribute('data-reload-n'), 10) || 0;
|
||||
if (attempts >= IMAGE_RETRY_LIMIT) {
|
||||
logger.error(`failed to load an image after ${attempts} attempts`);
|
||||
return;
|
||||
}
|
||||
const src = node.src;
|
||||
node.src = null;
|
||||
node.src = src + '#' + attempts;
|
||||
node.setAttribute('data-reload-n', attempts + 1);
|
||||
}, IMAGE_RETRY_DELAY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assigns a "times-repeated" badge to the message.
|
||||
*/
|
||||
const updateMessageBadge = message => {
|
||||
const { node, times } = message;
|
||||
if (!node || !times) {
|
||||
// Nothing to update
|
||||
return;
|
||||
}
|
||||
const foundBadge = node.querySelector('.Chat__badge');
|
||||
const badge = foundBadge || document.createElement('div');
|
||||
badge.textContent = times;
|
||||
badge.className = classes([
|
||||
'Chat__badge',
|
||||
'Chat__badge--animate',
|
||||
]);
|
||||
requestAnimationFrame(() => {
|
||||
badge.className = 'Chat__badge';
|
||||
});
|
||||
if (!foundBadge) {
|
||||
node.appendChild(badge);
|
||||
}
|
||||
};
|
||||
|
||||
class ChatRenderer {
|
||||
constructor() {
|
||||
/** @type {HTMLElement} */
|
||||
this.loaded = false;
|
||||
/** @type {HTMLElement} */
|
||||
this.rootNode = null;
|
||||
this.queue = [];
|
||||
this.messages = [];
|
||||
this.visibleMessages = [];
|
||||
this.page = null;
|
||||
this.events = new EventEmitter();
|
||||
// Scroll handler
|
||||
/** @type {HTMLElement} */
|
||||
this.scrollNode = null;
|
||||
this.scrollTracking = true;
|
||||
this.handleScroll = type => {
|
||||
const node = this.scrollNode;
|
||||
const height = node.scrollHeight;
|
||||
const bottom = node.scrollTop + node.offsetHeight;
|
||||
const scrollTracking = (
|
||||
Math.abs(height - bottom) < SCROLL_TRACKING_TOLERANCE
|
||||
);
|
||||
if (scrollTracking !== this.scrollTracking) {
|
||||
this.scrollTracking = scrollTracking;
|
||||
this.events.emit('scrollTrackingChanged', scrollTracking);
|
||||
logger.debug('tracking', this.scrollTracking);
|
||||
}
|
||||
};
|
||||
this.ensureScrollTracking = () => {
|
||||
if (this.scrollTracking) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
};
|
||||
// Periodic message pruning
|
||||
setInterval(() => this.pruneMessages(), MESSAGE_PRUNE_INTERVAL);
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this.loaded && this.rootNode && this.page;
|
||||
}
|
||||
|
||||
mount(node) {
|
||||
// Mount existing root node on top of the new node
|
||||
if (this.rootNode) {
|
||||
node.appendChild(this.rootNode);
|
||||
}
|
||||
// Initialize the root node
|
||||
else {
|
||||
this.rootNode = node;
|
||||
}
|
||||
// Find scrollable parent
|
||||
this.scrollNode = findNearestScrollableParent(this.rootNode);
|
||||
this.scrollNode.addEventListener('scroll', this.handleScroll);
|
||||
setImmediate(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
// Flush the queue
|
||||
this.tryFlushQueue();
|
||||
}
|
||||
|
||||
onStateLoaded() {
|
||||
this.loaded = true;
|
||||
this.tryFlushQueue();
|
||||
}
|
||||
|
||||
tryFlushQueue() {
|
||||
if (this.isReady() && this.queue.length > 0) {
|
||||
this.processBatch(this.queue);
|
||||
this.queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
assignStyle(style = {}) {
|
||||
for (let key of Object.keys(style)) {
|
||||
this.rootNode.style.setProperty(key, style[key]);
|
||||
}
|
||||
}
|
||||
|
||||
setHighlight(text, color) {
|
||||
if (!text || !color) {
|
||||
this.highlightRegex = null;
|
||||
this.highlightColor = null;
|
||||
return;
|
||||
}
|
||||
const allowedRegex = /^[a-z0-9_\-\s]+$/ig;
|
||||
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)
|
||||
));
|
||||
// Nothing to match, reset highlighting
|
||||
if (lines.length === 0) {
|
||||
this.highlightRegex = null;
|
||||
this.highlightColor = null;
|
||||
return;
|
||||
}
|
||||
this.highlightRegex = new RegExp('(' + lines.join('|') + ')', 'gi');
|
||||
this.highlightColor = color;
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
// scrollHeight is always bigger than scrollTop and is
|
||||
// automatically clamped to the valid range.
|
||||
this.scrollNode.scrollTop = this.scrollNode.scrollHeight;
|
||||
}
|
||||
|
||||
changePage(page) {
|
||||
if (!this.isReady()) {
|
||||
this.page = page;
|
||||
this.tryFlushQueue();
|
||||
return;
|
||||
}
|
||||
this.page = page;
|
||||
// Fast clear of the root node
|
||||
this.rootNode.textContent = '';
|
||||
this.visibleMessages = [];
|
||||
// Re-add message nodes
|
||||
const fragment = document.createDocumentFragment();
|
||||
let node;
|
||||
for (let message of this.messages) {
|
||||
if (canPageAcceptType(page, message.type)) {
|
||||
node = message.node;
|
||||
fragment.appendChild(node);
|
||||
this.visibleMessages.push(message);
|
||||
}
|
||||
}
|
||||
if (node) {
|
||||
this.rootNode.appendChild(fragment);
|
||||
node.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
getCombinableMessage(predicate) {
|
||||
const now = Date.now();
|
||||
const len = this.visibleMessages.length;
|
||||
const from = len - 1;
|
||||
const to = Math.max(0, len - COMBINE_MAX_MESSAGES);
|
||||
for (let i = from; i >= to; i--) {
|
||||
const message = this.visibleMessages[i];
|
||||
const matches = (
|
||||
// Is not an internal message
|
||||
!message.type.startsWith(MESSAGE_TYPE_INTERNAL)
|
||||
// Text payload must fully match
|
||||
&& isSameMessage(message, predicate)
|
||||
// Must land within the specified time window
|
||||
&& now < message.createdAt + COMBINE_MAX_TIME_WINDOW
|
||||
);
|
||||
if (matches) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
processBatch(batch, options = {}) {
|
||||
const {
|
||||
prepend,
|
||||
notifyListeners = true,
|
||||
} = options;
|
||||
const now = Date.now();
|
||||
// Queue up messages until chat is ready
|
||||
if (!this.isReady()) {
|
||||
if (prepend) {
|
||||
this.queue = [...batch, ...this.queue];
|
||||
}
|
||||
else {
|
||||
this.queue = [...this.queue, ...batch];
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Insert messages
|
||||
const fragment = document.createDocumentFragment();
|
||||
const countByType = {};
|
||||
let node;
|
||||
for (let payload of batch) {
|
||||
const message = createMessage(payload);
|
||||
// Combine messages
|
||||
const combinable = this.getCombinableMessage(message);
|
||||
if (combinable) {
|
||||
combinable.times = (combinable.times || 1) + 1;
|
||||
updateMessageBadge(combinable);
|
||||
continue;
|
||||
}
|
||||
// Reuse message node
|
||||
if (message.node) {
|
||||
node = message.node;
|
||||
}
|
||||
// Reconnected
|
||||
else if (message.type === 'internal/reconnected') {
|
||||
node = createReconnectedNode();
|
||||
}
|
||||
// Create message node
|
||||
else {
|
||||
node = createMessageNode();
|
||||
// Payload is plain text
|
||||
if (message.text) {
|
||||
node.textContent = message.text;
|
||||
}
|
||||
// Payload is HTML
|
||||
else if (message.html) {
|
||||
node.innerHTML = message.html;
|
||||
}
|
||||
else {
|
||||
logger.error('Error: message is missing text payload', message);
|
||||
}
|
||||
// Highlight text
|
||||
if (!message.avoidHighlighting && this.highlightRegex) {
|
||||
const highlighted = highlightNode(node,
|
||||
this.highlightRegex,
|
||||
text => (
|
||||
createHighlightNode(text, this.highlightColor)
|
||||
));
|
||||
if (highlighted) {
|
||||
node.className += ' ChatMessage--highlighted';
|
||||
}
|
||||
}
|
||||
// Linkify text
|
||||
const linkifyNodes = node.querySelectorAll('.linkify');
|
||||
for (let i = 0; i < linkifyNodes.length; ++i) {
|
||||
linkifyNode(linkifyNodes[i]);
|
||||
}
|
||||
// Assign an image error handler
|
||||
if (now < message.createdAt + IMAGE_RETRY_MESSAGE_AGE) {
|
||||
const imgNodes = node.querySelectorAll('img');
|
||||
for (let i = 0; i < imgNodes.length; i++) {
|
||||
const imgNode = imgNodes[i];
|
||||
imgNode.addEventListener('error', handleImageError);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Store the node in the message
|
||||
message.node = node;
|
||||
// Query all possible selectors to find out the message type
|
||||
if (!message.type) {
|
||||
// IE8: Does not support querySelector on elements that
|
||||
// are not yet in the document.
|
||||
const typeDef = !Byond.IS_LTE_IE8 && MESSAGE_TYPES
|
||||
.find(typeDef => (
|
||||
typeDef.selector && node.querySelector(typeDef.selector)
|
||||
));
|
||||
message.type = typeDef?.type || MESSAGE_TYPE_UNKNOWN;
|
||||
}
|
||||
updateMessageBadge(message);
|
||||
if (!countByType[message.type]) {
|
||||
countByType[message.type] = 0;
|
||||
}
|
||||
countByType[message.type] += 1;
|
||||
// TODO: Detect duplicates
|
||||
this.messages.push(message);
|
||||
if (canPageAcceptType(this.page, message.type)) {
|
||||
fragment.appendChild(node);
|
||||
this.visibleMessages.push(message);
|
||||
}
|
||||
}
|
||||
if (node) {
|
||||
const firstChild = this.rootNode.childNodes[0];
|
||||
if (prepend && firstChild) {
|
||||
this.rootNode.insertBefore(fragment, firstChild);
|
||||
}
|
||||
else {
|
||||
this.rootNode.appendChild(fragment);
|
||||
}
|
||||
if (this.scrollTracking) {
|
||||
setImmediate(() => this.scrollToBottom());
|
||||
}
|
||||
}
|
||||
// Notify listeners that we have processed the batch
|
||||
if (notifyListeners) {
|
||||
this.events.emit('batchProcessed', countByType);
|
||||
}
|
||||
}
|
||||
|
||||
pruneMessages() {
|
||||
if (!this.isReady()) {
|
||||
return;
|
||||
}
|
||||
// Delay pruning because user is currently interacting
|
||||
// with chat history
|
||||
if (!this.scrollTracking) {
|
||||
logger.debug('pruning delayed');
|
||||
return;
|
||||
}
|
||||
// Visible messages
|
||||
{
|
||||
const messages = this.visibleMessages;
|
||||
const fromIndex = Math.max(0,
|
||||
messages.length - MAX_VISIBLE_MESSAGES);
|
||||
if (fromIndex > 0) {
|
||||
this.visibleMessages = messages.slice(fromIndex);
|
||||
for (let i = 0; i < fromIndex; i++) {
|
||||
const message = messages[i];
|
||||
this.rootNode.removeChild(message.node);
|
||||
// Mark this message as pruned
|
||||
message.node = 'pruned';
|
||||
}
|
||||
// Remove pruned messages from the message array
|
||||
this.messages = this.messages.filter(message => (
|
||||
message.node !== 'pruned'
|
||||
));
|
||||
logger.log(`pruned ${fromIndex} visible messages`);
|
||||
}
|
||||
}
|
||||
// All messages
|
||||
{
|
||||
const fromIndex = Math.max(0,
|
||||
this.messages.length - MAX_PERSISTED_MESSAGES);
|
||||
if (fromIndex > 0) {
|
||||
this.messages = this.messages.slice(fromIndex);
|
||||
logger.log(`pruned ${fromIndex} stored messages`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rebuildChat() {
|
||||
if (!this.isReady()) {
|
||||
return;
|
||||
}
|
||||
// Make a copy of messages
|
||||
const fromIndex = Math.max(0,
|
||||
this.messages.length - MAX_PERSISTED_MESSAGES);
|
||||
const messages = this.messages.slice(fromIndex);
|
||||
// Remove existing nodes
|
||||
for (let message of messages) {
|
||||
message.node = undefined;
|
||||
}
|
||||
// Fast clear of the root node
|
||||
this.rootNode.textContent = '';
|
||||
this.messages = [];
|
||||
this.visibleMessages = [];
|
||||
// Repopulate the chat log
|
||||
this.processBatch(messages, {
|
||||
notifyListeners: false,
|
||||
});
|
||||
}
|
||||
|
||||
saveToDisk() {
|
||||
// Allow only on IE11
|
||||
if (Byond.IS_LTE_IE10) {
|
||||
return;
|
||||
}
|
||||
// Compile currently loaded stylesheets as CSS text
|
||||
let cssText = '';
|
||||
const styleSheets = document.styleSheets;
|
||||
for (let i = 0; i < styleSheets.length; i++) {
|
||||
const cssRules = styleSheets[i].cssRules;
|
||||
for (let i = 0; i < cssRules.length; i++) {
|
||||
const rule = cssRules[i];
|
||||
cssText += rule.cssText + '\n';
|
||||
}
|
||||
}
|
||||
cssText += 'body, html { background-color: #141414 }\n';
|
||||
// Compile chat log as HTML text
|
||||
let messagesHtml = '';
|
||||
for (let message of this.visibleMessages) {
|
||||
if (message.node) {
|
||||
messagesHtml += message.node.outerHTML + '\n';
|
||||
}
|
||||
}
|
||||
// Create a page
|
||||
const pageHtml = '<!doctype html>\n'
|
||||
+ '<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__;
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* Replaces text matching a regular expression with a custom node.
|
||||
*/
|
||||
export const replaceInTextNode = (regex, createNode) => node => {
|
||||
const text = node.textContent;
|
||||
const textLength = text.length;
|
||||
let match;
|
||||
let lastIndex = 0;
|
||||
let fragment;
|
||||
let n = 0;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while (match = regex.exec(text)) {
|
||||
n += 1;
|
||||
// Lazy init fragment
|
||||
if (!fragment) {
|
||||
fragment = document.createDocumentFragment();
|
||||
}
|
||||
const matchText = match[0];
|
||||
const matchLength = matchText.length;
|
||||
const matchIndex = match.index;
|
||||
// Insert previous unmatched chunk
|
||||
if (lastIndex < matchIndex) {
|
||||
fragment.appendChild(document.createTextNode(
|
||||
text.substring(lastIndex, matchIndex)));
|
||||
}
|
||||
lastIndex = matchIndex + matchLength;
|
||||
// Create a wrapper node
|
||||
fragment.appendChild(createNode(matchText));
|
||||
}
|
||||
if (fragment) {
|
||||
// Insert the remaining unmatched chunk
|
||||
if (lastIndex < textLength) {
|
||||
fragment.appendChild(document.createTextNode(
|
||||
text.substring(lastIndex, textLength)));
|
||||
}
|
||||
// Commit the fragment
|
||||
node.parentNode.replaceChild(fragment, node);
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
|
||||
// Highlight
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Default highlight node.
|
||||
*/
|
||||
const createHighlightNode = text => {
|
||||
const node = document.createElement('span');
|
||||
node.setAttribute('style',
|
||||
'background-color:#fd4;color:#000');
|
||||
node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlights the text in the node based on the provided regular expression.
|
||||
*
|
||||
* @param {Node} node Node which you want to process
|
||||
* @param {RegExp} regex Regular expression to highlight
|
||||
* @param {(text: string) => Node} createNode Highlight node creator
|
||||
* @returns {number} Number of matches
|
||||
*/
|
||||
export const highlightNode = (
|
||||
node,
|
||||
regex,
|
||||
createNode = createHighlightNode,
|
||||
) => {
|
||||
if (!createNode) {
|
||||
createNode = createHighlightNode;
|
||||
}
|
||||
let n = 0;
|
||||
const childNodes = node.childNodes;
|
||||
for (let i = 0; i < childNodes.length; i++) {
|
||||
const node = childNodes[i];
|
||||
// Is a text node
|
||||
if (node.nodeType === 3) {
|
||||
n += replaceInTextNode(regex, createNode)(node);
|
||||
}
|
||||
else {
|
||||
n += highlightNode(node, regex, createNode);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
|
||||
// Linkify
|
||||
// --------------------------------------------------------
|
||||
|
||||
const URL_REGEX = /(?:(?:https?:\/\/)|(?:www\.))(?:[^ ]*?\.[^ ]*?)+[-A-Za-z0-9+&@#/%?=~_|$!:,.;()]+/ig;
|
||||
|
||||
/**
|
||||
* Highlights the text in the node based on the provided regular expression.
|
||||
*
|
||||
* @param {Node} node Node which you want to process
|
||||
* @returns {number} Number of matches
|
||||
*/
|
||||
export const linkifyNode = node => {
|
||||
let n = 0;
|
||||
const childNodes = node.childNodes;
|
||||
for (let i = 0; i < childNodes.length; i++) {
|
||||
const node = childNodes[i];
|
||||
const tag = String(node.nodeName).toLowerCase();
|
||||
// Is a text node
|
||||
if (node.nodeType === 3) {
|
||||
n += linkifyTextNode(node);
|
||||
}
|
||||
else if (tag !== 'a') {
|
||||
n += linkifyNode(node);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
const linkifyTextNode = replaceInTextNode(URL_REGEX, text => {
|
||||
const node = document.createElement('a');
|
||||
node.href = text;
|
||||
node.textContent = text;
|
||||
return node;
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* @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]
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const CONNECTION_LOST_AFTER = 15000;
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { useSelector } from 'common/redux';
|
||||
import { selectGame } from './selectors';
|
||||
|
||||
export const useGame = context => {
|
||||
return useSelector(context, selectGame);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { useGame } from './hooks';
|
||||
export { gameMiddleware } from './middleware';
|
||||
export { gameReducer } from './reducer';
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { pingSuccess } from '../ping/actions';
|
||||
import { connectionLost, connectionRestored, roundRestarted } from './actions';
|
||||
import { selectGame } from './selectors';
|
||||
import { CONNECTION_LOST_AFTER } from './constants';
|
||||
|
||||
const withTimestamp = action => ({
|
||||
...action,
|
||||
meta: {
|
||||
...action.meta,
|
||||
now: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
export const gameMiddleware = store => {
|
||||
let lastPingedAt;
|
||||
setInterval(() => {
|
||||
const state = store.getState();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const game = selectGame(state);
|
||||
const pingsAreFailing = lastPingedAt
|
||||
&& Date.now() >= lastPingedAt + CONNECTION_LOST_AFTER;
|
||||
if (!game.connectionLostAt && pingsAreFailing) {
|
||||
store.dispatch(withTimestamp(connectionLost()));
|
||||
}
|
||||
if (game.connectionLostAt && !pingsAreFailing) {
|
||||
store.dispatch(withTimestamp(connectionRestored()));
|
||||
}
|
||||
}, 1000);
|
||||
return next => action => {
|
||||
const { type, payload, meta } = action;
|
||||
if (type === pingSuccess.type) {
|
||||
lastPingedAt = meta.now;
|
||||
return next(action);
|
||||
}
|
||||
if (type === roundRestarted.type) {
|
||||
return next(withTimestamp(action));
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectGame = state => state.game;
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
// Themes
|
||||
import './styles/main.scss';
|
||||
import './styles/themes/light.scss';
|
||||
|
||||
import { perf } from 'common/perf';
|
||||
import { combineReducers } from 'common/redux';
|
||||
import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
|
||||
import { setupGlobalEvents } from 'tgui/events';
|
||||
import { captureExternalLinks } from 'tgui/links';
|
||||
import { createRenderer } from 'tgui/renderer';
|
||||
import { configureStore, StoreProvider } from 'tgui/store';
|
||||
import { audioMiddleware, audioReducer } from './audio';
|
||||
import { chatMiddleware, chatReducer } from './chat';
|
||||
import { gameMiddleware, gameReducer } from './game';
|
||||
import { setupPanelFocusHacks } from './panelFocus';
|
||||
import { pingMiddleware, pingReducer } from './ping';
|
||||
import { settingsMiddleware, settingsReducer } from './settings';
|
||||
import { telemetryMiddleware } from './telemetry';
|
||||
|
||||
perf.mark('inception', window.performance?.timing?.navigationStart);
|
||||
perf.mark('init');
|
||||
|
||||
const store = configureStore({
|
||||
reducer: combineReducers({
|
||||
audio: audioReducer,
|
||||
chat: chatReducer,
|
||||
game: gameReducer,
|
||||
ping: pingReducer,
|
||||
settings: settingsReducer,
|
||||
}),
|
||||
middleware: {
|
||||
pre: [
|
||||
chatMiddleware,
|
||||
pingMiddleware,
|
||||
telemetryMiddleware,
|
||||
settingsMiddleware,
|
||||
audioMiddleware,
|
||||
gameMiddleware,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const renderApp = createRenderer(() => {
|
||||
const { Panel } = require('./Panel');
|
||||
return (
|
||||
<StoreProvider store={store}>
|
||||
<Panel />
|
||||
</StoreProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const setupApp = () => {
|
||||
// Delay setup
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupApp);
|
||||
return;
|
||||
}
|
||||
|
||||
setupGlobalEvents({
|
||||
ignoreWindowFocus: true,
|
||||
});
|
||||
setupPanelFocusHacks();
|
||||
captureExternalLinks();
|
||||
|
||||
// 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();
|
||||
module.hot.accept([
|
||||
'./audio',
|
||||
'./chat',
|
||||
'./game',
|
||||
'./Notifications',
|
||||
'./Panel',
|
||||
'./ping',
|
||||
'./settings',
|
||||
'./telemetry',
|
||||
], () => {
|
||||
renderApp();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setupApp();
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "tgui-panel",
|
||||
"version": "4.3.0",
|
||||
"dependencies": {
|
||||
"common": "workspace:*",
|
||||
"dompurify": "^2.3.8",
|
||||
"inferno": "^7.4.11",
|
||||
"tgui": "workspace:*",
|
||||
"tgui-dev-server": "workspace:*",
|
||||
"tgui-polyfill": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Basically, hacks from goonchat which try to keep the map focused at all
|
||||
* times, except for when some meaningful action happens o
|
||||
*
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { vecLength, vecSubtract } from 'common/vector';
|
||||
import { canStealFocus, globalEvents } from 'tgui/events';
|
||||
import { focusMap } from 'tgui/focus';
|
||||
|
||||
// Empyrically determined number for the smallest possible
|
||||
// text you can select with the mouse.
|
||||
const MIN_SELECTION_DISTANCE = 10;
|
||||
|
||||
const deferredFocusMap = () => setImmediate(() => focusMap());
|
||||
|
||||
export const setupPanelFocusHacks = () => {
|
||||
let focusStolen = false;
|
||||
let clickStartPos = null;
|
||||
window.addEventListener('focusin', e => {
|
||||
focusStolen = canStealFocus(e.target);
|
||||
});
|
||||
window.addEventListener('mousedown', e => {
|
||||
clickStartPos = [e.screenX, e.screenY];
|
||||
});
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (clickStartPos) {
|
||||
const clickEndPos = [e.screenX, e.screenY];
|
||||
const dist = vecLength(vecSubtract(clickEndPos, clickStartPos));
|
||||
if (dist >= MIN_SELECTION_DISTANCE) {
|
||||
focusStolen = true;
|
||||
}
|
||||
}
|
||||
if (!focusStolen) {
|
||||
deferredFocusMap();
|
||||
}
|
||||
});
|
||||
globalEvents.on('keydown', key => {
|
||||
if (key.isModifierKey()) {
|
||||
return;
|
||||
}
|
||||
deferredFocusMap();
|
||||
});
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Color } from 'common/color';
|
||||
import { toFixed } from 'common/math';
|
||||
import { useSelector } from 'common/redux';
|
||||
import { Box } from 'tgui/components';
|
||||
import { selectPing } from './selectors';
|
||||
|
||||
export const PingIndicator = (props, context) => {
|
||||
const ping = useSelector(context, selectPing);
|
||||
const color = Color.lookup(ping.networkQuality, [
|
||||
new Color(220, 40, 40),
|
||||
new Color(220, 200, 40),
|
||||
new Color(60, 220, 40),
|
||||
]);
|
||||
const roundtrip = ping.roundtrip
|
||||
? toFixed(ping.roundtrip)
|
||||
: '--';
|
||||
return (
|
||||
<div className="Ping">
|
||||
<Box
|
||||
className="Ping__indicator"
|
||||
backgroundColor={color} />
|
||||
{roundtrip}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { createAction } from 'common/redux';
|
||||
|
||||
export const pingSuccess = createAction(
|
||||
'ping/success',
|
||||
ping => {
|
||||
const now = Date.now();
|
||||
const roundtrip = (now - ping.sentAt) * 0.5;
|
||||
return {
|
||||
payload: {
|
||||
lastId: ping.id,
|
||||
roundtrip,
|
||||
},
|
||||
meta: { now },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const pingFail = createAction('ping/fail');
|
||||
export const pingReply = createAction('ping/reply');
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const PING_INTERVAL = 2500;
|
||||
export const PING_TIMEOUT = 2000;
|
||||
export const PING_MAX_FAILS = 3;
|
||||
export const PING_QUEUE_SIZE = 8;
|
||||
export const PING_ROUNDTRIP_BEST = 50;
|
||||
export const PING_ROUNDTRIP_WORST = 200;
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { pingMiddleware } from './middleware';
|
||||
export { PingIndicator } from './PingIndicator';
|
||||
export { pingReducer } from './reducer';
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { pingFail, pingSuccess } from './actions';
|
||||
import { PING_INTERVAL, PING_QUEUE_SIZE, PING_TIMEOUT } from './constants';
|
||||
|
||||
export const pingMiddleware = store => {
|
||||
let initialized = false;
|
||||
let index = 0;
|
||||
let interval;
|
||||
const pings = [];
|
||||
const sendPing = () => {
|
||||
for (let i = 0; i < PING_QUEUE_SIZE; i++) {
|
||||
const ping = pings[i];
|
||||
if (ping && Date.now() - ping.sentAt > PING_TIMEOUT) {
|
||||
pings[i] = null;
|
||||
store.dispatch(pingFail());
|
||||
}
|
||||
}
|
||||
const ping = { index, sentAt: Date.now() };
|
||||
pings[index] = ping;
|
||||
Byond.sendMessage('ping', { index });
|
||||
index = (index + 1) % PING_QUEUE_SIZE;
|
||||
};
|
||||
return next => action => {
|
||||
const { type, payload } = action;
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
interval = setInterval(sendPing, PING_INTERVAL);
|
||||
sendPing();
|
||||
}
|
||||
if (type === 'roundrestart') {
|
||||
// Stop pinging because dreamseeker is currently reconnecting.
|
||||
// Topic calls in the middle of reconnect will crash the connection.
|
||||
clearInterval(interval);
|
||||
return next(action);
|
||||
}
|
||||
if (type === 'pingReply') {
|
||||
const { index } = payload;
|
||||
const ping = pings[index];
|
||||
// Received a timed out ping
|
||||
if (!ping) {
|
||||
return;
|
||||
}
|
||||
pings[index] = null;
|
||||
return next(pingSuccess(ping));
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectPing = state => state.ping;
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { toFixed } from 'common/math';
|
||||
import { useLocalState } from 'tgui/backend';
|
||||
import { useDispatch, useSelector } from 'common/redux';
|
||||
import { Box, Button, ColorBox, Divider, Dropdown, Flex, Input, LabeledList, NumberInput, Section, Stack, Tabs, TextArea } from 'tgui/components';
|
||||
import { ChatPageSettings } from '../chat';
|
||||
import { rebuildChat, saveChatToDisk } from '../chat/actions';
|
||||
import { THEMES } from '../themes';
|
||||
import { changeSettingsTab, updateSettings } from './actions';
|
||||
import { FONTS, SETTINGS_TABS } from './constants';
|
||||
import { selectActiveTab, selectSettings } from './selectors';
|
||||
|
||||
export const SettingsPanel = (props, context) => {
|
||||
const activeTab = useSelector(context, selectActiveTab);
|
||||
const dispatch = useDispatch(context);
|
||||
return (
|
||||
<Stack fill>
|
||||
<Stack.Item>
|
||||
<Section fitted fill minHeight="8em">
|
||||
<Tabs vertical>
|
||||
{SETTINGS_TABS.map(tab => (
|
||||
<Tabs.Tab
|
||||
key={tab.id}
|
||||
selected={tab.id === activeTab}
|
||||
onClick={() => dispatch(changeSettingsTab({
|
||||
tabId: tab.id,
|
||||
}))}>
|
||||
{tab.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow={1} basis={0}>
|
||||
{activeTab === 'general' && (
|
||||
<SettingsGeneral />
|
||||
)}
|
||||
{activeTab === 'chatPage' && (
|
||||
<ChatPageSettings />
|
||||
)}
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsGeneral = (props, context) => {
|
||||
const {
|
||||
theme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
highlightText,
|
||||
highlightColor,
|
||||
} = useSelector(context, selectSettings);
|
||||
const dispatch = useDispatch(context);
|
||||
const [freeFont, setFreeFont] = useLocalState(context, "freeFont", false);
|
||||
return (
|
||||
<Section>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Theme">
|
||||
<Dropdown
|
||||
selected={theme}
|
||||
options={THEMES}
|
||||
onSelected={value => dispatch(updateSettings({
|
||||
theme: value,
|
||||
}))} />
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Font style">
|
||||
<Stack inline align="baseline">
|
||||
<Stack.Item>
|
||||
{!freeFont && (
|
||||
<Dropdown
|
||||
selected={fontFamily}
|
||||
options={FONTS}
|
||||
onSelected={value => dispatch(updateSettings({
|
||||
fontFamily: value,
|
||||
}))} />
|
||||
) || (
|
||||
<Input
|
||||
value={fontFamily}
|
||||
onChange={(e, value) => dispatch(updateSettings({
|
||||
fontFamily: value,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
content="Custom font"
|
||||
icon={freeFont? "lock-open" : "lock"}
|
||||
color={freeFont? "good" : "bad"}
|
||||
ml={1}
|
||||
onClick={() => {
|
||||
setFreeFont(!freeFont);
|
||||
}}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Font size">
|
||||
<NumberInput
|
||||
width="4em"
|
||||
step={1}
|
||||
stepPixelSize={10}
|
||||
minValue={8}
|
||||
maxValue={32}
|
||||
value={fontSize}
|
||||
unit="px"
|
||||
format={value => toFixed(value)}
|
||||
onChange={(e, value) => dispatch(updateSettings({
|
||||
fontSize: value,
|
||||
}))} />
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Line height">
|
||||
<NumberInput
|
||||
width="4em"
|
||||
step={0.01}
|
||||
stepPixelSize={2}
|
||||
minValue={0.8}
|
||||
maxValue={5}
|
||||
value={lineHeight}
|
||||
format={value => toFixed(value, 2)}
|
||||
onDrag={(e, value) => dispatch(updateSettings({
|
||||
lineHeight: value,
|
||||
}))} />
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Flex mb={1} color="label" align="baseline">
|
||||
<Flex.Item grow={1}>
|
||||
Highlight words (comma separated):
|
||||
</Flex.Item>
|
||||
<Flex.Item shrink={0}>
|
||||
<ColorBox mr={1} color={highlightColor} />
|
||||
<Input
|
||||
width="5em"
|
||||
monospace
|
||||
placeholder="#ffffff"
|
||||
value={highlightColor}
|
||||
onInput={(e, value) => dispatch(updateSettings({
|
||||
highlightColor: value,
|
||||
}))} />
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
<TextArea
|
||||
height="3em"
|
||||
value={highlightText}
|
||||
onChange={(e, value) => dispatch(updateSettings({
|
||||
highlightText: value,
|
||||
}))} />
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Button
|
||||
icon="check"
|
||||
onClick={() => dispatch(rebuildChat())}>
|
||||
Apply now
|
||||
</Button>
|
||||
<Box inline fontSize="0.9em" ml={1} color="label">
|
||||
Can freeze the chat for a while.
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Button
|
||||
icon="save"
|
||||
onClick={() => dispatch(saveChatToDisk())}>
|
||||
Save chat log
|
||||
</Button>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { createAction } from 'common/redux';
|
||||
|
||||
export const updateSettings = createAction('settings/update');
|
||||
export const loadSettings = createAction('settings/load');
|
||||
export const changeSettingsTab = createAction('settings/changeTab');
|
||||
export const toggleSettings = createAction('settings/toggle');
|
||||
export const openChatSettings = createAction('settings/openChatTab');
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const SETTINGS_TABS = [
|
||||
{
|
||||
id: 'general',
|
||||
name: 'General',
|
||||
},
|
||||
{
|
||||
id: 'chatPage',
|
||||
name: 'Chat Tabs',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
export const FONTS_DISABLED = "Default";
|
||||
|
||||
export const FONTS = [
|
||||
FONTS_DISABLED,
|
||||
'Verdana',
|
||||
'Arial',
|
||||
'Arial Black',
|
||||
'Comic Sans MS',
|
||||
'Impact',
|
||||
'Lucida Sans Unicode',
|
||||
'Tahoma',
|
||||
'Trebuchet MS',
|
||||
'Courier New',
|
||||
'Lucida Console',
|
||||
];
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* @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)),
|
||||
};
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { useSettings } from './hooks';
|
||||
export { settingsMiddleware } from './middleware';
|
||||
export { settingsReducer } from './reducer';
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { storage } from 'common/storage';
|
||||
import { setClientTheme } from '../themes';
|
||||
import { loadSettings, updateSettings } from './actions';
|
||||
import { selectSettings } from './selectors';
|
||||
import { FONTS_DISABLED } from './constants';
|
||||
|
||||
const setGlobalFontSize = fontSize => {
|
||||
document.documentElement.style
|
||||
.setProperty('font-size', fontSize + 'px');
|
||||
document.body.style
|
||||
.setProperty('font-size', fontSize + 'px');
|
||||
};
|
||||
|
||||
const setGlobalFontFamily = fontFamily => {
|
||||
if (fontFamily === FONTS_DISABLED) fontFamily = null;
|
||||
|
||||
document.documentElement.style
|
||||
.setProperty('font-family', fontFamily);
|
||||
document.body.style
|
||||
.setProperty('font-family', fontFamily);
|
||||
};
|
||||
|
||||
export const settingsMiddleware = store => {
|
||||
let initialized = false;
|
||||
return next => action => {
|
||||
const { type, payload } = action;
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
storage.get('panel-settings').then(settings => {
|
||||
store.dispatch(loadSettings(settings));
|
||||
});
|
||||
}
|
||||
if (type === updateSettings.type || type === loadSettings.type) {
|
||||
// Set client theme
|
||||
const theme = payload?.theme;
|
||||
if (theme) {
|
||||
setClientTheme(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);
|
||||
// Save settings to the web storage
|
||||
storage.set('panel-settings', settings);
|
||||
return;
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { changeSettingsTab, loadSettings, openChatSettings, toggleSettings, updateSettings } from './actions';
|
||||
import { FONTS, SETTINGS_TABS } from './constants';
|
||||
|
||||
const initialState = {
|
||||
version: 1,
|
||||
fontSize: 13,
|
||||
fontFamily: FONTS[0],
|
||||
lineHeight: 1.2,
|
||||
theme: 'light',
|
||||
adminMusicVolume: 0.5,
|
||||
highlightText: '',
|
||||
highlightColor: '#ffdd44',
|
||||
view: {
|
||||
visible: false,
|
||||
activeTab: SETTINGS_TABS[0].id,
|
||||
},
|
||||
};
|
||||
|
||||
export const settingsReducer = (state = initialState, action) => {
|
||||
const { type, payload } = action;
|
||||
if (type === updateSettings.type) {
|
||||
return {
|
||||
...state,
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
if (type === loadSettings.type) {
|
||||
// Validate version and/or migrate state
|
||||
if (!payload?.version) {
|
||||
return state;
|
||||
}
|
||||
delete payload.view;
|
||||
return {
|
||||
...state,
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
if (type === toggleSettings.type) {
|
||||
return {
|
||||
...state,
|
||||
view: {
|
||||
...state.view,
|
||||
visible: !state.view.visible,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === openChatSettings.type) {
|
||||
return {
|
||||
...state,
|
||||
view: {
|
||||
...state.view,
|
||||
visible: true,
|
||||
activeTab: 'chatPage',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === changeSettingsTab.type) {
|
||||
const { tabId } = payload;
|
||||
return {
|
||||
...state,
|
||||
view: {
|
||||
...state.view,
|
||||
activeTab: tabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectSettings = state => state.settings;
|
||||
export const selectActiveTab = state => state.settings.view.activeTab;
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2020 Aleksej Komarov
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
@use 'sass:color';
|
||||
@use "sass:math";
|
||||
@use '~tgui/styles/base.scss';
|
||||
@use '~tgui/styles/colors.scss';
|
||||
|
||||
$text-color: #abc6ec !default;
|
||||
$color-bg-section: base.$color-bg-section !default;
|
||||
|
||||
.Chat {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.Chat__badge {
|
||||
display: inline-block;
|
||||
min-width: 0.5em;
|
||||
font-size: 0.7em;
|
||||
padding: 0.2em 0.3em;
|
||||
line-height: 1;
|
||||
color: white;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
background-color: crimson;
|
||||
border-radius: 10px;
|
||||
transition: font-size 200ms ease-out;
|
||||
|
||||
&:before {
|
||||
content: 'x';
|
||||
}
|
||||
}
|
||||
|
||||
.Chat__badge--animate {
|
||||
font-size: 0.9em;
|
||||
transition: font-size 0ms;
|
||||
}
|
||||
|
||||
.Chat__scrollButton {
|
||||
position: fixed;
|
||||
right: 2em;
|
||||
bottom: 1em;
|
||||
}
|
||||
|
||||
.Chat__reconnected {
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
margin: 1em 0 2em;
|
||||
|
||||
&:before {
|
||||
content: 'Reconnected';
|
||||
display: inline-block;
|
||||
border-radius: 1em;
|
||||
padding: 0 0.7em;
|
||||
color: colors.$red;
|
||||
background-color: $color-bg-section;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
margin-top: -0.75em;
|
||||
border-bottom: (math.div(1em, 6)) solid colors.$red;
|
||||
}
|
||||
}
|
||||
|
||||
.Chat__highlight {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.Chat__highlight--restricted {
|
||||
color: #fff;
|
||||
background-color: #a00;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ChatMessage {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.ChatMessage--highlighted {
|
||||
position: relative;
|
||||
border-left: (math.div(1em, 6)) solid rgba(255, 221, 68);
|
||||
padding-left: 0.5em;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(255, 221, 68, 0.1);
|
||||
// Make this click-through since this is an overlay
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,883 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2020 Aleksej Komarov
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
img {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
img.icon {
|
||||
height: 1em;
|
||||
min-height: 16px;
|
||||
width: auto;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #397ea5;
|
||||
}
|
||||
|
||||
a.visited {
|
||||
color: #7c00e6;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #7c00e6;
|
||||
}
|
||||
|
||||
a.popt {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* POPUPS */
|
||||
|
||||
.popup {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.popup .close {
|
||||
position: absolute;
|
||||
background: #aaa;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
z-index: 2;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.popup .close:hover {
|
||||
background: #999;
|
||||
}
|
||||
|
||||
.popup .head {
|
||||
background: #999;
|
||||
color: #ddd;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid green;
|
||||
}
|
||||
|
||||
.popup input {
|
||||
border: 1px solid #999;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
outline: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup input[type=text]:hover,
|
||||
.popup input[type=text]:active,
|
||||
.popup input[type=text]:focus {
|
||||
border-color: green;
|
||||
}
|
||||
|
||||
.popup input[type=submit] {
|
||||
padding: 5px 10px;
|
||||
background: #999;
|
||||
color: #ddd;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.popup input[type=submit]:hover,
|
||||
.popup input[type=submit]:focus,
|
||||
.popup input[type=submit]:active {
|
||||
background: #aaa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changeFont {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.changeFont a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
padding: 3px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.changeFont a:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.highlightPopup {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlightPopup input[type=text] {
|
||||
display: block;
|
||||
width: 215px;
|
||||
text-align: left;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.highlightPopup input.highlightColor {
|
||||
background-color: #FFFF00;
|
||||
}
|
||||
|
||||
.highlightPopup input.highlightTermSubmit {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ADMIN CONTEXT MENU */
|
||||
|
||||
|
||||
.contextMenu {
|
||||
background-color: #ddd;
|
||||
position: fixed;
|
||||
margin: 2px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.contextMenu a {
|
||||
display: block;
|
||||
padding: 2px 5px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contextMenu a:hover {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ADMIN FILTER MESSAGES MENU */
|
||||
|
||||
|
||||
.filterMessages {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.filterMessages div {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.filterMessages input {
|
||||
}
|
||||
|
||||
.filterMessages label {
|
||||
}
|
||||
|
||||
.icon-stack {
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
width: 1em;
|
||||
vertical-align: middle;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*****************************************
|
||||
*
|
||||
* OUTPUT ACTUALLY RELATED TO MESSAGES
|
||||
*
|
||||
******************************************/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* MOTD */
|
||||
|
||||
|
||||
.motd {
|
||||
color: #a4bad6;
|
||||
font-family: Verdana, sans-serif;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.motd h1,
|
||||
.motd h2,
|
||||
.motd h3,
|
||||
.motd h4,
|
||||
.motd h5,
|
||||
.motd h6 {
|
||||
color: #a4bad6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.motd a,
|
||||
.motd a:link,
|
||||
.motd a:visited,
|
||||
.motd a:active,
|
||||
.motd a:hover {
|
||||
color: #a4bad6;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ADD HERE FOR BOLD */
|
||||
|
||||
|
||||
.bold,
|
||||
.name,
|
||||
.prefix,
|
||||
.ooc,
|
||||
.looc,
|
||||
.adminooc,
|
||||
.admin,
|
||||
.medal,
|
||||
.yell {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ADD HERE FOR ITALIC */
|
||||
|
||||
|
||||
.italic, .italics {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* OUTPUT COLORS */
|
||||
|
||||
|
||||
.highlight {
|
||||
background: yellow;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #a4bad6;
|
||||
font-family: Georgia, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
h1.alert, h2.alert {
|
||||
color: #a4bad6;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ooc {
|
||||
color: #cca300;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.adminobserverooc {
|
||||
color: #0099cc;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.adminooc {
|
||||
color: #3d5bc3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.adminsay {
|
||||
color: #ff4500;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin {
|
||||
color: #5975da;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.say, .emote, .infoplain, .oocplain, .warningplain {
|
||||
}
|
||||
|
||||
.deadsay {
|
||||
color: #e2c1ff;
|
||||
}
|
||||
|
||||
.binarysay {
|
||||
color: #1e90ff;
|
||||
}
|
||||
|
||||
.binarysay a {
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.binarysay a:active, .binarysay a:visited {
|
||||
color: #88ff88;
|
||||
}
|
||||
|
||||
/* RADIO COLORS */
|
||||
/* IF YOU CHANGE THIS KEEP IT IN SYNC WITH TGUI CONSTANTS */
|
||||
|
||||
.radio {
|
||||
color: #1ecc43;
|
||||
}
|
||||
|
||||
.sciradio {
|
||||
color: #c68cfa;
|
||||
}
|
||||
|
||||
.comradio {
|
||||
color: #fcdf03;
|
||||
}
|
||||
|
||||
.secradio {
|
||||
color: #dd3535;
|
||||
}
|
||||
|
||||
.medradio {
|
||||
color: #57b8f0;
|
||||
}
|
||||
|
||||
.engradio {
|
||||
color: #f37746;
|
||||
}
|
||||
|
||||
.suppradio {
|
||||
color: #b88646;
|
||||
}
|
||||
|
||||
.servradio {
|
||||
color: #6ca729;
|
||||
}
|
||||
|
||||
.syndradio {
|
||||
color: #8f4a4b;
|
||||
}
|
||||
|
||||
.centcomradio {
|
||||
color: #2681a5;
|
||||
}
|
||||
|
||||
.aiprivradio {
|
||||
color: #d65d95;
|
||||
}
|
||||
|
||||
.redteamradio {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.blueteamradio {
|
||||
color: #3434fd;
|
||||
}
|
||||
|
||||
.greenteamradio {
|
||||
color: #34fd34;
|
||||
}
|
||||
|
||||
.yellowteamradio {
|
||||
color: #fdfd34;
|
||||
}
|
||||
|
||||
.yell {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alert {
|
||||
color: #d82020;
|
||||
}
|
||||
|
||||
.userdanger {
|
||||
color: #c51e1e;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #c51e1e;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #c51e1e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.alertwarning {
|
||||
color: #FF0000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.boldwarning {
|
||||
color: #c51e1e;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.announce {
|
||||
color: #c51e1e;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.boldannounce {
|
||||
color: #c51e1e;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.minorannounce {
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.greenannounce {
|
||||
color: #059223;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rose {
|
||||
color: #ff5050;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #9ab0ff;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: #6685f5;
|
||||
}
|
||||
|
||||
.tinynotice {
|
||||
color: #6685f5;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.tinynoticeital {
|
||||
color: #6685f5;
|
||||
font-style: italic;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.smallnotice {
|
||||
color: #6685f5;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.smallnoticeital {
|
||||
color: #6685f5;
|
||||
font-style: italic;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.boldnotice {
|
||||
color: #6685f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hear {
|
||||
color: #6685f5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.adminnotice {
|
||||
color: #6685f5;
|
||||
}
|
||||
|
||||
.adminhelp {
|
||||
color: #ff0000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.unconscious {
|
||||
color: #a4bad6;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.suicide {
|
||||
color: #ff5050;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.green {
|
||||
color: #059223;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: #FF0000;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: #215cff;
|
||||
}
|
||||
|
||||
.nicegreen {
|
||||
color: #059223;
|
||||
}
|
||||
|
||||
.cult {
|
||||
color: #973e3b;
|
||||
}
|
||||
|
||||
.cultitalic {
|
||||
color: #973e3b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cultbold {
|
||||
color: #973e3b;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cultboldtalic {
|
||||
color: #973e3b;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.cultlarge {
|
||||
color: #973e3b;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.narsie {
|
||||
color: #973e3b;
|
||||
font-weight: bold;
|
||||
font-size: 925%;
|
||||
}
|
||||
|
||||
.narsiesmall {
|
||||
color: #973e3b;
|
||||
font-weight: bold;
|
||||
font-size: 370%;
|
||||
}
|
||||
|
||||
.colossus {
|
||||
color: #7F282A;
|
||||
font-size: 310%;
|
||||
}
|
||||
|
||||
.hierophant {
|
||||
color: #b441ee;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hierophant_warning {
|
||||
color: #c56bf1;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.purple {
|
||||
color: #9956d3;
|
||||
}
|
||||
|
||||
.holoparasite {
|
||||
color: #88809c;
|
||||
}
|
||||
|
||||
.revennotice {
|
||||
color: #c099e2;
|
||||
}
|
||||
|
||||
.revenboldnotice {
|
||||
color: #c099e2;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.revenbignotice {
|
||||
color: #c099e2;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.revenminor {
|
||||
color: #823abb;
|
||||
}
|
||||
|
||||
.revenwarning {
|
||||
color: #760fbb;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.revendanger {
|
||||
color: #760fbb;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.deconversion_message {
|
||||
color: #a947ff;
|
||||
font-size: 185%;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ghostalert {
|
||||
color: #6600ff;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alien {
|
||||
color: #855d85;
|
||||
}
|
||||
|
||||
.noticealien {
|
||||
color: #059223;
|
||||
}
|
||||
|
||||
.alertalien {
|
||||
color: #059223;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.changeling {
|
||||
color: #059223;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.alertsyndie {
|
||||
color: #FF0000;
|
||||
font-size: 185%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.spider {
|
||||
color: #8800ff;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.interface {
|
||||
color: #750e75;
|
||||
}
|
||||
|
||||
.sans {
|
||||
font-family: "Comic Sans MS", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.papyrus {
|
||||
font-family: "Papyrus", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.robot {
|
||||
font-family: "Courier New", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.tape_recorder {
|
||||
color: #FF0000;
|
||||
font-family: "Courier New", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.command_headset {
|
||||
font-weight: bold;
|
||||
font-size: 160%;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.reallybig {
|
||||
font-size: 245%;
|
||||
}
|
||||
|
||||
.extremelybig {
|
||||
font-size: 310%;
|
||||
}
|
||||
|
||||
.greentext {
|
||||
color: #059223;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.redtext {
|
||||
color: #c51e1e;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.clown {
|
||||
color: #ff70c1;
|
||||
font-size: 160%;
|
||||
font-family: "Comic Sans MS", cursive, sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.singing {
|
||||
font-family: "Trebuchet MS", cursive, sans-serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.his_grace {
|
||||
color: #15D512;
|
||||
font-family: "Courier New", cursive, sans-serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hypnophrase {
|
||||
color: #202020;
|
||||
font-weight: bold;
|
||||
animation: hypnocolor 1500ms infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
@keyframes hypnocolor {
|
||||
0% {
|
||||
color: #202020;
|
||||
}
|
||||
|
||||
25% {
|
||||
color: #4b02ac;
|
||||
}
|
||||
|
||||
50% {
|
||||
color: #9f41f1;
|
||||
}
|
||||
|
||||
75% {
|
||||
color: #541c9c;
|
||||
}
|
||||
|
||||
100% {
|
||||
color: #7adbf3;
|
||||
}
|
||||
}
|
||||
|
||||
.phobia {
|
||||
color: #dd0000;
|
||||
font-weight: bold;
|
||||
animation: phobia 750ms infinite;
|
||||
}
|
||||
|
||||
@keyframes phobia {
|
||||
0% {
|
||||
color: #f75a5a;
|
||||
}
|
||||
|
||||
50% {
|
||||
color: #dd0000;
|
||||
}
|
||||
|
||||
100% {
|
||||
color: #f75a5a;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1em;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.bigicon {
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
.memo {
|
||||
color: #638500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.memoedit {
|
||||
text-align: center;
|
||||
font-size: 125%;
|
||||
}
|
||||
|
||||
.abductor {
|
||||
color: #c204c2;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mind_control {
|
||||
color: #df3da9;
|
||||
font-size: 100%;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.slime {
|
||||
color: #00CED1;
|
||||
}
|
||||
|
||||
.drone {
|
||||
color: #848482;
|
||||
}
|
||||
|
||||
.monkey {
|
||||
color: #975032;
|
||||
}
|
||||
|
||||
.swarmer {
|
||||
color: #2C75FF;
|
||||
}
|
||||
|
||||
.resonate {
|
||||
color: #298F85;
|
||||
}
|
||||
|
||||
.monkeyhive {
|
||||
color: #a56408;
|
||||
}
|
||||
|
||||
.monkeylead {
|
||||
color: #af6805;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.connectionClosed, .fatalError {
|
||||
background: red;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.connectionClosed.restored {
|
||||
background: green;
|
||||
}
|
||||
|
||||
.internal.boldnshit {
|
||||
color: #3d5bc3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* HELPER CLASSES */
|
||||
|
||||
|
||||
.text-normal {
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: 3em;
|
||||
}
|
||||
@@ -1,921 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2020 Aleksej Komarov
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #fff;
|
||||
font-family: Verdana, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
img {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
img.icon {
|
||||
height: 1em;
|
||||
min-height: 16px;
|
||||
width: auto;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
a.visited {
|
||||
color: #ff00ff;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #ff00ff;
|
||||
}
|
||||
|
||||
a.popt {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* POPUPS */
|
||||
|
||||
.popup {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.popup .close {
|
||||
position: absolute;
|
||||
background: #aaa;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
z-index: 2;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.popup .close:hover {
|
||||
background: #999;
|
||||
}
|
||||
|
||||
.popup .head {
|
||||
background: #999;
|
||||
color: #ddd;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid green;
|
||||
}
|
||||
|
||||
.popup input {
|
||||
border: 1px solid #999;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
outline: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup input[type=text]:hover,
|
||||
.popup input[type=text]:active,
|
||||
.popup input[type=text]:focus {
|
||||
border-color: green;
|
||||
}
|
||||
|
||||
.popup input[type=submit] {
|
||||
padding: 5px 10px;
|
||||
background: #999;
|
||||
color: #ddd;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.popup input[type=submit]:hover,
|
||||
.popup input[type=submit]:focus,
|
||||
.popup input[type=submit]:active {
|
||||
background: #aaa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changeFont {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.changeFont a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
padding: 3px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.changeFont a:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.highlightPopup {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlightPopup input[type=text] {
|
||||
display: block;
|
||||
width: 215px;
|
||||
text-align: left;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.highlightPopup input.highlightColor {
|
||||
background-color: #FFFF00;
|
||||
}
|
||||
|
||||
.highlightPopup input.highlightTermSubmit {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ADMIN CONTEXT MENU */
|
||||
|
||||
|
||||
.contextMenu {
|
||||
background-color: #ddd;
|
||||
position: fixed;
|
||||
margin: 2px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.contextMenu a {
|
||||
display: block;
|
||||
padding: 2px 5px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contextMenu a:hover {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ADMIN FILTER MESSAGES MENU */
|
||||
|
||||
|
||||
.filterMessages {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.filterMessages div {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.filterMessages input {
|
||||
}
|
||||
|
||||
.filterMessages label {
|
||||
}
|
||||
|
||||
.icon-stack {
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
width: 1em;
|
||||
vertical-align: middle;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*****************************************
|
||||
*
|
||||
* OUTPUT ACTUALLY RELATED TO MESSAGES
|
||||
*
|
||||
******************************************/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* MOTD */
|
||||
|
||||
|
||||
.motd {
|
||||
color: #638500;
|
||||
font-family: Verdana, sans-serif;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.motd h1,
|
||||
.motd h2,
|
||||
.motd h3,
|
||||
.motd h4,
|
||||
.motd h5,
|
||||
.motd h6 {
|
||||
color: #638500;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.motd a,
|
||||
.motd a:link,
|
||||
.motd a:visited,
|
||||
.motd a:active,
|
||||
.motd a:hover {
|
||||
color: #638500;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ADD HERE FOR BOLD */
|
||||
|
||||
|
||||
.bold,
|
||||
.name,
|
||||
.prefix,
|
||||
.ooc,
|
||||
.looc,
|
||||
.adminooc,
|
||||
.admin,
|
||||
.medal,
|
||||
.yell {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ADD HERE FOR ITALIC */
|
||||
|
||||
|
||||
.italic,
|
||||
.italics {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* OUTPUT COLORS */
|
||||
|
||||
|
||||
.highlight {
|
||||
background: yellow;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #0000ff;
|
||||
font-family: Georgia, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
h1.alert, h2.alert {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ooc {
|
||||
color: #002eb8;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.adminobserverooc {
|
||||
color: #0099cc;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.adminooc {
|
||||
color: #700038;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.adminsay {
|
||||
color: #ff4500;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin {
|
||||
color: #4473ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.say, .emote, .infoplain, .oocplain, .warningplain {
|
||||
}
|
||||
|
||||
.deadsay {
|
||||
color: #5c00e6;
|
||||
}
|
||||
|
||||
.binarysay {
|
||||
color: #20c20e;
|
||||
background-color: #000000;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.binarysay a {
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.binarysay a:active,
|
||||
.binarysay a:visited {
|
||||
color: #88ff88;
|
||||
}
|
||||
|
||||
.radio {
|
||||
color: #008000;
|
||||
}
|
||||
|
||||
.sciradio {
|
||||
color: #993399;
|
||||
}
|
||||
|
||||
.comradio {
|
||||
color: #948f02;
|
||||
}
|
||||
|
||||
.secradio {
|
||||
color: #a30000;
|
||||
}
|
||||
|
||||
.medradio {
|
||||
color: #337296;
|
||||
}
|
||||
|
||||
.engradio {
|
||||
color: #fb5613;
|
||||
}
|
||||
|
||||
.suppradio {
|
||||
color: #a8732b;
|
||||
}
|
||||
|
||||
.servradio {
|
||||
color: #6eaa2c;
|
||||
}
|
||||
|
||||
.syndradio {
|
||||
color: #6d3f40;
|
||||
}
|
||||
|
||||
.centcomradio {
|
||||
color: #686868;
|
||||
}
|
||||
|
||||
.aiprivradio {
|
||||
color: #ff00ff;
|
||||
}
|
||||
|
||||
.redteamradio {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.blueteamradio {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
.greenteamradio {
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.yellowteamradio {
|
||||
color: #d1ba22;
|
||||
}
|
||||
|
||||
.yell {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alert {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
h1.alert, h2.alert {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.userdanger {
|
||||
color: #ff0000;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.bolddanger {
|
||||
color: #ff0000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.tinydanger {
|
||||
color: #ff0000;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.smalldanger {
|
||||
color: #ff0000;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #ff0000;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.alertwarning {
|
||||
color: #FF0000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.boldwarning {
|
||||
color: #ff0000;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.announce {
|
||||
color: #228b22;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.boldannounce {
|
||||
color: #ff0000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.minorannounce {
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.greenannounce {
|
||||
color: #00ff00;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rose {
|
||||
color: #ff5050;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #0000CC;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: #000099;
|
||||
}
|
||||
|
||||
.tinynotice {
|
||||
color: #000099;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.tinynoticeital {
|
||||
color: #000099;
|
||||
font-style: italic;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.smallnotice {
|
||||
color: #000099;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.smallnoticeital {
|
||||
color: #000099;
|
||||
font-style: italic;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.boldnotice {
|
||||
color: #000099;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hear {
|
||||
color: #000099;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.adminnotice {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
.adminhelp {
|
||||
color: #ff0000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.unconscious {
|
||||
color: #0000ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.suicide {
|
||||
color: #ff5050;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.green {
|
||||
color: #03ff39;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: #FF0000;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: #0000FF;
|
||||
}
|
||||
|
||||
.nicegreen {
|
||||
color: #14a833;
|
||||
}
|
||||
|
||||
.cult {
|
||||
color: #973e3b;
|
||||
}
|
||||
|
||||
.cultitalic {
|
||||
color: #973e3b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cultbold {
|
||||
color: #973e3b;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cultboldtalic {
|
||||
color: #973e3b;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.cultlarge {
|
||||
color: #973e3b;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.narsie {
|
||||
color: #973e3b;
|
||||
font-weight: bold;
|
||||
font-size: 925%;
|
||||
}
|
||||
|
||||
.narsiesmall {
|
||||
color: #973e3b;
|
||||
font-weight: bold;
|
||||
font-size: 370%;
|
||||
}
|
||||
|
||||
.colossus {
|
||||
color: #7F282A;
|
||||
font-size: 310%;
|
||||
}
|
||||
|
||||
.hierophant {
|
||||
color: #660099;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hierophant_warning {
|
||||
color: #660099;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.purple {
|
||||
color: #5e2d79;
|
||||
}
|
||||
|
||||
.holoparasite {
|
||||
color: #35333a;
|
||||
}
|
||||
|
||||
.revennotice {
|
||||
color: #1d2953;
|
||||
}
|
||||
|
||||
.revenboldnotice {
|
||||
color: #1d2953;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.revenbignotice {
|
||||
color: #1d2953;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.revenminor {
|
||||
color: #823abb;
|
||||
}
|
||||
|
||||
.revenwarning {
|
||||
color: #760fbb;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.revendanger {
|
||||
color: #760fbb;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.deconversion_message {
|
||||
color: #5000A0;
|
||||
font-size: 185%;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ghostalert {
|
||||
color: #5c00e6;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alien {
|
||||
color: #543354;
|
||||
}
|
||||
|
||||
.noticealien {
|
||||
color: #00c000;
|
||||
}
|
||||
|
||||
.alertalien {
|
||||
color: #00c000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.changeling {
|
||||
color: #800080;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.alertsyndie {
|
||||
color: #FF0000;
|
||||
font-size: 185%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.spider {
|
||||
color: #4d004d;
|
||||
font-weight: bold;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.interface {
|
||||
color: #330033;
|
||||
}
|
||||
|
||||
.sans {
|
||||
font-family: "Comic Sans MS", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.papyrus {
|
||||
font-family: "Papyrus", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.robot {
|
||||
font-family: "Courier New", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.tape_recorder {
|
||||
color: #800000;
|
||||
font-family: "Courier New", cursive, sans-serif;
|
||||
}
|
||||
|
||||
.command_headset {
|
||||
font-weight: bold;
|
||||
font-size: 160%;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.reallybig {
|
||||
font-size: 245%;
|
||||
}
|
||||
|
||||
.extremelybig {
|
||||
font-size: 310%;
|
||||
}
|
||||
|
||||
.greentext {
|
||||
color: #00FF00;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.redtext {
|
||||
color: #FF0000;
|
||||
font-size: 185%;
|
||||
}
|
||||
|
||||
.clown {
|
||||
color: #FF69Bf;
|
||||
font-size: 160%;
|
||||
font-family: "Comic Sans MS", cursive, sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.singing {
|
||||
font-family: "Trebuchet MS", cursive, sans-serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.his_grace {
|
||||
color: #15D512;
|
||||
font-family: "Courier New", cursive, sans-serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hypnophrase {
|
||||
color: #0d0d0d;
|
||||
font-weight: bold;
|
||||
animation: hypnocolor 1500ms infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
@keyframes hypnocolor {
|
||||
0% {
|
||||
color: #0d0d0d;
|
||||
}
|
||||
|
||||
25% {
|
||||
color: #410194;
|
||||
}
|
||||
|
||||
50% {
|
||||
color: #7f17d8;
|
||||
}
|
||||
|
||||
75% {
|
||||
color: #410194;
|
||||
}
|
||||
|
||||
100% {
|
||||
color: #3bb5d3;
|
||||
}
|
||||
}
|
||||
|
||||
.phobia {
|
||||
color: #dd0000;
|
||||
font-weight: bold;
|
||||
animation: phobia 750ms infinite;
|
||||
}
|
||||
|
||||
@keyframes phobia {
|
||||
0% {
|
||||
color: #0d0d0d;
|
||||
}
|
||||
|
||||
50% {
|
||||
color: #dd0000;
|
||||
}
|
||||
|
||||
100% {
|
||||
color: #0d0d0d;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1em;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.bigicon {
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
.memo {
|
||||
color: #638500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.memoedit {
|
||||
text-align: center;
|
||||
font-size: 125%;
|
||||
}
|
||||
|
||||
.abductor {
|
||||
color: #800080;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mind_control {
|
||||
color: #A00D6F;
|
||||
font-size: 100%;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.slime {
|
||||
color: #00CED1;
|
||||
}
|
||||
|
||||
.drone {
|
||||
color: #848482;
|
||||
}
|
||||
|
||||
.monkey {
|
||||
color: #975032;
|
||||
}
|
||||
|
||||
.swarmer {
|
||||
color: #2C75FF;
|
||||
}
|
||||
|
||||
.resonate {
|
||||
color: #298F85;
|
||||
}
|
||||
|
||||
.monkeyhive {
|
||||
color: #774704;
|
||||
}
|
||||
|
||||
.monkeylead {
|
||||
color: #774704;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.connectionClosed,
|
||||
.fatalError {
|
||||
background: red;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.connectionClosed.restored {
|
||||
background: green;
|
||||
}
|
||||
|
||||
.internal.boldnshit {
|
||||
color: blue;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* HELPER CLASSES */
|
||||
|
||||
|
||||
.text-normal {
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: 3em;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2020 Aleksej Komarov
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
@use 'sass:meta';
|
||||
@use "sass:math";
|
||||
@use 'sass:color';
|
||||
|
||||
@use '~tgui/styles/colors.scss';
|
||||
@use '~tgui/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/styles/reset.scss');
|
||||
|
||||
// Atomic classes
|
||||
@include meta.load-css('~tgui/styles/atomic/candystripe.scss');
|
||||
@include meta.load-css('~tgui/styles/atomic/color.scss');
|
||||
@include meta.load-css('~tgui/styles/atomic/debug-layout.scss');
|
||||
@include meta.load-css('~tgui/styles/atomic/outline.scss');
|
||||
@include meta.load-css('~tgui/styles/atomic/text.scss');
|
||||
|
||||
// Components
|
||||
@include meta.load-css('~tgui/styles/components/BlockQuote.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Button.scss');
|
||||
@include meta.load-css('~tgui/styles/components/ColorBox.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Dimmer.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Divider.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Dropdown.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Flex.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Input.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Knob.scss');
|
||||
@include meta.load-css('~tgui/styles/components/LabeledList.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Modal.scss');
|
||||
@include meta.load-css('~tgui/styles/components/NanoMap.scss');
|
||||
@include meta.load-css('~tgui/styles/components/NoticeBox.scss');
|
||||
@include meta.load-css('~tgui/styles/components/NumberInput.scss');
|
||||
@include meta.load-css('~tgui/styles/components/ProgressBar.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Section.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Slider.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Stack.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Table.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Tabs.scss');
|
||||
@include meta.load-css('~tgui/styles/components/TextArea.scss');
|
||||
@include meta.load-css('~tgui/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/styles/layouts/Layout.scss');
|
||||
// @include meta.load-css('~tgui/styles/layouts/TitleBar.scss');
|
||||
@include meta.load-css('~tgui/styles/layouts/Window.scss');
|
||||
|
||||
// Goonchat styles
|
||||
@include meta.load-css('./goon/chat-dark.scss');
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2020 Aleksej Komarov
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
@use 'sass:color';
|
||||
@use 'sass:meta';
|
||||
|
||||
@use '~tgui/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/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/styles/atomic/color.scss');
|
||||
|
||||
// Components
|
||||
@include meta.load-css('~tgui/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/styles/components/Section.scss');
|
||||
@include meta.load-css('~tgui/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/styles/components/Input.scss', $with: (
|
||||
'border-color': colors.fg(colors.$label),
|
||||
'background-color': #ffffff,
|
||||
));
|
||||
@include meta.load-css('~tgui/styles/components/NumberInput.scss');
|
||||
@include meta.load-css('~tgui/styles/components/TextArea.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Knob.scss');
|
||||
@include meta.load-css('~tgui/styles/components/Slider.scss');
|
||||
@include meta.load-css('~tgui/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/styles/layouts/Layout.scss', $with: (
|
||||
'scrollbar-color-multiplier': -1,
|
||||
));
|
||||
@include meta.load-css('~tgui/styles/layouts/Window.scss');
|
||||
@include meta.load-css('~tgui/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),
|
||||
));
|
||||
|
||||
// Goonchat styles
|
||||
@include meta.load-css('../goon/chat-light.scss');
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { storage } from 'common/storage';
|
||||
import { createLogger } from 'tgui/logging';
|
||||
|
||||
const logger = createLogger('telemetry');
|
||||
|
||||
const MAX_CONNECTIONS_STORED = 10;
|
||||
|
||||
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;
|
||||
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);
|
||||
};
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* @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',
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -36,10 +36,6 @@ module.exports = (env = {}, argv) => {
|
||||
'./packages/tgui-polyfill',
|
||||
'./packages/tgui',
|
||||
],
|
||||
'tgui-panel': [
|
||||
'./packages/tgui-polyfill',
|
||||
'./packages/tgui-panel',
|
||||
],
|
||||
},
|
||||
output: {
|
||||
path: argv.useTmpFolder
|
||||
|
||||
@@ -9168,19 +9168,6 @@ resolve@^2.0.0-next.3:
|
||||
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.8
|
||||
inferno: ^7.4.11
|
||||
tgui: "workspace:*"
|
||||
tgui-dev-server: "workspace:*"
|
||||
tgui-polyfill: "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"
|
||||
|
||||
Reference in New Issue
Block a user