From 37dc763ff9d948db4532eb346c38712fc6f00ff0 Mon Sep 17 00:00:00 2001 From: Jeremiah <42397676+jlsnow301@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:14:26 -0800 Subject: [PATCH] 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> --- tgui/bun.lock | 25 +- tgui/packages/common/package.json | 3 +- tgui/packages/common/type-safety.test.ts | 53 ++++ tgui/packages/common/type-safety.ts | 56 +++++ tgui/packages/tgui-panel/Panel.tsx | 28 +-- tgui/packages/tgui-panel/app.tsx | 20 ++ ...PlayingWidget.jsx => NowPlayingWidget.tsx} | 7 +- tgui/packages/tgui-panel/chat/ChatPanel.tsx | 2 +- tgui/packages/tgui-panel/chat/ChatTabs.tsx | 8 +- tgui/packages/tgui-panel/chat/middleware.ts | 31 +-- tgui/packages/tgui-panel/chat/types.ts | 4 + tgui/packages/tgui-panel/events/store.ts | 3 + tgui/packages/tgui-panel/index.tsx | 11 +- tgui/packages/tgui-panel/package.json | 8 +- .../tgui-panel/settings/SettingsGeneral.tsx | 70 +++--- .../tgui-panel/settings/SettingsPanel.tsx | 23 +- .../tgui-panel/settings/SettingsStatPanel.tsx | 29 +-- .../tgui-panel/settings/TextHighlight.tsx | 87 +++---- tgui/packages/tgui-panel/settings/actions.ts | 20 -- tgui/packages/tgui-panel/settings/atoms.ts | 55 +++++ .../packages/tgui-panel/settings/constants.ts | 21 +- tgui/packages/tgui-panel/settings/helpers.ts | 85 +++++++ tgui/packages/tgui-panel/settings/hooks.ts | 21 -- tgui/packages/tgui-panel/settings/index.ts | 10 - .../tgui-panel/settings/middleware.ts | 148 ------------ .../packages/tgui-panel/settings/migration.ts | 115 +++++++++ tgui/packages/tgui-panel/settings/model.ts | 20 -- tgui/packages/tgui-panel/settings/reducer.ts | 227 ------------------ .../packages/tgui-panel/settings/selectors.ts | 12 - .../tgui-panel/settings/settingsImExport.ts | 24 +- .../tgui-panel/{ => settings}/themes.ts | 25 +- tgui/packages/tgui-panel/settings/types.ts | 47 ++++ .../tgui-panel/settings/use-highlights.ts | 109 +++++++++ .../tgui-panel/settings/use-settings.ts | 56 +++++ tgui/packages/tgui-say/package.json | 2 +- tgui/packages/tgui/package.json | 6 +- 36 files changed, 777 insertions(+), 694 deletions(-) create mode 100644 tgui/packages/common/type-safety.test.ts create mode 100644 tgui/packages/common/type-safety.ts create mode 100644 tgui/packages/tgui-panel/app.tsx rename tgui/packages/tgui-panel/audio/{NowPlayingWidget.jsx => NowPlayingWidget.tsx} (95%) create mode 100644 tgui/packages/tgui-panel/events/store.ts create mode 100644 tgui/packages/tgui-panel/settings/atoms.ts create mode 100644 tgui/packages/tgui-panel/settings/helpers.ts delete mode 100644 tgui/packages/tgui-panel/settings/hooks.ts delete mode 100644 tgui/packages/tgui-panel/settings/index.ts delete mode 100644 tgui/packages/tgui-panel/settings/middleware.ts create mode 100644 tgui/packages/tgui-panel/settings/migration.ts delete mode 100644 tgui/packages/tgui-panel/settings/model.ts delete mode 100644 tgui/packages/tgui-panel/settings/reducer.ts delete mode 100644 tgui/packages/tgui-panel/settings/selectors.ts rename tgui/packages/tgui-panel/{ => settings}/themes.ts (89%) create mode 100644 tgui/packages/tgui-panel/settings/types.ts create mode 100644 tgui/packages/tgui-panel/settings/use-highlights.ts create mode 100644 tgui/packages/tgui-panel/settings/use-settings.ts diff --git a/tgui/bun.lock b/tgui/bun.lock index d5ca5c7549e..10639816fa5 100644 --- a/tgui/bun.lock +++ b/tgui/bun.lock @@ -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=="], diff --git a/tgui/packages/common/package.json b/tgui/packages/common/package.json index f40c3b79237..c9e6b725e22 100644 --- a/tgui/packages/common/package.json +++ b/tgui/packages/common/package.json @@ -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 } diff --git a/tgui/packages/common/type-safety.test.ts b/tgui/packages/common/type-safety.test.ts new file mode 100644 index 00000000000..f572a19781d --- /dev/null +++ b/tgui/packages/common/type-safety.test.ts @@ -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, + }); + }); +}); diff --git a/tgui/packages/common/type-safety.ts b/tgui/packages/common/type-safety.ts new file mode 100644 index 00000000000..71e1c6aa568 --- /dev/null +++ b/tgui/packages/common/type-safety.ts @@ -0,0 +1,56 @@ +import type { ZodObject } from 'zod'; + +type MergeInput = { + /** + * A zod object. + * @see Writing a Zod Schema: https://zod.dev/basics + */ + schema: ZodObject; + /** The input getting merged */ + source: Record; + /** 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>( + input: MergeInput, +): 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 }; +} diff --git a/tgui/packages/tgui-panel/Panel.tsx b/tgui/packages/tgui-panel/Panel.tsx index d79fd985fdb..319f774a4ba 100644 --- a/tgui/packages/tgui-panel/Panel.tsx +++ b/tgui/packages/tgui-panel/Panel.tsx @@ -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 ; - } - } + const [settingsVisible, setSettingsVisible] = useAtom(settingsVisibleAtom); return ( @@ -51,13 +47,11 @@ export const Panel = (props) => { @@ -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 diff --git a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx index 68f69833bc5..9f936aa5a45 100644 --- a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx +++ b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx @@ -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 ( @@ -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} diff --git a/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx b/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx index 4502c52ae90..d4a28c1ab33 100644 --- a/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx +++ b/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx @@ -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 = () => ( - Unlink Stat Panel from chat! -); +function LinkedToChat() { + return Unlink Stat Panel from chat!; +} export function SettingsStatPanel(props) { - const { statLinked, statFontSize, statTabsStyle } = - useSelector(selectSettings); - const dispatch = useDispatch(); + const { settings, updateSettings } = useSettings(); + const { statLinked, statFontSize, statTabsStyle } = settings; return (
@@ -29,14 +26,12 @@ export function SettingsStatPanel(props) { - {TabsViews.map((view) => ( + {tabViews.map((view) => ( @@ -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'} diff --git a/tgui/packages/tgui-panel/settings/TextHighlight.tsx b/tgui/packages/tgui-panel/settings/TextHighlight.tsx index 29d28b3647b..6bb53fac771 100644 --- a/tgui/packages/tgui-panel/settings/TextHighlight.tsx +++ b/tgui/packages/tgui-panel/settings/TextHighlight.tsx @@ -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) { @@ -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) { @@ -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, + }) } /> @@ -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, + }) } /> diff --git a/tgui/packages/tgui-panel/settings/actions.ts b/tgui/packages/tgui-panel/settings/actions.ts index 3c4a13b45b0..4190a25234f 100644 --- a/tgui/packages/tgui-panel/settings/actions.ts +++ b/tgui/packages/tgui-panel/settings/actions.ts @@ -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) => ({ diff --git a/tgui/packages/tgui-panel/settings/atoms.ts b/tgui/packages/tgui-panel/settings/atoms.ts new file mode 100644 index 00000000000..d232ac8fad9 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/atoms.ts @@ -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), +})); diff --git a/tgui/packages/tgui-panel/settings/constants.ts b/tgui/packages/tgui-panel/settings/constants.ts index 3fd4e5c2e4a..0e076fc07f6 100644 --- a/tgui/packages/tgui-panel/settings/constants.ts +++ b/tgui/packages/tgui-panel/settings/constants.ts @@ -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; diff --git a/tgui/packages/tgui-panel/settings/helpers.ts b/tgui/packages/tgui-panel/settings/helpers.ts new file mode 100644 index 00000000000..5f02f47d32a --- /dev/null +++ b/tgui/packages/tgui-panel/settings/helpers.ts @@ -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(); +} diff --git a/tgui/packages/tgui-panel/settings/hooks.ts b/tgui/packages/tgui-panel/settings/hooks.ts deleted file mode 100644 index b37b49a22c8..00000000000 --- a/tgui/packages/tgui-panel/settings/hooks.ts +++ /dev/null @@ -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)), - }; -}; diff --git a/tgui/packages/tgui-panel/settings/index.ts b/tgui/packages/tgui-panel/settings/index.ts deleted file mode 100644 index 7d5dcda427a..00000000000 --- a/tgui/packages/tgui-panel/settings/index.ts +++ /dev/null @@ -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'; diff --git a/tgui/packages/tgui-panel/settings/middleware.ts b/tgui/packages/tgui-panel/settings/middleware.ts deleted file mode 100644 index ca1df55a69b..00000000000 --- a/tgui/packages/tgui-panel/settings/middleware.ts +++ /dev/null @@ -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; - }; -} diff --git a/tgui/packages/tgui-panel/settings/migration.ts b/tgui/packages/tgui-panel/settings/migration.ts new file mode 100644 index 00000000000..77f1096bf37 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/migration.ts @@ -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); +} diff --git a/tgui/packages/tgui-panel/settings/model.ts b/tgui/packages/tgui-panel/settings/model.ts deleted file mode 100644 index 9c86be37628..00000000000 --- a/tgui/packages/tgui-panel/settings/model.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @file - */ -import { createUuid } from 'tgui-core/uuid'; - -export const createHighlightSetting = (obj?: Record) => ({ - id: createUuid(), - highlightText: '', - highlightColor: '#ffdd44', - highlightWholeMessage: true, - matchWord: false, - matchCase: false, - ...obj, -}); - -export const createDefaultHighlightSetting = (obj?: Record) => - createHighlightSetting({ - id: 'default', - ...obj, - }); diff --git a/tgui/packages/tgui-panel/settings/reducer.ts b/tgui/packages/tgui-panel/settings/reducer.ts deleted file mode 100644 index 4328f6530a4..00000000000 --- a/tgui/packages/tgui-panel/settings/reducer.ts +++ /dev/null @@ -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; -} diff --git a/tgui/packages/tgui-panel/settings/selectors.ts b/tgui/packages/tgui-panel/settings/selectors.ts deleted file mode 100644 index 75f9ecb4988..00000000000 --- a/tgui/packages/tgui-panel/settings/selectors.ts +++ /dev/null @@ -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; diff --git a/tgui/packages/tgui-panel/settings/settingsImExport.ts b/tgui/packages/tgui-panel/settings/settingsImExport.ts index 1cc7ba9c6fa..305fa394775 100644 --- a/tgui/packages/tgui-panel/settings/settingsImExport.ts +++ b/tgui/packages/tgui-panel/settings/settingsImExport.ts @@ -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): void { + const settings = store.get(storedSettingsAtom); -export function exportChatSettings( - settings: Record, - pages: Record[], -) { 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[] = []; + if ('chatPages' in ourImport) { + pageRecord = ourImport.chatPages; + delete ourImport.chatPages; } - const pageRecord = ourImport.chatPages; - delete ourImport.chatPages; dispatch(importSettings(ourImport, pageRecord)); + startSettingsMigration(ourImport); } diff --git a/tgui/packages/tgui-panel/themes.ts b/tgui/packages/tgui-panel/settings/themes.ts similarity index 89% rename from tgui/packages/tgui-panel/themes.ts rename to tgui/packages/tgui-panel/settings/themes.ts index 5679c62d955..e94aaf7be3a 100644 --- a/tgui/packages/tgui-panel/themes.ts +++ b/tgui/packages/tgui-panel/settings/themes.ts @@ -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 { // 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, }); -}; +} diff --git a/tgui/packages/tgui-panel/settings/types.ts b/tgui/packages/tgui-panel/settings/types.ts new file mode 100644 index 00000000000..480735ad332 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/types.ts @@ -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; + /** Keep this for compatibility with other servers */ + highlightText: string; +}; + +export type SettingsState = z.infer; + +// Imported and loaded settings without chatpages +export interface MergedSettings extends SettingsState, HighlightState {} + +// Full exported settings with chatpages +export interface ExportedSettings extends MergedSettings, ChatPages {} diff --git a/tgui/packages/tgui-panel/settings/use-highlights.ts b/tgui/packages/tgui-panel/settings/use-highlights.ts new file mode 100644 index 00000000000..b96cf67b8e7 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/use-highlights.ts @@ -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 & { 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 = {}; + // 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, + }; +} diff --git a/tgui/packages/tgui-panel/settings/use-settings.ts b/tgui/packages/tgui-panel/settings/use-settings.ts new file mode 100644 index 00000000000..6422e735b2d --- /dev/null +++ b/tgui/packages/tgui-panel/settings/use-settings.ts @@ -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 { + 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( + update: Record, + ): void { + const newSettings: SettingsState = { + ...settings, + ...update, + }; + + generalSettingsHandler(newSettings); + storeSettings(newSettings); + } + + return { settings, updateSettings }; +} diff --git a/tgui/packages/tgui-say/package.json b/tgui/packages/tgui-say/package.json index 47d64bc70e9..4ea8abe8411 100644 --- a/tgui/packages/tgui-say/package.json +++ b/tgui/packages/tgui-say/package.json @@ -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 } diff --git a/tgui/packages/tgui/package.json b/tgui/packages/tgui/package.json index c33da97bede..13157f150e7 100644 --- a/tgui/packages/tgui/package.json +++ b/tgui/packages/tgui/package.json @@ -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