Rewrites chat settings internals (#94436)

## About The Pull Request
Atomized from a larger PR. There should be no UI behavior changes with
this. It rewrites how settings are handled in tgui-panel internals.
Previously, all UI actions go through TGUI's bespoke redux
implementation. This is all part of the broader system we used to give
us reactive state in inferno. We have the real tools now - we shouldn't
be using this to get by. For the most part, I find
middleware/reducers/actions much more confusing.

Previously:
- user clicks open settings
- this fires a message ('settings/toggle')
- this message is intercepted by the store
- the message is filtered through a sieve of middleware and reducers
- the appropriate action is taken
- rerender

It's a miracle all of this works after having so many cooks in the
kitchen.

Now:
- user clicks open
- it directly flips the settingsVisible state
- rerender

BONUS: This now does runtime type checking, which should fix broken
settings and reduce the need to clear cache
## Why It's Good For The Game
It's done in a React-y way and should be easier to work on. 
Eventually, I want to remove the redux implementation to just work with
the real state tools. This is a huge chunk.
Hopefully more reliable settings panel?
## Changelog

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jeremiah
2025-12-15 21:14:26 -05:00
committed by GitHub
co-authored by Copilot
parent ac678e45db
commit 37dc763ff9
36 changed files with 777 additions and 694 deletions
+15 -10
View File
@@ -23,7 +23,8 @@
"name": "common",
"version": "4.3.1",
"dependencies": {
"es-toolkit": "^1.39.3",
"es-toolkit": "^1.43.0",
"zod": "^4.1.13",
},
},
"packages/tgfont": {
@@ -41,9 +42,9 @@
"common": "workspace:*",
"dateformat": "^5.0.3",
"dompurify": "^3.2.5",
"es-toolkit": "^1.39.3",
"es-toolkit": "^1.43.0",
"highlight.js": "^11.11.1",
"jotai": "^2.12.4",
"jotai": "^2.16.0",
"js-yaml": "^4.1.0",
"marked": "^15.0.11",
"marked-base-url": "^1.1.6",
@@ -51,7 +52,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-json-tree": "^0.20.0",
"tgui-core": "^5.3.1",
"tgui-core": "^5.5.10",
"tgui-dev-server": "workspace:*",
},
},
@@ -70,12 +71,14 @@
"dependencies": {
"common": "workspace:*",
"dompurify": "^3.2.5",
"es-toolkit": "^1.39.3",
"es-toolkit": "^1.43.0",
"jotai": "^2.16.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
"tgui-core": "^5.3.1",
"tgui-core": "^5.5.10",
"tgui-dev-server": "workspace:*",
"zod": "^4.1.13",
},
},
"packages/tgui-say": {
@@ -86,7 +89,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
"tgui-core": "^5.3.1",
"tgui-core": "^5.5.10",
},
},
"packages/tgui-setup": {
@@ -477,7 +480,7 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"es-toolkit": ["es-toolkit@1.39.7", "", {}, "sha512-ek/wWryKouBrZIjkwW2BFf91CWOIMvoy2AE5YYgUrfWsJQM2Su1LoLtrw8uusEpN9RfqLlV/0FVNjT0WMv8Bxw=="],
"es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -647,7 +650,7 @@
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
"jotai": ["jotai@2.12.5", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-G8m32HW3lSmcz/4mbqx0hgJIQ0ekndKWiYP7kWVKi0p6saLXdSoye+FZiOFyonnd7Q482LCzm8sMDl7Ar1NWDw=="],
"jotai": ["jotai@2.16.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-NmkwPBet0SHQ28GBfEb10sqnbVOYyn6DL4iazZgGRDUKxSWL0iqcm+IK4TqTSFC2ixGk+XX2e46Wbv364a3cKg=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -1009,7 +1012,7 @@
"tgui": ["tgui@workspace:packages/tgui"],
"tgui-core": ["tgui-core@5.3.1", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@nozbe/microfuzz": "^1.0.0" }, "peerDependencies": { "react": "^19.1.0", "react-dom": "^19.1.0" } }, "sha512-+8dDM4R2G6T1SpzpkRXGYc7YD+9ohqYjTV/4Zgcqc1I4Ld8bJH9vKHCQVbEAsHTCIjHsTBW4xnOpKyAx1gRB5w=="],
"tgui-core": ["tgui-core@5.5.10", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@nozbe/microfuzz": "^1.0.0" }, "peerDependencies": { "react": "^19.1.0", "react-dom": "^19.1.0" } }, "sha512-/LrZOS9BXSGItzNWzqGpQSo+5BmXQiydbc95+vJGpO/44bW9PqZ2/AnyvP6JCSbBhHT/PQSGVJscJcBnqB+eiA=="],
"tgui-dev-server": ["tgui-dev-server@workspace:packages/tgui-dev-server"],
@@ -1121,6 +1124,8 @@
"yerror": ["yerror@8.0.0", "", {}, "sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g=="],
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
+2 -1
View File
@@ -2,7 +2,8 @@
"name": "common",
"version": "4.3.1",
"dependencies": {
"es-toolkit": "^1.39.3"
"es-toolkit": "^1.43.0",
"zod": "^4.1.13"
},
"private": true
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, it } from 'bun:test';
import assert from 'node:assert/strict';
import * as z from 'zod';
import { smoothMerge } from './type-safety';
describe('smoothMerge', () => {
it('merges valid fields from source into target', () => {
const schema = z.object({
a: z.string(),
b: z.number(),
});
const source = { a: 'hello', b: 'not a number', c: true };
const target = { a: 'default', b: 42 };
const result = smoothMerge({ schema, source, target });
assert.deepEqual(result, { a: 'hello', b: 42 });
});
it('returns target if source is empty', () => {
const schema = z.object({
a: z.string(),
});
const source = {};
const target = { a: 'default' };
const result = smoothMerge({ schema, source, target });
assert.deepEqual(result, target);
});
it('completely ignores an object if its not in the schema', () => {
const schema = z.object({
a: z.string(),
b: z.number(),
});
const source = {
c: 1,
d: [1, 2, 3],
};
const target = {
a: 'default',
b: 42,
};
const result = smoothMerge({ schema, source, target });
assert.deepEqual(result, {
a: 'default',
b: 42,
});
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { ZodObject } from 'zod';
type MergeInput<TObj> = {
/**
* A zod object.
* @see Writing a Zod Schema: https://zod.dev/basics
*/
schema: ZodObject<any>;
/** The input getting merged */
source: Record<string, unknown>;
/** The defaults, which is the shape of the output */
target: TObj;
};
/**
* Merges two objects together while validating the output against a zod schema.
* Different than just parsing - it does not throw errors, it simply discards
* invalid fields and invalid value types.
*
* @example
*
* ```ts
* const schema = z.object({
* a: z.string(),
* b: z.number(),
* });
*
* const source = { a: 'hello', b: 'not a number', c: true };
* const target = { a: 'default', b: 42 };
*
* const result = smoothMerge({ schema, source, target });
* // result is { a: 'hello', b: 42 }
* ```
*/
export function smoothMerge<TObj extends Record<string, unknown>>(
input: MergeInput<TObj>,
): TObj {
if (Object.keys(input.source).length === 0) return input.target;
const validated = {};
for (const [key, value] of Object.entries(input.source)) {
// Skip keys that are not in the schema
if (!(key in input.schema.shape)) continue;
const fieldSchema = input.schema.shape[key];
const result = fieldSchema.safeParse(value);
// Only assign fields which pass validation
if (result.success) {
validated[key] = result.data;
}
}
return { ...input.target, ...validated };
}
+11 -17
View File
@@ -4,28 +4,24 @@
* @license MIT
*/
import { useAtom } from 'jotai';
import { Pane } from 'tgui/layouts';
import { Button, Section, Stack } from 'tgui-core/components';
import { NowPlayingWidget, useAudio } from './audio';
import { ChatPanel, ChatTabs } from './chat';
import { useGame } from './game';
import { Notifications } from './Notifications';
import { PingIndicator } from './ping';
import { ReconnectButton } from './reconnect';
import { SettingsPanel, useSettings } from './settings';
import { settingsVisibleAtom } from './settings/atoms';
import { SettingsPanel } from './settings/SettingsPanel';
import { useSettings } from './settings/use-settings';
export const Panel = (props) => {
const audio = useAudio();
const settings = useSettings();
const { settings } = useSettings();
const game = useGame();
if (process.env.NODE_ENV !== 'production') {
const { useDebug, KitchenSink } = require('tgui/debug');
const debug = useDebug();
if (debug.kitchenSink) {
return <KitchenSink panel />;
}
}
const [settingsVisible, setSettingsVisible] = useAtom(settingsVisibleAtom);
return (
<Pane theme={settings.theme}>
@@ -51,13 +47,11 @@ export const Panel = (props) => {
</Stack.Item>
<Stack.Item>
<Button
icon={settings.visible ? 'times' : 'cog'}
selected={settings.visible}
tooltip={
settings.visible ? 'Close settings' : 'Open settings'
}
icon={settingsVisible ? 'times' : 'cog'}
selected={settingsVisible}
tooltip={settingsVisible ? 'Close settings' : 'Open settings'}
tooltipPosition="bottom-start"
onClick={() => settings.toggle()}
onClick={() => setSettingsVisible(!settingsVisible)}
/>
</Stack.Item>
</Stack>
@@ -70,7 +64,7 @@ export const Panel = (props) => {
</Section>
</Stack.Item>
)}
{settings.visible && (
{settingsVisible && (
<Stack.Item>
<SettingsPanel />
</Stack.Item>
+20
View File
@@ -0,0 +1,20 @@
import { Provider } from 'jotai';
import { store } from './events/store';
import { Panel } from './Panel';
/** Just an expandable wrapper for setup shenanigans and providers */
export function App() {
if (process.env.NODE_ENV !== 'production') {
const { useDebug, KitchenSink } = require('tgui/debug');
const debug = useDebug();
if (debug.kitchenSink) {
return <KitchenSink panel />;
}
}
return (
<Provider store={store}>
<Panel />
</Provider>
);
}
@@ -7,14 +7,13 @@
import { useDispatch, useSelector } from 'tgui/backend';
import { Button, Collapsible, Flex, Knob, Section } from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { useSettings } from '../settings';
import { useSettings } from '../settings/use-settings';
import { selectAudio } from './selectors';
export const NowPlayingWidget = (props) => {
const { settings, updateSettings } = useSettings();
const audio = useSelector(selectAudio),
dispatch = useDispatch(),
settings = useSettings(),
title = audio.meta?.title,
URL = audio.meta?.link,
Artist = audio.meta?.artist || 'Unknown Artist',
@@ -100,7 +99,7 @@ export const NowPlayingWidget = (props) => {
stepPixelSize={1}
format={(value) => `${toFixed(value * 100)}%`}
onChange={(e, value) =>
settings.update({
updateSettings({
adminMusicVolume: value,
})
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { chatRenderer } from './renderer';
type Props = {
fontSize?: string;
lineHeight: string;
lineHeight: string | number;
};
type State = {
+5 -3
View File
@@ -4,10 +4,10 @@
* @license MIT
*/
import { useAtom } from 'jotai';
import { useDispatch, useSelector } from 'tgui/backend';
import { Box, Button, Stack, Tabs } from 'tgui-core/components';
import { openChatSettings } from '../settings/actions';
import { settingsVisibleAtom } from '../settings/atoms';
import { addChatPage, changeChatPage } from './actions';
import { selectChatPages, selectCurrentChatPage } from './selectors';
@@ -20,6 +20,8 @@ export function ChatTabs(props) {
const currentPage = useSelector(selectCurrentChatPage);
const dispatch = useDispatch();
const [, setSettingsVisible] = useAtom(settingsVisibleAtom);
return (
<Stack align="center">
<Stack.Item>
@@ -50,7 +52,7 @@ export function ChatTabs(props) {
icon="plus"
onClick={() => {
dispatch(addChatPage());
dispatch(openChatSettings());
setSettingsVisible(true);
}}
/>
</Stack.Item>
+3 -28
View File
@@ -7,16 +7,8 @@
import type { Store } from 'common/redux';
import { storage } from 'common/storage';
import DOMPurify from 'dompurify';
import {
addHighlightSetting,
importSettings,
loadSettings,
removeHighlightSetting,
updateHighlightSetting,
updateSettings,
} from '../settings/actions';
import { selectSettings } from '../settings/selectors';
import { store as jotaiStore } from '../events/store';
import { settingsAtom } from '../settings/atoms';
import {
addChatPage,
changeChatPage,
@@ -101,7 +93,7 @@ export const chatMiddleware = (store: Store) => {
});
return (next) => (action) => {
const { type, payload } = action;
const settings = selectSettings(store.getState());
const settings = jotaiStore.get(settingsAtom);
// Load the chat once settings are loaded
if (!initialized && settings.initialized) {
setInterval(() => {
@@ -174,23 +166,6 @@ export const chatMiddleware = (store: Store) => {
return next(action);
}
if (
type === updateSettings.type ||
type === loadSettings.type ||
type === addHighlightSetting.type ||
type === removeHighlightSetting.type ||
type === updateHighlightSetting.type ||
type === importSettings.type
) {
next(action);
const nextSettings = selectSettings(store.getState());
chatRenderer.setHighlight(
nextSettings.highlightSettings,
nextSettings.highlightSettingById,
);
return;
}
if (type === 'roundrestart') {
// Save chat as soon as possible
saveChatToStorage(store);
+4
View File
@@ -1,3 +1,7 @@
export type ChatPages = {
chatPages: Record<string, Page>;
};
export type Page = {
isMain: boolean;
id: string;
+3
View File
@@ -0,0 +1,3 @@
import { createStore } from 'jotai';
export const store = createStore();
+3 -8
View File
@@ -16,14 +16,12 @@ import { render } from 'tgui/renderer';
import { configureStore } from 'tgui/store';
import { setupGlobalEvents } from 'tgui-core/events';
import { setupHotReloading } from 'tgui-dev-server/link/client';
import { App } from './app';
import { audioMiddleware, audioReducer } from './audio';
import { chatMiddleware, chatReducer } from './chat';
import { gameMiddleware, gameReducer } from './game';
import { Panel } from './Panel';
import { setupPanelFocusHacks } from './panelFocus';
import { pingMiddleware, pingReducer } from './ping';
import { settingsMiddleware, settingsReducer } from './settings';
import { telemetryMiddleware } from './telemetry';
perf.mark('inception', window.performance?.timeOrigin);
@@ -35,14 +33,12 @@ const store = configureStore({
chat: chatReducer,
game: gameReducer,
ping: pingReducer,
settings: settingsReducer,
}),
middleware: {
pre: [
chatMiddleware,
pingMiddleware,
telemetryMiddleware,
settingsMiddleware,
audioMiddleware,
gameMiddleware,
],
@@ -66,7 +62,7 @@ function setupApp() {
captureExternalLinks();
// Re-render UI on store updates
store.subscribe(() => render(<Panel />));
store.subscribe(() => render(<App />));
// Dispatch incoming messages as store actions
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
@@ -95,11 +91,10 @@ function setupApp() {
'./Notifications',
'./Panel',
'./ping',
'./settings',
'./telemetry',
],
() => {
render(<Panel />);
render(<App />);
},
);
}
+5 -3
View File
@@ -4,12 +4,14 @@
"dependencies": {
"common": "workspace:*",
"dompurify": "^3.2.5",
"es-toolkit": "^1.39.3",
"es-toolkit": "^1.43.0",
"jotai": "^2.16.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
"tgui-core": "^5.3.1",
"tgui-dev-server": "workspace:*"
"tgui-core": "^5.5.10",
"tgui-dev-server": "workspace:*",
"zod": "^4.1.13"
},
"private": true
}
@@ -12,20 +12,18 @@ import {
} from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { capitalize } from 'tgui-core/string';
import { clearChat, saveChatToDisk } from '../chat/actions';
import { THEMES } from '../themes';
import { exportSettings, updateSettings } from './actions';
import { FONTS } from './constants';
import { selectChatPages } from '../chat/selectors';
import { FONTS, THEMES } from './constants';
import { resetPaneSplitters, setEditPaneSplitters } from './scaling';
import { selectSettings } from './selectors';
import { importChatSettings } from './settingsImExport';
import { exportChatSettings, importChatSettings } from './settingsImExport';
import { useSettings } from './use-settings';
export function SettingsGeneral(props) {
const { theme, fontFamily, fontSize, lineHeight } =
useSelector(selectSettings);
const dispatch = useDispatch();
const { settings, updateSettings } = useSettings();
const [freeFont, setFreeFont] = useState(false);
const dispatch = useDispatch();
const chat = useSelector(selectChatPages);
const [editingPanes, setEditingPanes] = useState(false);
@@ -36,14 +34,12 @@ export function SettingsGeneral(props) {
{THEMES.map((THEME) => (
<Button
key={THEME}
selected={theme === THEME}
selected={settings.theme === THEME}
color="transparent"
onClick={() =>
dispatch(
updateSettings({
theme: THEME,
}),
)
updateSettings({
theme: THEME,
})
}
>
{capitalize(THEME)}
@@ -77,8 +73,8 @@ export function SettingsGeneral(props) {
<Stack.Item>
{!freeFont ? (
<Collapsible
title={fontFamily}
width={'100%'}
title={settings.fontFamily}
width="100%"
buttons={
<Button
icon={freeFont ? 'lock-open' : 'lock'}
@@ -95,14 +91,12 @@ export function SettingsGeneral(props) {
<Button
key={FONT}
fontFamily={FONT}
selected={fontFamily === FONT}
selected={settings.fontFamily === FONT}
color="transparent"
onClick={() =>
dispatch(
updateSettings({
fontFamily: FONT,
}),
)
updateSettings({
fontFamily: FONT,
})
}
>
{FONT}
@@ -113,13 +107,11 @@ export function SettingsGeneral(props) {
<Stack>
<Input
fluid
value={fontFamily}
value={settings.fontFamily}
onBlur={(value) =>
dispatch(
updateSettings({
fontFamily: value,
}),
)
updateSettings({
fontFamily: value,
})
}
/>
<Button
@@ -145,12 +137,10 @@ export function SettingsGeneral(props) {
stepPixelSize={20}
minValue={8}
maxValue={32}
value={fontSize}
value={settings.fontSize}
unit="px"
format={(value) => toFixed(value)}
onChange={(e, value) =>
dispatch(updateSettings({ fontSize: value }))
}
onChange={(e, value) => updateSettings({ fontSize: value })}
/>
</Stack.Item>
</Stack>
@@ -161,14 +151,12 @@ export function SettingsGeneral(props) {
step={0.01}
minValue={0.8}
maxValue={5}
value={lineHeight}
value={settings.lineHeight}
format={(value) => toFixed(value, 2)}
onChange={(e, value) =>
dispatch(
updateSettings({
lineHeight: value,
}),
)
updateSettings({
lineHeight: value,
})
}
/>
</LabeledList.Item>
@@ -179,7 +167,7 @@ export function SettingsGeneral(props) {
<Button
icon="compact-disc"
tooltip="Export chat settings"
onClick={() => dispatch(exportSettings())}
onClick={() => exportChatSettings(chat)}
>
Export settings
</Button>
@@ -189,7 +177,7 @@ export function SettingsGeneral(props) {
accept=".json"
tooltip="Import chat settings"
icon="arrow-up-from-bracket"
onSelectFiles={(files) => importChatSettings(files)}
onSelectFiles={importChatSettings}
>
Import settings
</Button.File>
@@ -4,20 +4,20 @@
* @license MIT
*/
import { useDispatch, useSelector } from 'tgui/backend';
import { Section, Stack, Tabs } from 'tgui-core/components';
import { ChatPageSettings } from '../chat';
import { changeSettingsTab } from './actions';
import { SETTINGS_TABS } from './constants';
import { selectActiveTab } from './selectors';
import { SettingsGeneral } from './SettingsGeneral';
import { SettingsStatPanel } from './SettingsStatPanel';
import { TextHighlightSettings } from './TextHighlight';
import { useSettings } from './use-settings';
export function SettingsPanel(props) {
const activeTab = useSelector(selectActiveTab);
const dispatch = useDispatch();
const {
settings: { view },
updateSettings,
} = useSettings();
const { activeTab } = view;
return (
<Stack fill>
@@ -29,11 +29,12 @@ export function SettingsPanel(props) {
key={tab.id}
selected={tab.id === activeTab}
onClick={() =>
dispatch(
changeSettingsTab({
tabId: tab.id,
}),
)
updateSettings({
view: {
...view,
activeTab: tab.id,
},
})
}
>
{tab.name}
@@ -1,4 +1,3 @@
import { useDispatch, useSelector } from 'tgui/backend';
import {
Button,
LabeledList,
@@ -9,19 +8,17 @@ import {
} from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { capitalize } from 'tgui-core/string';
import { useSettings } from './use-settings';
import { updateSettings } from './actions';
import { selectSettings } from './selectors';
const tabViews = ['default', 'classic', 'scrollable'];
const TabsViews = ['default', 'classic', 'scrollable'];
const LinkedToChat = () => (
<NoticeBox color="red">Unlink Stat Panel from chat!</NoticeBox>
);
function LinkedToChat() {
return <NoticeBox color="red">Unlink Stat Panel from chat!</NoticeBox>;
}
export function SettingsStatPanel(props) {
const { statLinked, statFontSize, statTabsStyle } =
useSelector(selectSettings);
const dispatch = useDispatch();
const { settings, updateSettings } = useSettings();
const { statLinked, statFontSize, statTabsStyle } = settings;
return (
<Section fill>
@@ -29,14 +26,12 @@ export function SettingsStatPanel(props) {
<Stack.Item>
<LabeledList>
<LabeledList.Item label="Tabs" verticalAlign="middle">
{TabsViews.map((view) => (
{tabViews.map((view) => (
<Button
key={view}
color="transparent"
selected={statTabsStyle === view}
onClick={() =>
dispatch(updateSettings({ statTabsStyle: view }))
}
onClick={() => updateSettings({ statTabsStyle: view })}
>
{capitalize(view)}
</Button>
@@ -57,7 +52,7 @@ export function SettingsStatPanel(props) {
unit="px"
format={(value) => toFixed(value)}
onChange={(e, value) =>
dispatch(updateSettings({ statFontSize: value }))
updateSettings({ statFontSize: value })
}
/>
)}
@@ -71,9 +66,7 @@ export function SettingsStatPanel(props) {
fluid
icon={statLinked ? 'unlink' : 'link'}
color={statLinked ? 'bad' : 'good'}
onClick={() =>
dispatch(updateSettings({ statLinked: !statLinked }))
}
onClick={() => updateSettings({ statLinked: !statLinked })}
>
{statLinked ? 'Unlink from chat' : 'Link to chat'}
</Button>
@@ -1,4 +1,4 @@
import { useDispatch, useSelector } from 'tgui/backend';
import { useDispatch } from 'tgui/backend';
import {
Box,
Button,
@@ -10,21 +10,15 @@ import {
Stack,
TextArea,
} from 'tgui-core/components';
import { rebuildChat } from '../chat/actions';
import {
addHighlightSetting,
removeHighlightSetting,
updateHighlightSetting,
} from './actions';
import { WARN_AFTER_HIGHLIGHT_AMT } from './constants';
import {
selectHighlightSettingById,
selectHighlightSettings,
} from './selectors';
import { useHighlights } from './use-highlights';
export function TextHighlightSettings(props) {
const highlightSettings = useSelector(selectHighlightSettings);
const {
highlights: { highlightSettings },
addHighlight,
} = useHighlights();
const dispatch = useDispatch();
return (
@@ -42,9 +36,7 @@ export function TextHighlightSettings(props) {
<Button
color="transparent"
icon="plus"
onClick={() => {
dispatch(addHighlightSetting());
}}
onClick={() => addHighlight()}
>
Add Highlight Setting
</Button>
@@ -73,8 +65,11 @@ export function TextHighlightSettings(props) {
function TextHighlightSetting(props) {
const { id, ...rest } = props;
const highlightSettingById = useSelector(selectHighlightSettingById);
const dispatch = useDispatch();
const {
highlights: { highlightSettingById },
updateHighlight,
removeHighlight,
} = useHighlights();
const {
highlightColor,
highlightText,
@@ -90,13 +85,7 @@ function TextHighlightSetting(props) {
<Button
color="transparent"
icon="times"
onClick={() =>
dispatch(
removeHighlightSetting({
id: id,
}),
)
}
onClick={() => removeHighlight(id)}
>
Delete
</Button>
@@ -106,12 +95,10 @@ function TextHighlightSetting(props) {
checked={highlightWholeMessage}
tooltip="If this option is selected, the entire message will be highlighted in yellow."
onClick={() =>
dispatch(
updateHighlightSetting({
id: id,
highlightWholeMessage: !highlightWholeMessage,
}),
)
updateHighlight({
id,
highlightWholeMessage: !highlightWholeMessage,
})
}
>
Whole Message
@@ -123,12 +110,10 @@ function TextHighlightSetting(props) {
tooltipPosition="bottom-start"
tooltip="If this option is selected, only exact matches (no extra letters before or after) will trigger. Not compatible with punctuation. Overriden if regex is used."
onClick={() =>
dispatch(
updateHighlightSetting({
id: id,
matchWord: !matchWord,
}),
)
updateHighlight({
id,
matchWord: !matchWord,
})
}
>
Exact
@@ -139,12 +124,10 @@ function TextHighlightSetting(props) {
tooltip="If this option is selected, the highlight will be case-sensitive."
checked={matchCase}
onClick={() =>
dispatch(
updateHighlightSetting({
id: id,
matchCase: !matchCase,
}),
)
updateHighlight({
id,
matchCase: !matchCase,
})
}
>
Case
@@ -158,12 +141,10 @@ function TextHighlightSetting(props) {
placeholder="#ffffff"
value={highlightColor}
onBlur={(value) =>
dispatch(
updateHighlightSetting({
id: id,
highlightColor: value,
}),
)
updateHighlight({
id,
highlightColor: value,
})
}
/>
</Stack.Item>
@@ -174,12 +155,10 @@ function TextHighlightSetting(props) {
value={highlightText}
placeholder="Put words to highlight here. Separate terms with commas, i.e. (term1, term2, term3)"
onBlur={(value) =>
dispatch(
updateHighlightSetting({
id: id,
highlightText: value,
}),
)
updateHighlight({
id: id,
highlightText: value,
})
}
/>
</Stack.Item>
@@ -6,26 +6,6 @@
import { createAction } from 'common/redux';
import { createHighlightSetting } from './model';
export const updateSettings = createAction('settings/update');
export const loadSettings = createAction('settings/load');
export const changeSettingsTab = createAction('settings/changeTab');
export const toggleSettings = createAction('settings/toggle');
export const openChatSettings = createAction('settings/openChatTab');
export const addHighlightSetting = createAction(
'settings/addHighlightSetting',
() => ({
payload: createHighlightSetting(),
}),
);
export const removeHighlightSetting = createAction(
'settings/removeHighlightSetting',
);
export const updateHighlightSetting = createAction(
'settings/updateHighlightSetting',
);
export const exportSettings = createAction('settings/export');
export const importSettings = createAction(
'settings/import',
(settings, pages) => ({
@@ -0,0 +1,55 @@
import { atom } from 'jotai';
import { FONTS, SETTINGS_TABS } from './constants';
import type { HighlightSetting, HighlightState, SettingsState } from './types';
export const defaultSettings: SettingsState = {
adminMusicVolume: 0.5,
fontFamily: FONTS[0],
fontSize: 13,
initialized: false,
lineHeight: 1.2,
statFontSize: 12,
statLinked: true,
statTabsStyle: 'default',
theme: 'light',
version: 1,
view: {
visible: false,
activeTab: SETTINGS_TABS[0].id,
},
};
export const defaultHighlightSetting: HighlightSetting = {
id: 'default',
highlightText: '',
highlightColor: '#ffdd44',
highlightWholeMessage: true,
matchWord: false,
matchCase: false,
};
export const defaultHighlights: HighlightState = {
highlightSettings: ['default'],
highlightSettingById: {
default: defaultHighlightSetting,
},
// Keep these two state vars for compatibility with other servers
highlightText: '',
highlightColor: '#ffdd44',
// END compatibility state vars
};
/**
* Separate from 'initialized' in settings. This is to keep chat from loading
* settings prior to settings being ready
*/
export const settingsLoadedAtom = atom(false);
export const settingsAtom = atom(defaultSettings);
export const settingsVisibleAtom = atom(false);
export const highlightsAtom = atom(defaultHighlights);
export const storedSettingsAtom = atom((get) => ({
...get(settingsAtom),
...get(highlightsAtom),
}));
+19 -2
View File
@@ -4,6 +4,23 @@
* @license MIT
*/
export const THEMES = ['light', 'dark'] as const;
export const COLORS = {
DARK: {
BG_BASE: '#202020',
BG_SECOND: '#151515',
BUTTON: '#404040',
TEXT: '#A6A6A6',
},
LIGHT: {
BG_BASE: '#EEEEEE',
BG_SECOND: '#FFFFFF',
BUTTON: '#FFFFFF',
TEXT: '#000000',
},
} as const;
export const SETTINGS_TABS = [
{
id: 'general',
@@ -22,7 +39,7 @@ export const SETTINGS_TABS = [
id: 'statPanel',
name: 'Stat Panel',
},
];
] as const;
export const FONTS_DISABLED = 'Default';
@@ -38,6 +55,6 @@ export const FONTS = [
'Trebuchet MS',
'Courier New',
'Lucida Console',
];
] as const;
export const WARN_AFTER_HIGHLIGHT_AMT = 10;
@@ -0,0 +1,85 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { FONTS_DISABLED } from './constants';
import { setClientTheme } from './themes';
import type { SettingsState } from './types';
let statFontTimer: NodeJS.Timeout;
let statTabsTimer: NodeJS.Timeout;
let overrideRule: HTMLStyleElement;
let overrideFontFamily: string | undefined;
let overrideFontSize: string;
/** Updates the global CSS rule to override the font family and size. */
function updateGlobalOverrideRule(): void {
let fontFamily = '';
if (overrideFontFamily !== undefined) {
fontFamily = `font-family: ${overrideFontFamily} !important;`;
}
const constructedRule = `body * :not(.Icon) {
${fontFamily}
}`;
if (overrideRule === undefined) {
overrideRule = document.createElement('style');
document.querySelector('head')!.append(overrideRule);
}
// no other way to force a CSS refresh other than to update its innerText
overrideRule.innerText = constructedRule;
document.body.style.setProperty('font-size', overrideFontSize);
}
function setGlobalFontSize(
fontSize: string | number,
statFontSize: string | number,
statLinked: boolean,
): void {
overrideFontSize = `${fontSize}px`;
// Used solution from theme.ts
clearInterval(statFontTimer);
Byond.command(
`.output statbrowser:set_font_size ${statLinked ? fontSize : statFontSize}px`,
);
statFontTimer = setTimeout(() => {
Byond.command(
`.output statbrowser:set_font_size ${statLinked ? fontSize : statFontSize}px`,
);
}, 1500);
}
function setGlobalFontFamily(fontFamily: string): void {
overrideFontFamily = fontFamily === FONTS_DISABLED ? undefined : fontFamily;
}
function setStatTabsStyle(style: string): void {
clearInterval(statTabsTimer);
Byond.command(`.output statbrowser:set_tabs_style ${style}`);
statTabsTimer = setTimeout(() => {
Byond.command(`.output statbrowser:set_tabs_style ${style}`);
}, 1500);
}
export function generalSettingsHandler(update: SettingsState): void {
// Set client theme
const theme = update?.theme;
if (theme) {
setClientTheme(theme);
}
// Update stat panel settings
setStatTabsStyle(update.statTabsStyle);
// Update global UI font size
setGlobalFontSize(update.fontSize, update.statFontSize, update.statLinked);
setGlobalFontFamily(update.fontFamily);
updateGlobalOverrideRule();
}
@@ -1,21 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { useDispatch, useSelector } from 'tgui/backend';
import { toggleSettings, updateSettings } from './actions';
import { selectSettings } from './selectors';
export const useSettings = () => {
const settings = useSelector(selectSettings);
const dispatch = useDispatch();
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,148 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { storage } from 'common/storage';
import { setClientTheme } from '../themes';
import {
addHighlightSetting,
exportSettings,
importSettings,
loadSettings,
removeHighlightSetting,
updateHighlightSetting,
updateSettings,
} from './actions';
import { FONTS_DISABLED } from './constants';
import { setDisplayScaling } from './scaling';
import { selectSettings } from './selectors';
import { exportChatSettings } from './settingsImExport';
let statFontTimer: NodeJS.Timeout;
let statTabsTimer: NodeJS.Timeout;
let overrideRule: HTMLStyleElement;
let overrideFontFamily: string | undefined;
let overrideFontSize: string;
/** Updates the global CSS rule to override the font family and size. */
function updateGlobalOverrideRule() {
let fontFamily = '';
if (overrideFontFamily !== undefined) {
fontFamily = `font-family: ${overrideFontFamily} !important;`;
}
const constructedRule = `body * :not(.Icon) {
${fontFamily}
}`;
if (overrideRule === undefined) {
overrideRule = document.createElement('style');
document.querySelector('head')!.append(overrideRule);
}
// no other way to force a CSS refresh other than to update its innerText
overrideRule.innerText = constructedRule;
document.body.style.setProperty('font-size', overrideFontSize);
}
function setGlobalFontSize(
fontSize: string,
statFontSize: string,
statLinked: boolean,
) {
overrideFontSize = `${fontSize}px`;
// Used solution from theme.ts
clearInterval(statFontTimer);
Byond.command(
`.output statbrowser:set_font_size ${statLinked ? fontSize : statFontSize}px`,
);
statFontTimer = setTimeout(() => {
Byond.command(
`.output statbrowser:set_font_size ${statLinked ? fontSize : statFontSize}px`,
);
}, 1500);
}
function setGlobalFontFamily(fontFamily: string) {
overrideFontFamily = fontFamily === FONTS_DISABLED ? undefined : fontFamily;
}
function setStatTabsStyle(style: string) {
clearInterval(statTabsTimer);
Byond.command(`.output statbrowser:set_tabs_style ${style}`);
statTabsTimer = setTimeout(() => {
Byond.command(`.output statbrowser:set_tabs_style ${style}`);
}, 1500);
}
export function settingsMiddleware(store) {
let initialized = false;
return (next) => (action) => {
const { type, payload } = action;
if (!initialized) {
initialized = true;
setDisplayScaling();
storage.get('panel-settings').then((settings) => {
store.dispatch(loadSettings(settings));
});
}
if (type === exportSettings.type) {
const state = store.getState();
const settings = selectSettings(state);
exportChatSettings(settings, state.chat.pageById);
return;
}
if (
type !== updateSettings.type &&
type !== loadSettings.type &&
type !== addHighlightSetting.type &&
type !== removeHighlightSetting.type &&
type !== updateHighlightSetting.type &&
type !== importSettings.type
) {
return next(action);
}
// 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());
if (importSettings.type) {
setClientTheme(settings.theme);
}
// Update stat panel settings
setStatTabsStyle(settings.statTabsStyle);
// Update global UI font size
setGlobalFontSize(
settings.fontSize,
settings.statFontSize,
settings.statLinked,
);
setGlobalFontFamily(settings.fontFamily);
updateGlobalOverrideRule();
// Save settings to the web storage
storage.set('panel-settings', settings);
return;
};
}
@@ -0,0 +1,115 @@
import { storage } from 'common/storage';
import { smoothMerge } from 'common/type-safety';
import { omit, pick } from 'es-toolkit';
import { chatRenderer } from '../chat/renderer';
import { store } from '../events/store';
import {
defaultHighlightSetting,
type defaultHighlights,
defaultSettings,
highlightsAtom,
settingsAtom,
} from './atoms';
import { generalSettingsHandler } from './helpers';
import {
type HighlightState,
type MergedSettings,
type SettingsState,
settingsSchema,
} from './types';
/** Fixes issues with stored highlight settings */
function migrateHighlights(next: HighlightState): HighlightState {
const draft: HighlightState = { ...next };
// Lazy init the list for compatibility reasons
if (!draft.highlightSettings) {
draft.highlightSettings = [defaultHighlightSetting.id];
}
if (!draft.highlightSettingById) {
draft.highlightSettingById = {
[defaultHighlightSetting.id]: defaultHighlightSetting,
};
}
// Compensating for mishandling of default highlight settings
if (!draft.highlightSettingById[defaultHighlightSetting.id]) {
draft.highlightSettings = [
defaultHighlightSetting.id,
...draft.highlightSettings,
];
draft.highlightSettingById[defaultHighlightSetting.id] =
defaultHighlightSetting;
}
// Update the highlight settings for default highlight
// settings compatibility — don't overwrite existing values
const defaultHighlight =
draft.highlightSettingById[defaultHighlightSetting.id];
if (!defaultHighlight.highlightColor) {
defaultHighlight.highlightColor =
draft.highlightColor ?? defaultHighlightSetting.highlightColor;
}
if (!defaultHighlight.highlightText) {
defaultHighlight.highlightText =
draft.highlightText ?? defaultHighlightSetting.highlightText;
}
return draft;
}
const highlightKeys: (keyof typeof defaultHighlights)[] = [
'highlightSettings',
'highlightSettingById',
'highlightText',
'highlightColor',
] as const;
/** A bit of a chunky procedural function. Handles imported and loaded settings */
export function startSettingsMigration(next: MergedSettings): void {
// No stored settings found, initialize with defaults
if (!next) {
console.log('Initializing panel settings with defaults.');
const initialized: SettingsState = {
...defaultSettings,
initialized: true,
};
storage.set('panel-settings', initialized);
store.set(settingsAtom, initialized);
return;
}
// Split the merged object as we save in two different atoms
const settingsPart = omit(next, highlightKeys);
const highlightPart = pick(next, highlightKeys);
const draftSettings = smoothMerge({
source: settingsPart,
target: defaultSettings,
schema: settingsSchema,
});
draftSettings.initialized = true;
draftSettings.view = defaultSettings.view; // Preserve view state
generalSettingsHandler(draftSettings);
store.set(settingsAtom, draftSettings);
console.log('Migrated panel settings:', draftSettings);
const migratedHighlights = migrateHighlights(highlightPart);
// Just exit if no valid version was found
if (!next.version) {
storage.set('panel-settings', { ...draftSettings, ...migratedHighlights });
return;
}
chatRenderer.setHighlight(
migratedHighlights.highlightSettings,
migratedHighlights.highlightSettingById,
);
store.set(highlightsAtom, migratedHighlights);
console.log('Migrated panel highlight settings:', migratedHighlights);
}
@@ -1,20 +0,0 @@
/**
* @file
*/
import { createUuid } from 'tgui-core/uuid';
export const createHighlightSetting = (obj?: Record<string, any>) => ({
id: createUuid(),
highlightText: '',
highlightColor: '#ffdd44',
highlightWholeMessage: true,
matchWord: false,
matchCase: false,
...obj,
});
export const createDefaultHighlightSetting = (obj?: Record<string, any>) =>
createHighlightSetting({
id: 'default',
...obj,
});
@@ -1,227 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import {
addHighlightSetting,
changeSettingsTab,
importSettings,
loadSettings,
openChatSettings,
removeHighlightSetting,
toggleSettings,
updateHighlightSetting,
updateSettings,
} from './actions';
import { FONTS, SETTINGS_TABS } from './constants';
import { createDefaultHighlightSetting } from './model';
const defaultHighlightSetting = createDefaultHighlightSetting();
const initialState = {
version: 1,
fontSize: 13,
fontFamily: FONTS[0],
lineHeight: 1.2,
theme: 'light',
adminMusicVolume: 0.5,
// Keep these two state vars for compatibility with other servers
highlightText: '',
highlightColor: '#ffdd44',
// END compatibility state vars
highlightSettings: [defaultHighlightSetting.id],
highlightSettingById: {
[defaultHighlightSetting.id]: defaultHighlightSetting,
},
view: {
visible: false,
activeTab: SETTINGS_TABS[0].id,
},
initialized: false,
statLinked: true,
statFontSize: 12,
statTabsStyle: 'default',
} as const;
export function settingsReducer(
state = initialState,
action: { type: string; payload: any },
) {
const { type, payload } = action;
switch (type) {
case updateSettings.type:
return {
...state,
...payload,
};
case loadSettings.type: {
{
// Validate version and/or migrate state
if (!payload?.version) {
const nextState = {
...state,
...payload,
};
nextState.initialized = true;
return nextState;
}
delete payload.view;
const nextState = {
...state,
...payload,
};
nextState.initialized = true;
// Lazy init the list for compatibility reasons
if (!nextState.highlightSettings) {
nextState.highlightSettings = [defaultHighlightSetting.id];
nextState.highlightSettingById[defaultHighlightSetting.id] =
defaultHighlightSetting;
}
// Compensating for mishandling of default highlight settings
else if (!nextState.highlightSettingById[defaultHighlightSetting.id]) {
nextState.highlightSettings = [
defaultHighlightSetting.id,
...nextState.highlightSettings,
];
nextState.highlightSettingById[defaultHighlightSetting.id] =
defaultHighlightSetting;
}
// Update the highlight settings for default highlight
// settings compatibility
const highlightSetting =
nextState.highlightSettingById[defaultHighlightSetting.id];
highlightSetting.highlightColor = nextState.highlightColor;
highlightSetting.highlightText = nextState.highlightText;
return nextState;
}
}
case importSettings.type: {
const newSettings = payload.newSettings;
if (!newSettings) {
return state;
}
const nextState = {
...state,
...newSettings,
};
return nextState;
}
case toggleSettings.type: {
return {
...state,
view: {
...state.view,
visible: !state.view.visible,
},
};
}
case openChatSettings.type: {
return {
...state,
view: {
...state.view,
visible: true,
activeTab: 'chatPage',
},
};
}
case changeSettingsTab.type: {
const { tabId } = payload;
return {
...state,
view: {
...state.view,
activeTab: tabId,
},
};
}
case addHighlightSetting.type: {
const highlightSetting = payload;
return {
...state,
highlightSettings: [...state.highlightSettings, highlightSetting.id],
highlightSettingById: {
...state.highlightSettingById,
[highlightSetting.id]: highlightSetting,
},
};
}
case removeHighlightSetting.type: {
const { id } = payload;
const nextState = {
...state,
highlightSettings: [...state.highlightSettings],
highlightSettingById: {
...state.highlightSettingById,
},
};
if (id === defaultHighlightSetting.id) {
nextState.highlightSettings[defaultHighlightSetting.id] =
defaultHighlightSetting;
} else {
delete nextState.highlightSettingById[id];
nextState.highlightSettings = nextState.highlightSettings.filter(
(sid) => sid !== id,
);
if (!nextState.highlightSettings.length) {
nextState.highlightSettings.push(defaultHighlightSetting.id);
nextState.highlightSettingById[defaultHighlightSetting.id] =
defaultHighlightSetting;
}
}
return nextState;
}
case updateHighlightSetting.type: {
const { id, ...settings } = payload;
const nextState = {
...state,
highlightSettings: [...state.highlightSettings],
highlightSettingById: {
...state.highlightSettingById,
},
};
// Transfer this data from the default highlight setting
// so they carry over to other servers
if (id === defaultHighlightSetting.id) {
if (settings.highlightText) {
nextState.highlightText = settings.highlightText;
}
if (settings.highlightColor) {
nextState.highlightColor = settings.highlightColor;
}
}
if (nextState.highlightSettingById[id]) {
nextState.highlightSettingById[id] = {
...nextState.highlightSettingById[id],
...settings,
};
}
return nextState;
}
}
return state;
}
@@ -1,12 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const selectSettings = (state) => state.settings;
export const selectActiveTab = (state) => state.settings.view.activeTab;
export const selectHighlightSettings = (state) =>
state.settings.highlightSettings;
export const selectHighlightSettingById = (state) =>
state.settings.highlightSettingById;
@@ -1,12 +1,13 @@
import { useDispatch } from 'tgui/backend';
import type { Page } from '../chat/types';
import { store } from '../events/store';
import { importSettings } from './actions';
import { storedSettingsAtom } from './atoms';
import { startSettingsMigration } from './migration';
export function exportChatSettings(pages: Record<string, Page>): void {
const settings = store.get(storedSettingsAtom);
export function exportChatSettings(
settings: Record<string, any>,
pages: Record<string, Page>[],
) {
const opts: SaveFilePickerOptions = {
id: `ss13-chatprefs-${Date.now()}`,
suggestedName: `ss13-chatsettings-${new Date().toJSON().slice(0, 10)}.json`,
@@ -38,17 +39,20 @@ export function exportChatSettings(
});
}
export function importChatSettings(settings: string | string[]) {
export function importChatSettings(settings: string | string[]): void {
const dispatch = useDispatch();
if (Array.isArray(settings)) {
return;
}
const ourImport = JSON.parse(settings);
if (!ourImport?.version) {
return;
if (!ourImport?.version) return;
let pageRecord: Record<string, Page>[] = [];
if ('chatPages' in ourImport) {
pageRecord = ourImport.chatPages;
delete ourImport.chatPages;
}
const pageRecord = ourImport.chatPages;
delete ourImport.chatPages;
dispatch(importSettings(ourImport, pageRecord));
startSettingsMigration(ourImport);
}
@@ -4,22 +4,7 @@
* @license MIT
*/
export const THEMES = ['light', 'dark'];
const COLORS = {
DARK: {
BG_BASE: '#202020',
BG_SECOND: '#151515',
BUTTON: '#404040',
TEXT: '#A6A6A6',
},
LIGHT: {
BG_BASE: '#EEEEEE',
BG_SECOND: '#FFFFFF',
BUTTON: '#FFFFFF',
TEXT: '#000000',
},
};
import { COLORS } from './constants';
let setClientThemeTimer: NodeJS.Timeout;
@@ -33,7 +18,7 @@ let setClientThemeTimer: NodeJS.Timeout;
* 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) => {
export function setClientTheme(name): void | Promise<void> {
// Transmit once for fast updates and again in a little while in case we won
// the race against statbrowser init.
clearInterval(setClientThemeTimer);
@@ -43,9 +28,7 @@ export const setClientTheme = (name) => {
}, 1500);
const themeColor = COLORS[name.toUpperCase()];
if (!themeColor) {
return;
}
if (!themeColor) return;
return Byond.winset({
// Main windows
@@ -94,4 +77,4 @@ export const setClientTheme = (name) => {
'input.background-color': themeColor.BG_SECOND,
'input.text-color': themeColor.TEXT,
});
};
}
@@ -0,0 +1,47 @@
import * as z from 'zod';
import type { ChatPages } from '../chat/types';
const viewSchema = z.object({
activeTab: z.string(),
visible: z.boolean(),
});
export const settingsSchema = z.object({
adminMusicVolume: z.number(),
fontFamily: z.string(),
fontSize: z.number(),
initialized: z.boolean(),
lineHeight: z.number(),
statFontSize: z.number(),
statLinked: z.boolean(),
statTabsStyle: z.string(),
theme: z.string(),
version: z.number(),
view: viewSchema,
});
export type HighlightSetting = {
highlightColor: string;
highlightText: string;
highlightWholeMessage: boolean;
id: string;
matchCase: boolean;
matchWord: boolean;
};
export type HighlightState = {
/** Keep this for compatibility with other servers */
highlightColor: string;
highlightSettings: string[];
highlightSettingById: Record<string, HighlightSetting>;
/** Keep this for compatibility with other servers */
highlightText: string;
};
export type SettingsState = z.infer<typeof settingsSchema>;
// Imported and loaded settings without chatpages
export interface MergedSettings extends SettingsState, HighlightState {}
// Full exported settings with chatpages
export interface ExportedSettings extends MergedSettings, ChatPages {}
@@ -0,0 +1,109 @@
import { storage } from 'common/storage';
import { useAtom, useAtomValue } from 'jotai';
import { createUuid } from 'tgui-core/uuid';
import { chatRenderer } from '../chat/renderer';
import { defaultHighlightSetting, highlightsAtom, settingsAtom } from './atoms';
import type { HighlightSetting, HighlightState } from './types';
/** Custom hook with utility functions for updating highlight settings */
export function useHighlights() {
const [highlights, setHighlights] = useAtom(highlightsAtom);
const settings = useAtomValue(settingsAtom);
function storeHighlights(update: HighlightState): void {
setHighlights(update);
storage.set('panel-settings', {
...settings,
...update,
});
chatRenderer.setHighlight(
update.highlightSettings,
update.highlightSettingById,
);
}
function updateHighlight(
update: Partial<HighlightSetting> & { id: string },
): void {
const { id } = update;
const current = highlights.highlightSettingById[id];
if (!current) return;
// Copies highlights and updates the specified setting
const draft: HighlightState['highlightSettingById'] = {
...highlights.highlightSettingById,
[id]: {
...current,
...update,
},
};
// Reconstruct the overall highlight structure
const newState: HighlightState = {
...highlights,
highlightSettings: Object.keys(draft),
highlightSettingById: draft,
};
// Update state and persist to storage
storeHighlights(newState);
}
function removeHighlight(id: string): void {
const draft: Record<string, HighlightSetting> = {};
// Rebuild the highlight settings without the specified id
for (const key in highlights.highlightSettingById) {
if (key !== id) {
draft[key] = highlights.highlightSettingById[key];
}
}
const draftKeys = highlights.highlightSettings.filter((key) => key !== id);
// Ensure the default highlight setting always exists
if (id === defaultHighlightSetting.id) {
draft[defaultHighlightSetting.id] = defaultHighlightSetting;
draftKeys.unshift(defaultHighlightSetting.id);
}
// Construct the updated highlight settings structure
const newState: HighlightState = {
...highlights,
highlightSettingById: draft,
highlightSettings: draftKeys,
};
// Update state and persist to storage
storeHighlights(newState);
}
function addHighlight(): void {
const draft: HighlightSetting = {
...defaultHighlightSetting,
id: createUuid(),
};
// Append to the existing highlight settings
const updatedIds: HighlightState['highlightSettingById'] = {
...highlights.highlightSettingById,
[draft.id]: draft,
};
// Reconstruct the overall highlight settings structure
const newState: HighlightState = {
...highlights,
highlightSettings: [...highlights.highlightSettings, draft.id],
highlightSettingById: updatedIds,
};
// Update state and persist to storage
storeHighlights(newState);
}
return {
highlights,
updateHighlight,
removeHighlight,
addHighlight,
};
}
@@ -0,0 +1,56 @@
import { storage } from 'common/storage';
import { useAtom, useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { highlightsAtom, settingsAtom, settingsLoadedAtom } from './atoms';
import { generalSettingsHandler } from './helpers';
import { startSettingsMigration } from './migration';
import { setDisplayScaling } from './scaling';
import type { SettingsState } from './types';
/** Custom hook that handles loading and updating settings from storage. */
export function useSettings() {
const [settings, setSettings] = useAtom(settingsAtom);
const highlights = useAtomValue(highlightsAtom);
const [loaded, setLoaded] = useAtom(settingsLoadedAtom);
/** Load and migrate settings */
useEffect(() => {
if (loaded) return;
async function fetchSettings(): Promise<void> {
try {
const storedSettings = await storage.get('panel-settings');
console.log('Loaded panel settings from storage:', storedSettings);
startSettingsMigration(storedSettings);
} catch (error) {
console.error('Failed to load panel settings:', error);
}
}
fetchSettings();
setDisplayScaling();
setLoaded(true);
}, []);
function storeSettings(update: SettingsState): void {
setSettings(update);
console.log('Updated panel settings:', update);
storage.set('panel-settings', { ...update, ...highlights });
}
/** Updates any set of keys. Offers type safety based on the selection */
function updateSettings<TKey extends keyof SettingsState>(
update: Record<TKey, SettingsState[TKey]>,
): void {
const newSettings: SettingsState = {
...settings,
...update,
};
generalSettingsHandler(newSettings);
storeSettings(newSettings);
}
return { settings, updateSettings };
}
+1 -1
View File
@@ -6,7 +6,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
"tgui-core": "^5.3.1"
"tgui-core": "^5.5.10"
},
"private": true
}
+3 -3
View File
@@ -5,9 +5,9 @@
"common": "workspace:*",
"dateformat": "^5.0.3",
"dompurify": "^3.2.5",
"es-toolkit": "^1.39.3",
"es-toolkit": "^1.43.0",
"highlight.js": "^11.11.1",
"jotai": "^2.12.4",
"jotai": "^2.16.0",
"js-yaml": "^4.1.0",
"marked": "^15.0.11",
"marked-base-url": "^1.1.6",
@@ -15,7 +15,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-json-tree": "^0.20.0",
"tgui-core": "^5.3.1",
"tgui-core": "^5.5.10",
"tgui-dev-server": "workspace:*"
},
"private": true