From 6cf7d1ab5fa6882b3213505aa04a0558fd9b2dde Mon Sep 17 00:00:00 2001 From: Kashargul <144968721+Kashargul@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:56:31 +0100 Subject: [PATCH] ports the chat panel and backend updates [NO GBP] (#18895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ports the chat settings update * . * . * follow up changes * zod * migration changes from jl * this will blow up badly * yuck * . * . * order * . * : * be gone * . * . * logging * dix logging * move to its own atom * fix settings import * . * fix audio * tgui fix * . * archiving as atom * more changes from JL * bring back default archive all * no stale state * no need to run twice * - * we need both anyway * dep up * . * build fixes port * Fix file path replacement in build script * backend update * . * . * use the tg method * only use as unchunked fallback for say etc * . * linter errors * tg's chunking methód should be sane enough * sadly still not * update for tg chunking * force those * jl suggestion * . * needs game atom * push * have this * we only want to store if a page was changed or added * only store what we really care about * fixes import port * we need settings first * recoloring shouldn't be a pain * partial * . * some type fixing * . * no throw of events * ree map might not be there yet * . * always store our settings * this is a valid crash * ss early assets * saver image access * missed unsafe access * . * move chat loading order * . * . * . * . * missing unregs * keep the ref map name, just don't bother * await the asset like redux did before * try that? * . * use await * biome up * better handling as JL did on tg * rename * last dep up * major bump here * major bump --------- Co-authored-by: Cameron Lennox --- biome.json | 11 +- bun.lock | 22 +- code/controllers/subsystems/assets.dm | 8 +- code/controllers/subsystems/early_assets.dm | 33 ++ .../game/objects/items/devices/radio/radio.dm | 5 +- code/modules/asset_cache/asset_list.dm | 14 +- .../asset_cache/transports/asset_transport.dm | 6 +- code/modules/tgui/external.dm | 2 +- code/modules/tgui/tgui_window.dm | 8 +- code/modules/tgui_panel/external.dm | 6 +- dependencies.sh | 2 +- package.json | 2 +- tgui/bun.lock | 278 +++++++----- tgui/global.d.ts | 9 +- tgui/package.json | 10 +- tgui/packages/common/assets.ts | 30 ++ tgui/packages/common/package.json | 3 +- tgui/packages/common/redux.test.ts | 70 --- tgui/packages/common/redux.ts | 198 --------- tgui/packages/common/type-safety.test.ts | 53 +++ tgui/packages/common/type-safety.ts | 56 +++ tgui/packages/tgfont/package.json | 2 +- tgui/packages/tgui-panel/Notifications.tsx | 8 +- tgui/packages/tgui-panel/Panel.tsx | 76 ++-- tgui/packages/tgui-panel/app.tsx | 12 + .../tgui-panel/audio/NowPlayingWidget.tsx | 85 ++-- tgui/packages/tgui-panel/audio/atoms.ts | 22 + tgui/packages/tgui-panel/audio/handlers.ts | 39 ++ tgui/packages/tgui-panel/audio/hooks.ts | 18 - tgui/packages/tgui-panel/audio/index.ts | 10 - tgui/packages/tgui-panel/audio/middleware.ts | 37 -- tgui/packages/tgui-panel/audio/reducer.ts | 52 --- tgui/packages/tgui-panel/audio/selectors.ts | 7 - tgui/packages/tgui-panel/audio/types.ts | 13 - .../tgui-panel/chat/ChatPageSettings.tsx | 84 +--- tgui/packages/tgui-panel/chat/ChatPanel.tsx | 123 +++--- tgui/packages/tgui-panel/chat/ChatTabs.tsx | 67 +-- tgui/packages/tgui-panel/chat/actions.ts | 27 -- tgui/packages/tgui-panel/chat/atoms.ts | 34 ++ tgui/packages/tgui-panel/chat/chatExport.ts | 23 +- tgui/packages/tgui-panel/chat/constants.ts | 12 +- tgui/packages/tgui-panel/chat/handlers.ts | 60 +++ tgui/packages/tgui-panel/chat/helpers.ts | 145 ++++++ tgui/packages/tgui-panel/chat/index.ts | 11 - tgui/packages/tgui-panel/chat/messageNode.ts | 5 + tgui/packages/tgui-panel/chat/middleware.ts | 412 ------------------ tgui/packages/tgui-panel/chat/migration.ts | 209 +++++++++ tgui/packages/tgui-panel/chat/model.ts | 69 +-- tgui/packages/tgui-panel/chat/reducer.ts | 261 ----------- tgui/packages/tgui-panel/chat/renderer.tsx | 135 ++---- tgui/packages/tgui-panel/chat/selectors.ts | 16 - tgui/packages/tgui-panel/chat/types.ts | 35 +- .../tgui-panel/chat/use-chat-pages.ts | 146 +++++++ .../tgui-panel/chat/use-chat-persistence.ts | 301 +++++++++++++ .../tgui-panel/events/handlers/assets.ts | 8 + .../tgui-panel/events/handlers/connected.ts | 15 + .../events/handlers/roundrestart.ts | 8 + tgui/packages/tgui-panel/events/listeners.ts | 30 ++ tgui/packages/tgui-panel/events/store.ts | 3 + tgui/packages/tgui-panel/game/actions.ts | 13 - tgui/packages/tgui-panel/game/atoms.ts | 52 +++ tgui/packages/tgui-panel/game/hooks.ts | 13 - tgui/packages/tgui-panel/game/index.ts | 9 - tgui/packages/tgui-panel/game/middleware.ts | 53 --- tgui/packages/tgui-panel/game/reducer.ts | 73 ---- tgui/packages/tgui-panel/game/selectors.ts | 7 - tgui/packages/tgui-panel/game/types.ts | 29 +- .../tgui-panel/game/use-keep-alive.ts | 27 ++ tgui/packages/tgui-panel/index.tsx | 71 +-- tgui/packages/tgui-panel/package.json | 12 +- tgui/packages/tgui-panel/panelFocus.ts | 12 +- .../tgui-panel/ping/PingIndicator.tsx | 13 +- tgui/packages/tgui-panel/ping/actions.ts | 25 -- tgui/packages/tgui-panel/ping/atoms.ts | 11 + tgui/packages/tgui-panel/ping/handlers.ts | 38 ++ tgui/packages/tgui-panel/ping/helpers.ts | 74 ++++ tgui/packages/tgui-panel/ping/index.ts | 9 - tgui/packages/tgui-panel/ping/middleware.ts | 60 --- tgui/packages/tgui-panel/ping/reducer.ts | 58 --- tgui/packages/tgui-panel/ping/selectors.ts | 7 - tgui/packages/tgui-panel/reconnect.tsx | 14 +- .../settings/SettingTabs/AdminSettings.tsx | 17 +- .../settings/SettingTabs/ExportTab.tsx | 175 +++----- .../settings/SettingTabs/MessageLimits.tsx | 75 ++-- .../settings/SettingTabs/SettingsGeneral.tsx | 158 +++---- .../{ => SettingTabs}/SettingsStatPanel.tsx | 29 +- .../settings/SettingTabs/TTSSettings.tsx | 39 +- .../SettingTabs/TextHighlightSettings.tsx | 130 +++--- .../tgui-panel/settings/SettingsPanel.tsx | 32 +- tgui/packages/tgui-panel/settings/actions.ts | 36 -- tgui/packages/tgui-panel/settings/atoms.ts | 75 ++++ .../packages/tgui-panel/settings/constants.ts | 8 +- 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 | 147 ------- .../packages/tgui-panel/settings/migration.ts | 133 ++++++ tgui/packages/tgui-panel/settings/model.ts | 22 - tgui/packages/tgui-panel/settings/reducer.ts | 287 ------------ tgui/packages/tgui-panel/settings/scaling.ts | 55 +++ .../packages/tgui-panel/settings/selectors.ts | 12 - .../tgui-panel/settings/settingsImExport.ts | 82 +++- .../tgui-panel/{ => settings}/themes.ts | 6 +- tgui/packages/tgui-panel/settings/types.ts | 65 +++ .../tgui-panel/settings/use-highlights.ts | 109 +++++ .../tgui-panel/settings/use-settings.ts | 66 +++ tgui/packages/tgui-panel/telemetry.ts | 122 ------ .../tgui-panel/telemetry/constants.ts | 1 + .../packages/tgui-panel/telemetry/handlers.ts | 103 +++++ tgui/packages/tgui-panel/telemetry/helpers.ts | 16 + tgui/packages/tgui-say/TguiSay.tsx | 2 +- tgui/packages/tgui-say/package.json | 6 +- tgui/packages/tgui-setup/helpers.js | 2 +- tgui/packages/tgui/App.tsx | 15 +- tgui/packages/tgui/Icons.tsx | 34 -- tgui/packages/tgui/assets.ts | 46 +- tgui/packages/tgui/backend.ts | 358 +++------------ tgui/packages/tgui/debug/actions.ts | 11 - tgui/packages/tgui/debug/hooks.ts | 12 - tgui/packages/tgui/debug/index.ts | 10 - tgui/packages/tgui/debug/middleware.ts | 82 ---- tgui/packages/tgui/debug/reducer.ts | 27 -- tgui/packages/tgui/debug/selectors.ts | 9 - tgui/packages/tgui/debug/use-debug.ts | 27 ++ tgui/packages/tgui/events/README.md | 113 +++++ tgui/packages/tgui/events/act.ts | 101 +++++ tgui/packages/tgui/events/handlers/assets.ts | 31 ++ .../packages/tgui/events/handlers/chunking.ts | 66 +++ tgui/packages/tgui/events/handlers/ping.ts | 5 + .../packages/tgui/events/handlers/suspense.ts | 55 +++ tgui/packages/tgui/events/handlers/update.ts | 100 +++++ tgui/packages/tgui/events/listeners.ts | 29 ++ tgui/packages/tgui/events/store.ts | 37 ++ tgui/packages/tgui/events/types.ts | 54 +++ tgui/packages/tgui/hotkeys.ts | 265 ----------- tgui/packages/tgui/index.tsx | 29 +- tgui/packages/tgui/interfaces/Changelog.tsx | 2 +- .../ChemDispenser/ChemDispenserSettings.tsx | 5 +- tgui/packages/tgui/interfaces/Pda/index.tsx | 16 +- .../bay_prefs/general/SubtabBody.tsx | 16 +- .../bay_prefs/general/SubtabInfo.tsx | 8 +- .../bay_prefs/general/SubtabTraits.tsx | 3 +- .../bay_prefs/general/body/Facial.tsx | 8 +- .../bay_prefs/general/body/Hair.tsx | 8 +- .../bay_prefs/general/body/Markings.tsx | 14 +- .../bay_prefs/helper_components.tsx | 6 +- .../tgui/interfaces/PreferencesMenu/data.ts | 2 +- .../preferences/features/base.tsx | 3 +- .../WikiCommon/WikiColorIcon.tsx | 7 +- .../tgui/interfaces/RadiationConfig.tsx | 9 +- .../ResearchConsole/pages/LockScreen.tsx | 6 +- .../VorePanelElements/MultiOverlayImage.tsx | 25 +- .../VorePanelElements/VorePanelTextArea.tsx | 2 +- .../VoreUserPreferencesTabs/test.tsx | 41 ++ tgui/packages/tgui/layouts/Pane.tsx | 10 +- tgui/packages/tgui/layouts/TitleBar.tsx | 9 +- tgui/packages/tgui/layouts/Window.tsx | 67 ++- tgui/packages/tgui/links.test.ts | 89 ---- tgui/packages/tgui/links.ts | 46 -- tgui/packages/tgui/package.json | 11 +- tgui/packages/tgui/renderer.ts | 1 - tgui/packages/tgui/routes.tsx | 113 +++-- tgui/packages/tgui/stack.ts | 28 ++ tgui/packages/tgui/store.ts | 111 ----- tgui/packages/tgui/styles/main.scss | 2 +- tgui/public/helpers.min.js | 2 +- tgui/rspack.config.ts | 4 + tgui/tsconfig.json | 18 +- tools/build/build.ts | 12 +- tools/build/bun.lock | 12 +- tools/build/lib/byond.ts | 95 ++-- tools/build/lib/download.ts | 18 +- tools/build/lib/helpers.ts | 10 +- tools/build/lib/winreg.ts | 12 +- tools/build/package.json | 2 +- vorestation.dme | 1 + 176 files changed, 4179 insertions(+), 4443 deletions(-) create mode 100644 code/controllers/subsystems/early_assets.dm create mode 100644 tgui/packages/common/assets.ts delete mode 100644 tgui/packages/common/redux.test.ts delete mode 100644 tgui/packages/common/redux.ts 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 create mode 100644 tgui/packages/tgui-panel/audio/atoms.ts create mode 100644 tgui/packages/tgui-panel/audio/handlers.ts delete mode 100644 tgui/packages/tgui-panel/audio/hooks.ts delete mode 100644 tgui/packages/tgui-panel/audio/index.ts delete mode 100644 tgui/packages/tgui-panel/audio/middleware.ts delete mode 100644 tgui/packages/tgui-panel/audio/reducer.ts delete mode 100644 tgui/packages/tgui-panel/audio/selectors.ts delete mode 100644 tgui/packages/tgui-panel/audio/types.ts delete mode 100644 tgui/packages/tgui-panel/chat/actions.ts create mode 100644 tgui/packages/tgui-panel/chat/atoms.ts create mode 100644 tgui/packages/tgui-panel/chat/handlers.ts create mode 100644 tgui/packages/tgui-panel/chat/helpers.ts delete mode 100644 tgui/packages/tgui-panel/chat/index.ts create mode 100644 tgui/packages/tgui-panel/chat/messageNode.ts delete mode 100644 tgui/packages/tgui-panel/chat/middleware.ts create mode 100644 tgui/packages/tgui-panel/chat/migration.ts delete mode 100644 tgui/packages/tgui-panel/chat/reducer.ts delete mode 100644 tgui/packages/tgui-panel/chat/selectors.ts create mode 100644 tgui/packages/tgui-panel/chat/use-chat-pages.ts create mode 100644 tgui/packages/tgui-panel/chat/use-chat-persistence.ts create mode 100644 tgui/packages/tgui-panel/events/handlers/assets.ts create mode 100644 tgui/packages/tgui-panel/events/handlers/connected.ts create mode 100644 tgui/packages/tgui-panel/events/handlers/roundrestart.ts create mode 100644 tgui/packages/tgui-panel/events/listeners.ts create mode 100644 tgui/packages/tgui-panel/events/store.ts delete mode 100644 tgui/packages/tgui-panel/game/actions.ts create mode 100644 tgui/packages/tgui-panel/game/atoms.ts delete mode 100644 tgui/packages/tgui-panel/game/hooks.ts delete mode 100644 tgui/packages/tgui-panel/game/index.ts delete mode 100644 tgui/packages/tgui-panel/game/middleware.ts delete mode 100644 tgui/packages/tgui-panel/game/reducer.ts delete mode 100644 tgui/packages/tgui-panel/game/selectors.ts create mode 100644 tgui/packages/tgui-panel/game/use-keep-alive.ts delete mode 100644 tgui/packages/tgui-panel/ping/actions.ts create mode 100644 tgui/packages/tgui-panel/ping/atoms.ts create mode 100644 tgui/packages/tgui-panel/ping/handlers.ts create mode 100644 tgui/packages/tgui-panel/ping/helpers.ts delete mode 100644 tgui/packages/tgui-panel/ping/index.ts delete mode 100644 tgui/packages/tgui-panel/ping/middleware.ts delete mode 100644 tgui/packages/tgui-panel/ping/reducer.ts delete mode 100644 tgui/packages/tgui-panel/ping/selectors.ts rename tgui/packages/tgui-panel/settings/{ => SettingTabs}/SettingsStatPanel.tsx (69%) delete mode 100644 tgui/packages/tgui-panel/settings/actions.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 create mode 100644 tgui/packages/tgui-panel/settings/scaling.ts delete mode 100644 tgui/packages/tgui-panel/settings/selectors.ts rename tgui/packages/tgui-panel/{ => settings}/themes.ts (98%) 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 delete mode 100644 tgui/packages/tgui-panel/telemetry.ts create mode 100644 tgui/packages/tgui-panel/telemetry/constants.ts create mode 100644 tgui/packages/tgui-panel/telemetry/handlers.ts create mode 100644 tgui/packages/tgui-panel/telemetry/helpers.ts delete mode 100644 tgui/packages/tgui/Icons.tsx delete mode 100644 tgui/packages/tgui/debug/actions.ts delete mode 100644 tgui/packages/tgui/debug/hooks.ts delete mode 100644 tgui/packages/tgui/debug/index.ts delete mode 100644 tgui/packages/tgui/debug/middleware.ts delete mode 100644 tgui/packages/tgui/debug/reducer.ts delete mode 100644 tgui/packages/tgui/debug/selectors.ts create mode 100644 tgui/packages/tgui/debug/use-debug.ts create mode 100644 tgui/packages/tgui/events/README.md create mode 100644 tgui/packages/tgui/events/act.ts create mode 100644 tgui/packages/tgui/events/handlers/assets.ts create mode 100644 tgui/packages/tgui/events/handlers/chunking.ts create mode 100644 tgui/packages/tgui/events/handlers/ping.ts create mode 100644 tgui/packages/tgui/events/handlers/suspense.ts create mode 100644 tgui/packages/tgui/events/handlers/update.ts create mode 100644 tgui/packages/tgui/events/listeners.ts create mode 100644 tgui/packages/tgui/events/store.ts create mode 100644 tgui/packages/tgui/events/types.ts delete mode 100644 tgui/packages/tgui/hotkeys.ts create mode 100644 tgui/packages/tgui/interfaces/VorePanel/VoreUserPreferencesTabs/test.tsx delete mode 100644 tgui/packages/tgui/links.test.ts delete mode 100644 tgui/packages/tgui/links.ts create mode 100644 tgui/packages/tgui/stack.ts delete mode 100644 tgui/packages/tgui/store.ts diff --git a/biome.json b/biome.json index 4f6b665fd3..d8a95eb4e7 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "assist": { "actions": { "source": { @@ -21,7 +21,8 @@ "!tgui/public", "tgui/public/tgui.html", "!tgui/packages/tgfont/dist", - "!tgui/packages/tgui-setup/helpers.js" + "!tgui/packages/tgui-setup/helpers.js", + "!**/bun.lock" ] }, "formatter": { @@ -40,6 +41,9 @@ "rules": { "recommended": true, "a11y": "off", + "complexity": { + "useArrowFunction": "error" + }, "correctness": { "noUnusedImports": "error", "noUnusedVariables": "off", @@ -57,7 +61,8 @@ "suspicious": { "noArrayIndexKey": "off", "noExplicitAny": "off", - "noImplicitAnyLet": "off" + "noImplicitAnyLet": "off", + "noVar": "error" } } }, diff --git a/bun.lock b/bun.lock index 1b7f57646b..a22098a881 100644 --- a/bun.lock +++ b/bun.lock @@ -1,32 +1,32 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "devDependencies": { - "@biomejs/biome": "^2.3.8", + "@biomejs/biome": "^2.3.11", "prettier": "^3.7.4", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], + "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], } diff --git a/code/controllers/subsystems/assets.dm b/code/controllers/subsystems/assets.dm index 32d8b4c390..e243fef610 100644 --- a/code/controllers/subsystems/assets.dm +++ b/code/controllers/subsystems/assets.dm @@ -1,10 +1,9 @@ SUBSYSTEM_DEF(assets) name = "Assets" dependencies = list( + /datum/controller/subsystem/atoms, /datum/controller/subsystem/holomaps, /datum/controller/subsystem/robot_sprites - ///datum/controller/subsystem/persistent_paintings, - ///datum/controller/subsystem/greyscale_previews, ) flags = SS_NO_FIRE var/list/datum/asset_cache_item/cache = list() @@ -25,11 +24,7 @@ SUBSYSTEM_DEF(assets) transport = newtransport transport.Load() - - /datum/controller/subsystem/assets/Initialize() - OnConfigLoad() - for(var/type in typesof(/datum/asset)) var/datum/asset/A = type if (type != initial(A._abstract)) @@ -37,7 +32,6 @@ SUBSYSTEM_DEF(assets) transport.Initialize(cache) - initialized = TRUE return SS_INIT_SUCCESS /datum/controller/subsystem/assets/Recover() diff --git a/code/controllers/subsystems/early_assets.dm b/code/controllers/subsystems/early_assets.dm new file mode 100644 index 0000000000..47e5a17264 --- /dev/null +++ b/code/controllers/subsystems/early_assets.dm @@ -0,0 +1,33 @@ +/// Initializes any assets that need to be loaded ASAP. +/// This houses preference menu assets, since they can be loaded at any time, +/// most dangerously before the atoms SS initializes. +/// Thus, we want it to fail consistently in CI as if it would've if a player +/// opened it up early. +SUBSYSTEM_DEF(early_assets) + name = "Early Assets" + dependents = list( + /datum/controller/subsystem/mapping, + /datum/controller/subsystem/atoms, + ) + init_stage = INITSTAGE_EARLY + flags = SS_NO_FIRE + +/datum/controller/subsystem/early_assets/Initialize() + var/init_source = "early assets" + SSatoms.set_tracked_initalized(INITIALIZATION_INNEW_REGULAR, init_source) + + for (var/datum/asset/asset_type as anything in subtypesof(/datum/asset)) + if (initial(asset_type._abstract) == asset_type) + continue + + if (!initial(asset_type.early)) + continue + + if (!load_asset_datum(asset_type)) + stack_trace("Could not initialize early asset [asset_type]!") + + CHECK_TICK + + SSatoms.clear_tracked_initalize(init_source) + + return SS_INIT_SUCCESS diff --git a/code/game/objects/items/devices/radio/radio.dm b/code/game/objects/items/devices/radio/radio.dm index 789331a1ca..181ecd0acc 100644 --- a/code/game/objects/items/devices/radio/radio.dm +++ b/code/game/objects/items/devices/radio/radio.dm @@ -144,7 +144,7 @@ ui.open() /obj/item/radio/tgui_data(mob/user) - var/data[0] + var/data = list() data["rawfreq"] = frequency data["listening"] = listening @@ -164,6 +164,9 @@ if(syndie) data["useSyndMode"] = 1 + else + data["useSyndMode"] = 0 + data["minFrequency"] = PUBLIC_LOW_FREQ data["maxFrequency"] = PUBLIC_HIGH_FREQ diff --git a/code/modules/asset_cache/asset_list.dm b/code/modules/asset_cache/asset_list.dm index c06123c664..ab24f6b112 100644 --- a/code/modules/asset_cache/asset_list.dm +++ b/code/modules/asset_cache/asset_list.dm @@ -18,15 +18,15 @@ GLOBAL_LIST_EMPTY(asset_datums) var/cached_serialized_url_mappings var/cached_serialized_url_mappings_transport_type + /// Whether or not this asset should be loaded in the "early assets" SS + var/early = FALSE + /// Whether or not this asset can be cached across rounds of the same commit under the `CACHE_ASSETS` config. /// This is not a *guarantee* the asset will be cached. Not all asset subtypes respect this field, and the /// config can, of course, be disabled. /// Disable this if your asset can change between rounds on the same exact version of the code. var/cross_round_cachable = FALSE - /// Whether or not this asset should be loaded in the "early assets" SS - var/early = FALSE - /datum/asset/New() GLOB.asset_datums[type] = src register() @@ -114,6 +114,9 @@ GLOBAL_LIST_EMPTY(asset_datums) for (var/asset_name in assets) .[asset_name] = SSassets.transport.get_asset_url(asset_name, assets[asset_name]) +/datum/asset/simple/unregister() + for (var/asset_name in assets) + SSassets.transport.unregister_asset(asset_name) // For registering or sending multiple others at once /datum/asset/group @@ -546,6 +549,11 @@ GLOBAL_LIST_EMPTY(asset_datums) return . = list("[item_filename]" = SSassets.transport.get_asset_url(item_filename)) +/datum/asset/changelog_item/unregister() + if (!item_filename) + return + SSassets.transport.unregister_asset(item_filename) + //Generates assets based on iconstates of a single icon /datum/asset/simple/icon_states _abstract = /datum/asset/simple/icon_states diff --git a/code/modules/asset_cache/transports/asset_transport.dm b/code/modules/asset_cache/transports/asset_transport.dm index 77daee2a60..1b7e7670fc 100644 --- a/code/modules/asset_cache/transports/asset_transport.dm +++ b/code/modules/asset_cache/transports/asset_transport.dm @@ -90,9 +90,9 @@ #endif if (!istype(client)) if (ismob(client)) - var/mob/M = client - if (M.client) - client = M.client + var/mob/our_mob = client + if (our_mob.client) + client = our_mob.client else //no stacktrace because this will mainly happen because the client went away return else diff --git a/code/modules/tgui/external.dm b/code/modules/tgui/external.dm index 7ebaef3337..922033ba5d 100644 --- a/code/modules/tgui/external.dm +++ b/code/modules/tgui/external.dm @@ -225,7 +225,7 @@ if(href_list["window_id"] == "statbrowser") return TRUE // Chunked messages will exceed the limit - if(href_list["tgui"] && href_list["totalPackets"]) + if(href_list["tgui"] && href_list["type"] == "payloadChunk") return TRUE return FALSE diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm index d20c54070a..4c2b285cae 100644 --- a/code/modules/tgui/tgui_window.dm +++ b/code/modules/tgui/tgui_window.dm @@ -381,19 +381,17 @@ reinitialize() if("chat/resend") SSchat.handle_resend(client, payload) - /* Does not work with tgui say, so not in use yet if("oversizedPayloadRequest") var/payload_id = payload["id"] - var/chunk_count = payload["chunkCount"] - var/permit_payload = chunk_count <= CONFIG_GET(number/tgui_max_chunk_count) + var/chunk_count = text2num(payload["chunkCount"]) + var/permit_payload = chunk_count <= MAX_MESSAGE_CHUNKS if(permit_payload) create_oversized_payload(payload_id, payload["type"], chunk_count) send_message("oversizePayloadResponse", list("allow" = permit_payload, "id" = payload_id)) if("payloadChunk") var/payload_id = payload["id"] append_payload_chunk(payload_id, payload["chunk"]) - send_message("acknowlegePayloadChunk", list("id" = payload_id)) - */ + send_message("acknowledgePayloadChunk", list("id" = payload_id)) /datum/tgui_window/vv_edit_var(var_name, var_value) return var_name != NAMEOF(src, id) && ..() diff --git a/code/modules/tgui_panel/external.dm b/code/modules/tgui_panel/external.dm index ac0b96979b..fbfb3544fb 100644 --- a/code/modules/tgui_panel/external.dm +++ b/code/modules/tgui_panel/external.dm @@ -19,19 +19,19 @@ // Failed to fix, using tgalert as fallback action = tg_alert(src, "Did that work?", "", "Yes", "No, switch to old ui") if (action == "No, switch to old ui") - winset(src, "legacy_output_selector", "left=output_legacy") + winset(src, "outputwindow.legacy_output_selector", "left=output_legacy") log_tgui(src, "Failed to fix.", context = "verb/fix_tgui_panel") /client/proc/nuke_chat() // Catch all solution (kick the whole thing in the pants) - winset(src, "legacy_output_selector", "left=output_legacy") + winset(src, "outputwindow.legacy_output_selector", "left=output_legacy") if(!tgui_panel || !istype(tgui_panel)) log_tgui(src, "tgui_panel datum is missing", context = "verb/fix_tgui_panel") tgui_panel = new(src) tgui_panel.initialize(force = TRUE) // Force show the panel to see if there are any errors - winset(src, "legacy_output_selector", "left=output_browser") + winset(src, "outputwindow.legacy_output_selector", "left=output_browser") if(prefs?.read_preference(/datum/preference/toggle/browser_dev_tools)) winset(src, null, "browser-options=[DEFAULT_CLIENT_BROWSER_OPTIONS],devtools") diff --git a/dependencies.sh b/dependencies.sh index d46311c2fe..cbc3eed2ff 100644 --- a/dependencies.sh +++ b/dependencies.sh @@ -17,7 +17,7 @@ export RUST_G_VERSION=6.0.0 export NODE_VERSION_LTS=22.14.0 # Bun version -export BUN_VERSION=1.3.4 +export BUN_VERSION=1.3.5 # SpacemanDMM git tag export SPACEMAN_DMM_VERSION=suite-1.11 diff --git a/package.json b/package.json index cd2d851415..b3469f7cf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "@biomejs/biome": "^2.3.8", + "@biomejs/biome": "^2.3.11", "prettier": "^3.7.4" }, "scripts": { diff --git a/tgui/bun.lock b/tgui/bun.lock index 70157607dd..49f8cbca24 100644 --- a/tgui/bun.lock +++ b/tgui/bun.lock @@ -5,16 +5,16 @@ "": { "name": "tgui-workspace", "devDependencies": { - "@happy-dom/global-registrator": "^17.6.3", - "@rspack/cli": "^1.6.7", - "@rspack/core": "^1.6.7", - "@types/bun": "^1.3.4", + "@happy-dom/global-registrator": "^20.0.11", + "@rspack/cli": "^1.7.1", + "@rspack/core": "^1.7.1", + "@types/bun": "^1.3.5", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/webpack-env": "^1.18.8", "@types/wicg-file-system-access": "^2023.10.7", "css-loader": "^7.1.2", - "sass-embedded": "^1.95.1", + "sass-embedded": "^1.97.2", "sass-loader": "^16.0.6", "typescript": "^5.9.3", }, @@ -23,14 +23,15 @@ "name": "common", "version": "4.3.1", "dependencies": { - "es-toolkit": "^1.42.0", + "es-toolkit": "^1.43.0", + "zod": "^4.3.5", }, }, "packages/tgfont": { "name": "tgfont", "version": "2.0.0", "dependencies": { - "svgo": "^3.3.2", + "svgo": "^4.0.0", "svgtofont": "^6.5.0", }, }, @@ -41,16 +42,17 @@ "common": "workspace:*", "dateformat": "^5.0.3", "dompurify": "^3.3.1", - "es-toolkit": "^1.42.0", + "es-toolkit": "^1.43.0", "highlight.js": "^11.11.1", + "jotai": "^2.16.1", "js-yaml": "^4.1.1", - "marked": "^15.0.12", + "marked": "^17.0.1", "marked-base-url": "^1.1.8", "marked-smartypants": "^1.1.11", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-json-tree": "^0.20.0", - "tgui-core": "^5.5.10", + "tgui-core": "^5.7.2", "tgui-dev-server": "workspace:*", }, }, @@ -69,12 +71,14 @@ "dependencies": { "common": "workspace:*", "dompurify": "^3.3.1", - "es-toolkit": "^1.42.0", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "es-toolkit": "^1.43.0", + "jotai": "^2.16.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "tgui": "workspace:*", - "tgui-core": "^5.5.10", + "tgui-core": "^5.7.2", "tgui-dev-server": "workspace:*", + "zod": "^4.3.5", }, }, "packages/tgui-say": { @@ -82,10 +86,10 @@ "version": "1.0.0", "dependencies": { "common": "workspace:*", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "tgui": "workspace:*", - "tgui-core": "^5.5.10", + "tgui-core": "^5.7.2", }, }, "packages/tgui-setup": { @@ -98,13 +102,13 @@ }, }, "packages": { - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.10.1", "", {}, "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.10.2", "", {}, "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A=="], "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], - "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], - "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], @@ -118,7 +122,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@17.6.3", "", { "dependencies": { "happy-dom": "^17.6.3" } }, "sha512-SE8Mu6bdkgKENemVg20yMrKdYeAvVesQrLPVfNBKnhicCM3ylHV9oIDzDMxSFj3qYCrHrIWOZsItOXSafrM4Tw=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], @@ -150,17 +154,17 @@ "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], - "@module-federation/error-codes": ["@module-federation/error-codes@0.21.6", "", {}, "sha512-MLJUCQ05KnoVl8xd6xs9a5g2/8U+eWmVxg7xiBMeR0+7OjdWUbHwcwgVFatRIwSZvFgKHfWEiI7wsU1q1XbTRQ=="], + "@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="], - "@module-federation/runtime": ["@module-federation/runtime@0.21.6", "", { "dependencies": { "@module-federation/error-codes": "0.21.6", "@module-federation/runtime-core": "0.21.6", "@module-federation/sdk": "0.21.6" } }, "sha512-+caXwaQqwTNh+CQqyb4mZmXq7iEemRDrTZQGD+zyeH454JAYnJ3s/3oDFizdH6245pk+NiqDyOOkHzzFQorKhQ=="], + "@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="], - "@module-federation/runtime-core": ["@module-federation/runtime-core@0.21.6", "", { "dependencies": { "@module-federation/error-codes": "0.21.6", "@module-federation/sdk": "0.21.6" } }, "sha512-5Hd1Y5qp5lU/aTiK66lidMlM/4ji2gr3EXAtJdreJzkY+bKcI5+21GRcliZ4RAkICmvdxQU5PHPL71XmNc7Lsw=="], + "@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="], - "@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.21.6", "", { "dependencies": { "@module-federation/runtime": "0.21.6", "@module-federation/webpack-bundler-runtime": "0.21.6" } }, "sha512-fnP+ZOZTFeBGiTAnxve+axGmiYn2D60h86nUISXjXClK3LUY1krUfPgf6MaD4YDJ4i51OGXZWPekeMe16pkd8Q=="], + "@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="], - "@module-federation/sdk": ["@module-federation/sdk@0.21.6", "", {}, "sha512-x6hARETb8iqHVhEsQBysuWpznNZViUh84qV2yE7AD+g7uIzHKiYdoWqj10posbo5XKf/147qgWDzKZoKoEP2dw=="], + "@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="], - "@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.21.6", "", { "dependencies": { "@module-federation/runtime": "0.21.6", "@module-federation/sdk": "0.21.6" } }, "sha512-7zIp3LrcWbhGuFDTUMLJ2FJvcwjlddqhWGxi/MW3ur1a+HaO8v5tF2nl+vElKmbG1DFLU/52l3PElVcWf/YcsQ=="], + "@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], @@ -202,33 +206,33 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@rspack/binding": ["@rspack/binding@1.6.7", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.6.7", "@rspack/binding-darwin-x64": "1.6.7", "@rspack/binding-linux-arm64-gnu": "1.6.7", "@rspack/binding-linux-arm64-musl": "1.6.7", "@rspack/binding-linux-x64-gnu": "1.6.7", "@rspack/binding-linux-x64-musl": "1.6.7", "@rspack/binding-wasm32-wasi": "1.6.7", "@rspack/binding-win32-arm64-msvc": "1.6.7", "@rspack/binding-win32-ia32-msvc": "1.6.7", "@rspack/binding-win32-x64-msvc": "1.6.7" } }, "sha512-7ICabuBN3gHc6PPN52+m1kruz3ogiJjg1C0gSWdLRk18m/4jlcM2aAy6wfXjgODJdB0Yh2ro/lIpBbj+AYWUGA=="], + "@rspack/binding": ["@rspack/binding@1.7.1", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.1", "@rspack/binding-darwin-x64": "1.7.1", "@rspack/binding-linux-arm64-gnu": "1.7.1", "@rspack/binding-linux-arm64-musl": "1.7.1", "@rspack/binding-linux-x64-gnu": "1.7.1", "@rspack/binding-linux-x64-musl": "1.7.1", "@rspack/binding-wasm32-wasi": "1.7.1", "@rspack/binding-win32-arm64-msvc": "1.7.1", "@rspack/binding-win32-ia32-msvc": "1.7.1", "@rspack/binding-win32-x64-msvc": "1.7.1" } }, "sha512-qVTV1/UWpMSZktvK5A8+HolgR1Qf0nYR3Gg4Vax5x3/BcHDpwGZ0fbdFRUirGVWH/XwxZ81zoI6F2SZq7xbX+w=="], - "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.6.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QiIAP8JTAtht0j8/xZZEQTJRB9e+KrOm9c7JJm73CewVg55rDWRrwopiVfBNlTu1coem1ztUHJYdQhg2uXfqww=="], + "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3C0w0kfCHfgOH+AP/Dx1bm/b3AR/or5CmU22Abevek0m95ndU3iT902eLcm9JNiMQnDQLBQbolfj5P591t0oPg=="], - "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.6.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-DpQRxxTXkMMNPmBXeJBaAB8HmWKxH2IfvHv7vU+kBhJ3xdPtXU4/xBv1W3biluoNRG11gc1WLIgjzeGgaLCxmw=="], + "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HTrBpdw2gWwcpJ3c8h4JF8B1YRNvrFT+K620ycttrlu/HvI4/U770BBJ/ej36R/hdh59JvMCGe+w49FyXv6rzg=="], - "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.6.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-211/XoBiooGGgUo/NxNpsrzGUXtH1d7g/4+UTtjYtfc8QHwu7ZMHcsqg0wss53fXzn/yyxd0DZ56vBHq52BiFw=="], + "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-BX9yAPCO0WBFyOzKl9bSXT/cH27nnOJp02smIQMxfv7RNfwGkJg5GgakYcuYG+9U1HEFitBSzmwS2+dxDcAxlg=="], - "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.6.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-0WnqAWz3WPDsXGvOOA++or7cHpoidVsH3FlqNaAfRu6ni6n7ig/s0/jKUB+C5FtXOgmGjAGkZHfFgNHsvZ0FWw=="], + "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-maBX19XyiVkxzh/NA79ALetCobc4zUyoWkWLeCGyW5xKzhPVFatJp+qCiHqHkqUZcgRo+1i5ihoZ2bXmelIeZg=="], - "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.6.7", "", { "os": "linux", "cpu": "x64" }, "sha512-iMrE0Q4IuYpkE0MjpaOVaUDYbQFiCRI9D3EPoXzlXJj4kJSdNheODpHTBVRlWt8Xp7UAoWuIFXCvKFKcSMm3aQ=="], + "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.1", "", { "os": "linux", "cpu": "x64" }, "sha512-8KJAeBLiWcN7zEc9aaS7LRJPZVtZuQU8mCsn+fRhdQDSc+a9FcTN8b6Lw29z8cejwbU6Gxr/8wk5XGexMWFaZA=="], - "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.6.7", "", { "os": "linux", "cpu": "x64" }, "sha512-e7gKFxpdEQwYGk7lTC/hukTgNtaoAstBXehnZNk4k3kuU6+86WDrkn18Cd949iNqfIPtIG/wIsFNGbkHsH69hQ=="], + "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Gn9x5vhKRELvSoZ3ZjquY8eWtCXur0OsYnZ2/ump8mofM6IDaL7Qqu3Hf4Kud31PDH0tfz0jWf9piX32HHPmgg=="], - "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.6.7", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-yx88EFdE9RP3hh7VhjjW6uc6wGU0KcpOcZp8T8E/a+X8L98fX0aVrtM1IDbndhmdluIMqGbfJNap2+QqOCY9Mw=="], + "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-2r9M5iVchmsFkp3sz7A5YnMm2TfpkB71LK3AoaRWKMfvf5oFky0GSGISYd2TCBASO+X2Qskaq+B24Szo8zH5FA=="], - "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.6.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-vgxVYpFK8P5ulSXQQA+EbX78R/SUU+WIf0JIY+LoUoP89gZOsise/lKAJMAybzpeTJ1t0ndLchFznDYnzq+l4Q=="], + "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-/WIHp982yqqqAuiz2WLtf1ofo9d1lHDGZJ7flxFllb1iMgnUeSRyX6stxEi11K3Rg6pQa7FdCZGKX/engyj2bw=="], - "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.6.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-bV5RTW0Va0UQKJm9HWLt7fWNBPaBBBxCJOA2pJT3nGGm6CCXKnZSyEiVbFUk4jI/uiwBfqenlLkzaGoMRbeDhA=="], + "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-Kpela29n+kDGGsss6q/3qTd6n9VW7TOQaiA7t1YLdCCl8qqcdKlz/vWjFMd2MqgcSGC/16PvChE4sgpUvryfCQ=="], - "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.6.7", "", { "os": "win32", "cpu": "x64" }, "sha512-8xlbuJQtYktlBjZupOHlO8FeZqSIhsV3ih7xBSiOYar6LI6uQzA7XiO3I5kaPSDirBMMMKv1Z4rKCxWx10a3TQ=="], + "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.1", "", { "os": "win32", "cpu": "x64" }, "sha512-B/y4MWqP2Xeto1/HV0qtZNOMPSLrEVOqi2b7JSIXG/bhlf+3IAkDzEEoHs+ZikLR4C8hMaS0pVJsDGKFmGzC9A=="], - "@rspack/cli": ["@rspack/cli@1.6.7", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.7", "@rspack/dev-server": "~1.1.4", "exit-hook": "^4.0.0", "webpack-bundle-analyzer": "4.10.2" }, "peerDependencies": { "@rspack/core": "^1.0.0-alpha || ^1.x" }, "bin": { "rspack": "bin/rspack.js" } }, "sha512-lWU4Gfw3HIysXnBuN1Ta43BeQHewoHd3MAzRlxmEYsUPxau26h6oTpL5WnLsT/Eh4G7XqnW1NJ8COujnn9EGfg=="], + "@rspack/cli": ["@rspack/cli@1.7.1", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.7", "@rspack/dev-server": "~1.1.5", "exit-hook": "^4.0.0", "webpack-bundle-analyzer": "4.10.2" }, "peerDependencies": { "@rspack/core": "^1.0.0-alpha || ^1.x" }, "bin": { "rspack": "bin/rspack.js" } }, "sha512-Pb9GfVlvcmv83T+OG9M1yklNwyesDm5DAlMLGI/Iz8VQd9Q8a30nevE+GBdzTq4255hvgTlx52kNrmJiuGCC5A=="], - "@rspack/core": ["@rspack/core@1.6.7", "", { "dependencies": { "@module-federation/runtime-tools": "0.21.6", "@rspack/binding": "1.6.7", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-tkd4nSzTf+pDa9OAE4INi/JEa93HNszjWy5C9+trf4ZCXLLHsHxHQFbzoreuz4Vv2PlCWajgvAdiPMV1vGIkuw=="], + "@rspack/core": ["@rspack/core@1.7.1", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.1", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-kRxfY8RRa6nU3/viDvAIP6CRpx+0rfXFRonPL0pHBx8u6HhV7m9rLEyaN6MWsLgNIAWkleFGb7tdo4ux2ljRJQ=="], - "@rspack/dev-server": ["@rspack/dev-server@1.1.4", "", { "dependencies": { "chokidar": "^3.6.0", "http-proxy-middleware": "^2.0.9", "p-retry": "^6.2.0", "webpack-dev-server": "5.2.2", "ws": "^8.18.0" }, "peerDependencies": { "@rspack/core": "*" } }, "sha512-kGHYX2jYf3ZiHwVl0aUEPBOBEIG1aWleCDCAi+Jg32KUu3qr/zDUpCEd0wPuHfLEgk0X0xAEYCS6JMO7nBStNQ=="], + "@rspack/dev-server": ["@rspack/dev-server@1.1.5", "", { "dependencies": { "chokidar": "^3.6.0", "http-proxy-middleware": "^2.0.9", "p-retry": "^6.2.0", "webpack-dev-server": "5.2.2", "ws": "^8.18.0" }, "peerDependencies": { "@rspack/core": "*" } }, "sha512-cwz0qc6iqqoJhyWqxP7ZqE2wyYNHkBMQUXxoQ0tNoZ4YNRkDyQ4HVJ/3oPSmMKbvJk/iJ16u7xZmwG6sK47q/A=="], "@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="], @@ -240,7 +244,7 @@ "@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="], - "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], @@ -260,7 +264,7 @@ "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], - "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="], "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], @@ -276,7 +280,7 @@ "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], - "@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], "@types/serve-index": ["@types/serve-index@1.9.4", "", { "dependencies": { "@types/express": "*" } }, "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug=="], @@ -288,6 +292,8 @@ "@types/webpack-env": ["@types/webpack-env@1.18.8", "", {}, "sha512-G9eAoJRMLjcvN4I08wB5I7YofOb/kaJNd5uoCMX+LbKXTPCF+ZIHuqTnFaK9Jz1rgs035f9JUPUhNFtqgucy/A=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/wicg-file-system-access": ["@types/wicg-file-system-access@2023.10.7", "", {}, "sha512-g49ijasEJvCd7ifmAY2D0wdEtt1xRjBbA33PJTiv8mKBr7DoMsPeISoJ8oQOTopSRi+FBWPpPW5ouDj2QPKtGA=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -362,7 +368,7 @@ "bufferstreams": ["bufferstreams@4.0.0", "", { "dependencies": { "readable-stream": "^3.4.0", "yerror": "^8.0.0" } }, "sha512-azX778/2VQ9K2uiYprSUKLgK2K6lR1KtJycJDsMg7u0+Cc994A9HyGaUKb01e/T+M8jse057429iKXurCaT35g=="], - "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -406,7 +412,7 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "common": ["common@workspace:packages/common"], @@ -434,7 +440,7 @@ "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], - "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], @@ -512,7 +518,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.42.0", "", {}, "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA=="], + "es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -592,7 +598,7 @@ "handle-thing": ["handle-thing@2.0.1", "", {}, "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg=="], - "happy-dom": ["happy-dom@17.6.3", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug=="], + "happy-dom": ["happy-dom@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -682,11 +688,13 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jotai": ["jotai@2.16.1", "", { "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-vrHcAbo3P7Br37C8Bv6JshMtlKMPqqmx0DDREtTjT4nf3QChDrYdbH+4ik/9V0cXA57dK28RkJ5dctYvavcIlg=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "jsonc-eslint-parser": ["jsonc-eslint-parser@2.4.1", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", "espree": "^9.0.0", "semver": "^7.3.5" } }, "sha512-uuPNLJkKN8NXAlZlQ6kmUF9qO+T6Kyd7oV4+/7yy8Jz6+MZNyhPq8EdLpdfnPVzUC8qSf1b4j1azKaGnFsjmsw=="], + "jsonc-eslint-parser": ["jsonc-eslint-parser@2.4.2", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", "espree": "^9.0.0", "semver": "^7.3.5" } }, "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA=="], "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -696,7 +704,7 @@ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "lodash-es": ["lodash-es@4.17.22", "", {}, "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -704,7 +712,7 @@ "make-fetch-happen": ["make-fetch-happen@13.0.1", "", { "dependencies": { "@npmcli/agent": "^2.0.0", "cacache": "^18.0.0", "http-cache-semantics": "^4.1.1", "is-lambda": "^1.0.1", "minipass": "^7.0.2", "minipass-fetch": "^3.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "proc-log": "^4.2.0", "promise-retry": "^2.0.1", "ssri": "^10.0.0" } }, "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA=="], - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "marked-base-url": ["marked-base-url@1.1.8", "", { "peerDependencies": { "marked": ">= 4 < 18" } }, "sha512-RA80m/VTq82jAnpusyK7B4M45TZ3LMB1ymrdAJI3oaSG4+8MzBc8FFlVKIAhkBrfRM7CxeTHTGUVVK59/eiQyA=="], @@ -712,7 +720,7 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], @@ -856,17 +864,17 @@ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], - "react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-base16-styling": ["react-base16-styling@0.10.0", "", { "dependencies": { "@types/lodash": "^4.17.0", "color": "^4.2.3", "csstype": "^3.1.3", "lodash-es": "^4.17.21" } }, "sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg=="], - "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-json-tree": ["react-json-tree@0.20.0", "", { "dependencies": { "@types/lodash": "^4.17.15", "react-base16-styling": "^0.10.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h+f9fUNAxzBx1rbrgUF7+zSWKGHDtt2VPYLErIuB0JyKGnWgFMM21ksqQyb3EXwXNnoMW2rdE5kuAaubgGOx2Q=="], @@ -890,45 +898,45 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass": ["sass@1.93.3", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg=="], + "sass": ["sass@1.97.2", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw=="], - "sass-embedded": ["sass-embedded@1.95.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.95.1", "sass-embedded-android-arm": "1.95.1", "sass-embedded-android-arm64": "1.95.1", "sass-embedded-android-riscv64": "1.95.1", "sass-embedded-android-x64": "1.95.1", "sass-embedded-darwin-arm64": "1.95.1", "sass-embedded-darwin-x64": "1.95.1", "sass-embedded-linux-arm": "1.95.1", "sass-embedded-linux-arm64": "1.95.1", "sass-embedded-linux-musl-arm": "1.95.1", "sass-embedded-linux-musl-arm64": "1.95.1", "sass-embedded-linux-musl-riscv64": "1.95.1", "sass-embedded-linux-musl-x64": "1.95.1", "sass-embedded-linux-riscv64": "1.95.1", "sass-embedded-linux-x64": "1.95.1", "sass-embedded-unknown-all": "1.95.1", "sass-embedded-win32-arm64": "1.95.1", "sass-embedded-win32-x64": "1.95.1" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-l086+s40Z0qP7ckj4T+rI/7tZcwAfcKCG9ah9A808yINWOxZFv0kO0u/UHhR4G9Aimeyax/JNvqh8RE7z1wngg=="], + "sass-embedded": ["sass-embedded@1.97.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.97.2", "sass-embedded-android-arm": "1.97.2", "sass-embedded-android-arm64": "1.97.2", "sass-embedded-android-riscv64": "1.97.2", "sass-embedded-android-x64": "1.97.2", "sass-embedded-darwin-arm64": "1.97.2", "sass-embedded-darwin-x64": "1.97.2", "sass-embedded-linux-arm": "1.97.2", "sass-embedded-linux-arm64": "1.97.2", "sass-embedded-linux-musl-arm": "1.97.2", "sass-embedded-linux-musl-arm64": "1.97.2", "sass-embedded-linux-musl-riscv64": "1.97.2", "sass-embedded-linux-musl-x64": "1.97.2", "sass-embedded-linux-riscv64": "1.97.2", "sass-embedded-linux-x64": "1.97.2", "sass-embedded-unknown-all": "1.97.2", "sass-embedded-win32-arm64": "1.97.2", "sass-embedded-win32-x64": "1.97.2" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-lKJcskySwAtJ4QRirKrikrWMFa2niAuaGenY2ElHjd55IwHUiur5IdKu6R1hEmGYMs4Qm+6rlRW0RvuAkmcryg=="], - "sass-embedded-all-unknown": ["sass-embedded-all-unknown@1.95.1", "", { "dependencies": { "sass": "1.95.1" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-ObGM3xSHEK2fu89GusvAdk1hId3D1R03CyQ6/AVTFSrcBFav1a3aWUmBWtImzf5LsVzliRnlAPPS6+rT/Ghb1A=="], + "sass-embedded-all-unknown": ["sass-embedded-all-unknown@1.97.2", "", { "dependencies": { "sass": "1.97.2" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-Fj75+vOIDv1T/dGDwEpQ5hgjXxa2SmMeShPa8yrh2sUz1U44bbmY4YSWPCdg8wb7LnwiY21B2KRFM+HF42yO4g=="], - "sass-embedded-android-arm": ["sass-embedded-android-arm@1.95.1", "", { "os": "android", "cpu": "arm" }, "sha512-siaN1TVEjhBP4QJ5UlDBRhyKmMbFhbdcyHj0B4hIuNcinuVprP6tH1NT0NkHvkXh2egBmTvjzZgJ1ySsCB32JA=="], + "sass-embedded-android-arm": ["sass-embedded-android-arm@1.97.2", "", { "os": "android", "cpu": "arm" }, "sha512-BPT9m19ttY0QVHYYXRa6bmqmS3Fa2EHByNUEtSVcbm5PkIk1ntmYkG9fn5SJpIMbNmFDGwHx+pfcZMmkldhnRg=="], - "sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.95.1", "", { "os": "android", "cpu": "arm64" }, "sha512-E+3vZXhUOVHFiSITH2g53/ynxTG4zz8vTVrXGAKkZQwSe6aCO22uc1Pah23F3jOrDNF/YLrsyp82T/CIIczK3w=="], + "sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.97.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pF6I+R5uThrscd3lo9B3DyNTPyGFsopycdx0tDAESN6s+dBbiRgNgE4Zlpv50GsLocj/lDLCZaabeTpL3ubhYA=="], - "sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.95.1", "", { "os": "android", "cpu": "none" }, "sha512-UcPcr5JXVtInD+/XE+2DhwPsALUdRAHyippnnAP6MtdaT3+AnqqvzSVy9Gb6SKyeqEk4YxPmIlQpZCVODDT4eA=="], + "sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.97.2", "", { "os": "android", "cpu": "none" }, "sha512-fprI8ZTJdz+STgARhg8zReI2QhhGIT9G8nS7H21kc3IkqPRzhfaemSxEtCqZyvDbXPcgYiDLV7AGIReHCuATog=="], - "sass-embedded-android-x64": ["sass-embedded-android-x64@1.95.1", "", { "os": "android", "cpu": "x64" }, "sha512-sW/TO+B0Wq9VDTa7YiO74DW4iF9jEYds+9yslaHtc69r/Ch+Zj+ZB6HeJysfmen91zn5CLJDGrnTSrIk+/COfQ=="], + "sass-embedded-android-x64": ["sass-embedded-android-x64@1.97.2", "", { "os": "android", "cpu": "x64" }, "sha512-RswwSjURZxupsukEmNt2t6RGvuvIw3IAD5sDq1Pc65JFvWFY3eHqCmH0lG0oXqMg6KJcF0eOxHOp2RfmIm2+4w=="], - "sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.95.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SWTCwszlBzjin35T2OiGZSDRbC/sqg5Mjepih18lelELrz14eB9LcFTZeiqDfdnwx6qQqPWj2VufCpExr8jElA=="], + "sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.97.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xcsZNnU1XZh21RE/71OOwNqPVcGBU0qT9A4k4QirdA34+ts9cDIaR6W6lgHOBR/Bnnu6w6hXJR4Xth7oFrefPA=="], - "sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.95.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-0GZEgkE1e8E2h97lUtwgZbKHrJYmRE/KhWQBHv6ZueAto8DJcAFNFrIQiQoRJjraE6QTaw6ahSvc1YJ7gL4OQA=="], + "sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.97.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-T/9DTMpychm6+H4slHCAsYJRJ6eM+9H9idKlBPliPrP4T8JdC2Cs+ZOsYqrObj6eOtAD0fGf+KgyNhnW3xVafA=="], - "sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.95.1", "", { "os": "linux", "cpu": "arm" }, "sha512-zUAm/rztm5Uyy+DSs408VJg404siVgUuZyqId4tFwkPNC5WRKu25Z8bFMriyGaE4YfEqbNwFV07C16mJoGeVOA=="], + "sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.97.2", "", { "os": "linux", "cpu": "arm" }, "sha512-yDRe1yifGHl6kibkDlRIJ2ZzAU03KJ1AIvsAh4dsIDgK5jx83bxZLV1ZDUv7a8KK/iV/80LZnxnu/92zp99cXQ=="], - "sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.95.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQxa+qVX7Os2rMpJ/AvhWup+1cS0JieQgCfi9cz1Zckn4zaUhg35+m2FQhfKvzv4afeW5bubTMOQeTRMQujbXw=="], + "sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.97.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Wh+nQaFer9tyE5xBPv5murSUZE/+kIcg8MyL5uqww6be9Iq+UmZpcJM7LUk+q8klQ9LfTmoDSNFA74uBqxD6IA=="], - "sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.95.1", "", { "os": "linux", "cpu": "arm" }, "sha512-gNdaGmM3nZ0jkFNmyXWyNlXZPdaMP+7n5Mk3yGFGShqRt/6T/bHh5SkyNnU2ZdP1z7R9poPItJhULrZJ42ETeA=="], + "sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.97.2", "", { "os": "linux", "cpu": "arm" }, "sha512-GIO6xfAtahJAWItvsXZ3MD1HM6s8cKtV1/HL088aUpKJaw/2XjTCveiOO2AdgMpLNztmq9DZ1lx5X5JjqhS45g=="], - "sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.95.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-8lD5vHGzBjBRCMIr9CXCyjmy8Q1q+H4ygcYCIm/aPNYhrm9uPOzJfs8hv9kDRgRAASFkcPGlFw8tDH4QqiJ5wg=="], + "sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.97.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-NfUqZSjHwnHvpSa7nyNxbWfL5obDjNBqhHUYmqbHUcmqBpFfHIQsUPgXME9DKn1yBlBc3mWnzMxRoucdYTzd2Q=="], - "sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.95.1", "", { "os": "linux", "cpu": "none" }, "sha512-WjKfHxnFc/jOL5QtmgYuiWCc4616V15DkpE+7z41JWEawRXku6w++w7AR+Zx/jbz93FZ/AsZp27IS3XUt80u3Q=="], + "sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.97.2", "", { "os": "linux", "cpu": "none" }, "sha512-qtM4dJ5gLfvyTZ3QencfNbsTEShIWImSEpkThz+Y2nsCMbcMP7/jYOA03UWgPfEOKSehQQ7EIau7ncbFNoDNPQ=="], - "sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.95.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3U6994SRUUmC8mPvSG/vNLUo2ZcGv3jHuPoBywTbJhGQI8gq0hef1MY8TU5mvtj9DhQYlah6MYktM4YrOQgqcQ=="], + "sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.97.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ZAxYOdmexcnxGnzdsDjYmNe3jGj+XW3/pF/n7e7r8y+5c6D2CQRrCUdapLgaqPt1edOPQIlQEZF8q5j6ng21yw=="], - "sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.95.1", "", { "os": "linux", "cpu": "none" }, "sha512-CJ0tEEQnfpJEMCQrdubLsmuVc/c66EgaCAO0ZgSJ/KpxBKF3O1lHN6e1UErRf6VO0rh8ExAOh75po12Vu849Og=="], + "sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.97.2", "", { "os": "linux", "cpu": "none" }, "sha512-reVwa9ZFEAOChXpDyNB3nNHHyAkPMD+FTctQKECqKiVJnIzv2EaFF6/t0wzyvPgBKeatA8jszAIeOkkOzbYVkQ=="], - "sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.95.1", "", { "os": "linux", "cpu": "x64" }, "sha512-nGnzrEpZZOsGOwrRVyX4t15M8ijZWhc4e4lLpOqaPm+lv23HFncfY05WxU5bRj0KAknrkeTM2IX/6veP2aeUdA=="], + "sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.97.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bvAdZQsX3jDBv6m4emaU2OMTpN0KndzTAMgJZZrKUgiC0qxBmBqbJG06Oj/lOCoXGCxAvUOheVYpezRTF+Feog=="], - "sass-embedded-unknown-all": ["sass-embedded-unknown-all@1.95.1", "", { "dependencies": { "sass": "1.95.1" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-bhywAcadVQoCotD4gVmyMBi2SENPvyLFPrXf33VK5mY487Nf/g5SgGCUuGmfTsbns4NBwbwR7PA/1fnJmeMtdA=="], + "sass-embedded-unknown-all": ["sass-embedded-unknown-all@1.97.2", "", { "dependencies": { "sass": "1.97.2" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-86tcYwohjPgSZtgeU9K4LikrKBJNf8ZW/vfsFbdzsRlvc73IykiqanufwQi5qIul0YHuu9lZtDWyWxM2dH/Rsg=="], - "sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.95.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RWWODCthWdMVODoq98lyIk9R56mgGJ4TFUjD9LSCe7fAYD/tiTkUabE4AUzkZqknQSYr0n0Q2uy7POSDIKvhVg=="], + "sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.97.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Cv28q8qNjAjZfqfzTrQvKf4JjsZ6EOQ5FxyHUQQeNzm73R86nd/8ozDa1Vmn79Hq0kwM15OCM9epanDuTG1ksA=="], - "sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.95.1", "", { "os": "win32", "cpu": "x64" }, "sha512-jotHgOQnCb1XdjK0fhsyuhsfox7Y5EkrOc4h2caEpRcNCnsPTBZHqhuc8Lnw8HbKIhwKYkqWhexkjgz62MShhg=="], + "sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.97.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DVxLxkeDCGIYeyHLAvWW3yy9sy5Ruk5p472QWiyfyyG1G1ASAR8fgfIY5pT0vE6Rv+VAKVLwF3WTspUYu7S1/Q=="], "sass-loader": ["sass-loader@16.0.6", "", { "dependencies": { "neo-async": "^2.6.2" }, "peerDependencies": { "@rspack/core": "0.x || 1.x", "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", "sass": "^1.3.0", "sass-embedded": "*", "webpack": "^5.0.0" }, "optionalPeers": ["@rspack/core", "node-sass", "sass", "sass-embedded", "webpack"] }, "sha512-sglGzId5gmlfxNs4gK2U3h7HlVRfx278YK6Ono5lwzuvi1jxig80YiuHkaDBVsYIKFhx8wN7XSCI0M2IDS/3qA=="], @@ -944,11 +952,11 @@ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="], + "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], "serve-index": ["serve-index@1.9.1", "", { "dependencies": { "accepts": "~1.3.4", "batch": "0.6.1", "debug": "2.6.9", "escape-html": "~1.0.3", "http-errors": "~1.6.2", "mime-types": "~2.1.17", "parseurl": "~1.3.2" } }, "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw=="], - "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], + "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -1018,7 +1026,7 @@ "svgicons2svgfont": ["svgicons2svgfont@15.0.1", "", { "dependencies": { "@types/sax": "^1.2.7", "commander": "^12.1.0", "debug": "^4.3.6", "glob": "^11.0.0", "sax": "^1.4.1", "svg-pathdata": "^7.0.0", "transformation-matrix": "^3.0.0", "yerror": "^8.0.0" }, "bin": { "svgicons2svgfont": "bin/svgicons2svgfont.js" } }, "sha512-rE3BoIipD6DxBejPswalKRZZYA+7sy4miHqiHgXB0zI1xJD3gSCVrXh2R6Sdh9E4XDTxYp7gDxGW2W8DIBif/g=="], - "svgo": ["svgo@3.3.2", "", { "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0" }, "bin": "./bin/svgo" }, "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw=="], + "svgo": ["svgo@4.0.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.4.1" }, "bin": "./bin/svgo.js" }, "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw=="], "svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="], @@ -1028,7 +1036,7 @@ "sync-message-port": ["sync-message-port@1.1.3", "", {}, "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="], - "tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="], + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], @@ -1038,7 +1046,7 @@ "tgui": ["tgui@workspace:packages/tgui"], - "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-core": ["tgui-core@5.7.2", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@nozbe/microfuzz": "^1.0.0" }, "peerDependencies": { "react": "^19.1.0", "react-dom": "^19.1.0" } }, "sha512-KyV8Fp5yLYTPOFOJshdrbOplzvyyEPtMt+KLODtLG1NCrMtkrb09IuiXNl829QPOdB9Pr9HohSgqbPTHPRTHOg=="], "tgui-dev-server": ["tgui-dev-server@workspace:packages/tgui-dev-server"], @@ -1062,7 +1070,7 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "toml-eslint-parser": ["toml-eslint-parser@0.10.0", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0" } }, "sha512-khrZo4buq4qVmsGzS5yQjKe/WsFvV8fGfOjDQN0q4iy9FjRfPWRgTFrU8u1R2iu/SfWLhY9WnCi4Jhdrcbtg+g=="], + "toml-eslint-parser": ["toml-eslint-parser@0.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0" } }, "sha512-9mjy3frhioGIVGcwamlVlUyJ9x+WHw/TXiz9R4YOlmsIuBN43r9Dp8HZ35SF9EKjHrn3BUZj04CF+YqZ2oJ+7w=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], @@ -1086,9 +1094,9 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unique-filename": ["unique-filename@3.0.0", "", { "dependencies": { "unique-slug": "^4.0.0" } }, "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g=="], @@ -1112,8 +1120,6 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], - "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], - "webpack-bundle-analyzer": ["webpack-bundle-analyzer@4.10.2", "", { "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", "acorn-walk": "^8.0.0", "commander": "^7.2.0", "debounce": "^1.2.1", "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", "ws": "^7.3.1" }, "bin": { "webpack-bundle-analyzer": "lib/bin/analyzer.js" } }, "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw=="], "webpack-dev-middleware": ["webpack-dev-middleware@7.4.5", "", { "dependencies": { "colorette": "^2.0.10", "memfs": "^4.43.1", "mime-types": "^3.0.1", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "schema-utils": "^4.0.0" }, "peerDependencies": { "webpack": "^5.0.0" }, "optionalPeers": ["webpack"] }, "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA=="], @@ -1136,7 +1142,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -1154,6 +1160,8 @@ "yerror": ["yerror@8.0.0", "", {}, "sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@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.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1162,12 +1170,40 @@ "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@types/body-parser/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/bonjour/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/connect/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/connect-history-api-fallback/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/express-serve-static-core/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/http-proxy/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/node-forge/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/sax/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/send/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/serve-static/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + + "@types/sockjs/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/ws/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "bun-types/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], "cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -1178,6 +1214,8 @@ "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "clean-css-cli/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1216,22 +1254,12 @@ "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "sass-embedded-all-unknown/sass": ["sass@1.95.1", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-uPoDh5NIEZV4Dp5GBodkmNY9tSQfXY02pmCcUo+FR1P+x953HGkpw+vV28D4IqYB6f8webZtwoSaZaiPtpTeMg=="], - - "sass-embedded-unknown-all/sass": ["sass@1.95.1", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-uPoDh5NIEZV4Dp5GBodkmNY9tSQfXY02pmCcUo+FR1P+x953HGkpw+vV28D4IqYB6f8webZtwoSaZaiPtpTeMg=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], - - "send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "serve-index/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "serve-index/http-errors": ["http-errors@1.6.3", "", { "dependencies": { "depd": "~1.1.2", "inherits": "2.0.3", "setprototypeof": "1.1.0", "statuses": ">= 1.4.0 < 2" } }, "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A=="], - "serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -1240,12 +1268,16 @@ "svgicons2svgfont/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "svgtofont/svgo": ["svgo@3.3.2", "", { "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0" }, "bin": "./bin/svgo" }, "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw=="], + "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "webpack-bundle-analyzer/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "webpack-bundle-analyzer/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "webpack-dev-middleware/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -1256,8 +1288,34 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@types/body-parser/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/bonjour/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/connect-history-api-fallback/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/connect/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/http-proxy/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/node-forge/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/sax/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/send/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/serve-static/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/sockjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "cacache/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -1284,10 +1342,6 @@ "node-gyp/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "sass-embedded-all-unknown/sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "sass-embedded-unknown-all/sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1302,16 +1356,12 @@ "serve-index/http-errors/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], - "serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], - - "serve-static/send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], - - "serve-static/send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "svgicons2svgfont/glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "svgtofont/svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "svgtofont/svgo/css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + "webpack-dev-middleware/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -1320,10 +1370,6 @@ "node-gyp/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "sass-embedded-all-unknown/sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - - "sass-embedded-unknown-all/sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - - "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "svgtofont/svgo/css-tree/mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], } } diff --git a/tgui/global.d.ts b/tgui/global.d.ts index 0579f0d58b..2641a6f4d2 100644 --- a/tgui/global.d.ts +++ b/tgui/global.d.ts @@ -177,7 +177,7 @@ type ByondType = { /** * Maps icons to their ref */ - iconRefMap: Record; + iconRefMap: Readonly>; /** * Downloads a blob, platform-agnostic @@ -189,12 +189,11 @@ type ByondType = { * Object that provides access to Byond Skin API and is available in * any tgui application. */ -const Byond: ByondType; +const Byond: ByondType = {}; interface Window { Byond: ByondType; - __store__: Store; - __augmentStack__: (store: Store) => StackAugmentor; + __augmentStack__: (stack: string, error?: Error) => string; // IE IndexedDB stuff. msIndexedDB: IDBFactory; @@ -204,4 +203,6 @@ interface Window { hubStorage: Storage; domainStorage: Storage; serverStorage: Storage; + + __chatRenderer__: any; } diff --git a/tgui/package.json b/tgui/package.json index c364d2bb10..03b3f8de59 100644 --- a/tgui/package.json +++ b/tgui/package.json @@ -2,16 +2,16 @@ "name": "tgui-workspace", "version": "6.0.0", "devDependencies": { - "@happy-dom/global-registrator": "^17.6.3", - "@rspack/cli": "^1.6.7", - "@rspack/core": "^1.6.7", - "@types/bun": "^1.3.4", + "@happy-dom/global-registrator": "^20.0.11", + "@rspack/cli": "^1.7.1", + "@rspack/core": "^1.7.1", + "@types/bun": "^1.3.5", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/webpack-env": "^1.18.8", "@types/wicg-file-system-access": "^2023.10.7", "css-loader": "^7.1.2", - "sass-embedded": "^1.95.1", + "sass-embedded": "^1.97.2", "sass-loader": "^16.0.6", "typescript": "^5.9.3" }, diff --git a/tgui/packages/common/assets.ts b/tgui/packages/common/assets.ts new file mode 100644 index 0000000000..958d472ca4 --- /dev/null +++ b/tgui/packages/common/assets.ts @@ -0,0 +1,30 @@ +const EXCLUDED_PATTERNS = [/v4shim/i]; + +export function loadStyleSheet(payload: string): void { + Byond.loadCss(payload); +} + +export function loadMappings( + payload: Record, + /** Lets you insert your own independent asset map. */ + map: Record, +): void { + for (const name in payload) { + // Skip anything that matches excluded patterns + if (EXCLUDED_PATTERNS.some((regex) => regex.test(name))) { + continue; + } + + const url = payload[name]; + const ext = name.split('.').pop(); + + map[name] = url; + + if (ext === 'css') { + Byond.loadCss(url); + } + if (ext === 'js') { + Byond.loadJs(url); + } + } +} diff --git a/tgui/packages/common/package.json b/tgui/packages/common/package.json index 3bbb7d8031..58f9d3ef68 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.42.0" + "es-toolkit": "^1.43.0", + "zod": "^4.3.5" }, "private": true } diff --git a/tgui/packages/common/redux.test.ts b/tgui/packages/common/redux.test.ts deleted file mode 100644 index 0a5d8c143c..0000000000 --- a/tgui/packages/common/redux.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'bun:test'; - -import { - type Action, - applyMiddleware, - combineReducers, - createAction, - createStore, - type Reducer, -} from './redux'; - -// Dummy Reducer -const counterReducer: Reducer> = (state = 0, action) => { - switch (action.type) { - case 'INCREMENT': - return state + 1; - case 'DECREMENT': - return state - 1; - default: - return state; - } -}; - -// Dummy Middleware -const loggingMiddleware = (storeApi) => (next) => (action) => { - console.log('Middleware:', action); - return next(action); -}; - -// Dummy Action Creators -const increment = createAction('INCREMENT'); -const decrement = createAction('DECREMENT'); - -describe('Redux implementation tests', () => { - it('createStore works', () => { - const store = createStore(counterReducer); - expect(store.getState()).toBe(0); - }); - - it('createStore with applyMiddleware works', () => { - const store = createStore( - counterReducer, - applyMiddleware(loggingMiddleware), - ); - expect(store.getState()).toBe(0); - }); - - it('dispatch works', () => { - const store = createStore(counterReducer); - store.dispatch(increment()); - expect(store.getState()).toBe(1); - store.dispatch(decrement()); - expect(store.getState()).toBe(0); - }); - - it('combineReducers works', () => { - const rootReducer = combineReducers({ - counter: counterReducer, - }); - const store = createStore(rootReducer); - expect(store.getState()).toEqual({ counter: 0 }); - }); - - it('createAction works', () => { - const incrementAction = increment(); - expect(incrementAction).toEqual({ type: 'INCREMENT' }); - const decrementAction = decrement(); - expect(decrementAction).toEqual({ type: 'DECREMENT' }); - }); -}); diff --git a/tgui/packages/common/redux.ts b/tgui/packages/common/redux.ts deleted file mode 100644 index 3327588471..0000000000 --- a/tgui/packages/common/redux.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -type Fn = (...args: any[]) => any; - -export type Reducer = ( - state: State | undefined, - action: ActionType, -) => State; - -export type Store = { - dispatch: Dispatch; - subscribe: (listener: () => void) => void; - getState: () => State; -}; - -type MiddlewareAPI = { - getState: () => State; - dispatch: Dispatch; -}; - -export type Middleware = ( - storeApi: MiddlewareAPI, -) => (next: Dispatch) => Dispatch; - -export type Action = { - type: TType; -}; - -export type AnyAction = Action & { - [extraProps: string]: any; -}; - -export type Dispatch = ( - action: ActionType, -) => void; - -type StoreEnhancer = (createStoreFunction: Fn) => Fn; - -type PreparedAction = { - payload?: any; - meta?: any; -}; - -/** - * Creates a Redux store. - */ -export const createStore = ( - reducer: Reducer, - enhancer?: StoreEnhancer, -): Store => { - // Apply a store enhancer (applyMiddleware is one of them). - if (enhancer) { - return enhancer(createStore)(reducer); - } - - let currentState: State; - const listeners: Array<() => void> = []; - - const getState = (): State => currentState; - - const subscribe = (listener: () => void): void => { - listeners.push(listener); - }; - - const dispatch = (action: ActionType): void => { - currentState = reducer(currentState, action); - for (let i = 0; i < listeners.length; i++) { - listeners[i](); - } - }; - - // This creates the initial store by causing each reducer to be called - // with an undefined state - dispatch({ type: '@@INIT' } as ActionType); - - return { - dispatch, - subscribe, - getState, - }; -}; - -/** - * Creates a store enhancer which applies middleware to all dispatched - * actions. - */ -export const applyMiddleware = ( - ...middlewares: Middleware[] -): StoreEnhancer => { - return ( - createStoreFunction: (reducer: Reducer, enhancer?: StoreEnhancer) => Store, - ) => { - return (reducer, ...args): Store => { - const store = createStoreFunction(reducer, ...args); - - let dispatch: Dispatch = (action, ...args) => { - throw new Error( - 'Dispatching while constructing your middleware is not allowed.', - ); - }; - - const storeApi: MiddlewareAPI = { - getState: store.getState, - dispatch: (action, ...args) => dispatch(action, ...args), - }; - - const chain = middlewares.map((middleware) => middleware(storeApi)); - dispatch = chain.reduceRight( - (next, middleware) => middleware(next), - store.dispatch, - ); - - return { - ...store, - dispatch, - }; - }; - }; -}; - -/** - * Combines reducers by running them in their own object namespaces as - * defined in reducersObj paramter. - * - * Main difference from redux/combineReducers is that it preserves keys - * in the state that are not present in the reducers object. This function - * is also more flexible than the redux counterpart. - */ -export const combineReducers = ( - reducersObj: Record, -): Reducer => { - const keys = Object.keys(reducersObj); - - return (prevState = {}, action) => { - const nextState = { ...prevState }; - let hasChanged = false; - - for (const key of keys) { - const reducer = reducersObj[key]; - const prevDomainState = prevState[key]; - const nextDomainState = reducer(prevDomainState, action); - - if (prevDomainState !== nextDomainState) { - hasChanged = true; - nextState[key] = nextDomainState; - } - } - - return hasChanged ? nextState : prevState; - }; -}; - -/** - * A utility function to create an action creator for the given action - * type string. The action creator accepts a single argument, which will - * be included in the action object as a field called payload. The action - * creator function will also have its toString() overriden so that it - * returns the action type, allowing it to be used in reducer logic that - * is looking for that action type. - * - * @param {string} type The action type to use for created actions. - * @param {any} prepare (optional) a method that takes any number of arguments - * and returns { payload } or { payload, meta }. If this is given, the - * resulting action creator will pass its arguments to this method to - * calculate payload & meta. - * - * @public - */ -export const createAction = ( - type: TAction, - prepare?: (...args: any[]) => PreparedAction, -) => { - const actionCreator = (...args: any[]) => { - let action: Action & PreparedAction = { type }; - - if (prepare) { - const prepared = prepare(...args); - if (!prepared) { - throw new Error('prepare function did not return an object'); - } - action = { ...action, ...prepared }; - } else { - action.payload = args[0]; - } - - return action; - }; - - actionCreator.toString = () => type; - actionCreator.type = type; - actionCreator.match = (action) => action.type === type; - - return actionCreator; -}; diff --git a/tgui/packages/common/type-safety.test.ts b/tgui/packages/common/type-safety.test.ts new file mode 100644 index 0000000000..f572a19781 --- /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 0000000000..71e1c6aa56 --- /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/tgfont/package.json b/tgui/packages/tgfont/package.json index 827c891cb7..6715323f11 100644 --- a/tgui/packages/tgfont/package.json +++ b/tgui/packages/tgfont/package.json @@ -7,7 +7,7 @@ "tgfont:build": "svgo icons && node svgtofont.mjs" }, "dependencies": { - "svgo": "^3.3.2", + "svgo": "^4.0.0", "svgtofont": "^6.5.0" } } diff --git a/tgui/packages/tgui-panel/Notifications.tsx b/tgui/packages/tgui-panel/Notifications.tsx index bf545fddbf..063b065e1f 100644 --- a/tgui/packages/tgui-panel/Notifications.tsx +++ b/tgui/packages/tgui-panel/Notifications.tsx @@ -6,12 +6,12 @@ import { Stack } from 'tgui-core/components'; -export const Notifications = (props) => { +export function Notifications(props) { const { children } = props; return
{children}
; -}; +} -const NotificationsItem = (props) => { +function NotificationsItem(props) { const { rightSlot, children } = props; return ( @@ -23,6 +23,6 @@ const NotificationsItem = (props) => { )} ); -}; +} Notifications.Item = NotificationsItem; diff --git a/tgui/packages/tgui-panel/Panel.tsx b/tgui/packages/tgui-panel/Panel.tsx index f657e8c1d6..44ddc76318 100644 --- a/tgui/packages/tgui-panel/Panel.tsx +++ b/tgui/packages/tgui-panel/Panel.tsx @@ -4,36 +4,40 @@ * @license MIT */ +import { useAtom, useAtomValue } from 'jotai'; +import { useState } from 'react'; 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 { visibleAtom } from './audio/atoms'; +import { NowPlayingWidget } from './audio/NowPlayingWidget'; +import { ChatPanel } from './chat/ChatPanel'; +import { ChatTabs } from './chat/ChatTabs'; +import { useChatPersistence } from './chat/use-chat-persistence'; +import { gameAtom } from './game/atoms'; +import { useKeepAlive } from './game/use-keep-alive'; import { Notifications } from './Notifications'; -import { PingIndicator } from './ping'; +import { PingIndicator } from './ping/PingIndicator'; 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 game = useGame(); - if (process.env.NODE_ENV !== 'production') { - const { useDebug, KitchenSink } = require('tgui/debug'); - const debug = useDebug(); - if (debug.kitchenSink) { - return ; - } - } +export function Panel(props) { + const [audioVisible, setAudioVisible] = useAtom(visibleAtom); + const game = useAtomValue(gameAtom); + const { settings } = useSettings(); + const [settingsVisible, setSettingsVisible] = useAtom(settingsVisibleAtom); + const [dismissedWarning, setDismissedWarning] = useState(false); + useChatPersistence(); + useKeepAlive(setDismissedWarning); return ( - +
- + @@ -42,49 +46,55 @@ export const Panel = (props) => {
- {audio.visible && ( + {audioVisible && (
)} - {settings.visible && ( + {settingsVisible && ( )}
- + {settings.showReconnectWarning && game.connectionLostAt && - !game.dismissedConnectionWarning && ( - }> + !dismissedWarning && ( + + } + > You are either AFK, experiencing lag or the connection has closed. @@ -101,4 +111,4 @@ export const Panel = (props) => { ); -}; +} diff --git a/tgui/packages/tgui-panel/app.tsx b/tgui/packages/tgui-panel/app.tsx new file mode 100644 index 0000000000..efe198ae3a --- /dev/null +++ b/tgui/packages/tgui-panel/app.tsx @@ -0,0 +1,12 @@ +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() { + return ( + + + + ); +} diff --git a/tgui/packages/tgui-panel/audio/NowPlayingWidget.tsx b/tgui/packages/tgui-panel/audio/NowPlayingWidget.tsx index 08d812e908..29687a9fac 100644 --- a/tgui/packages/tgui-panel/audio/NowPlayingWidget.tsx +++ b/tgui/packages/tgui-panel/audio/NowPlayingWidget.tsx @@ -4,7 +4,7 @@ * @license MIT */ -import { useDispatch, useSelector } from 'tgui/backend'; +import { useAtomValue } from 'jotai'; import { Button, Collapsible, @@ -13,31 +13,35 @@ import { Stack, } from 'tgui-core/components'; -import { useSettings } from '../settings'; -import { selectAudio } from './selectors'; -import type { AudioState } from './types'; +import { useSettings } from '../settings/use-settings'; +import { metaAtom, playingAtom } from './atoms'; +import { player } from './handlers'; -export const NowPlayingWidget = (props) => { - const audio: AudioState = useSelector(selectAudio), - dispatch = useDispatch(), - settings = useSettings(), - title = audio.meta?.title, - URL = audio.meta?.link, - Artist = audio.meta?.artist || 'Unknown Artist', - upload_date = audio.meta?.upload_date || 'Unknown Date', - album = audio.meta?.album || 'Unknown Album', - duration = audio.meta?.duration, - date = !Number.isNaN(Number(upload_date)) - ? upload_date?.substring(0, 4) + - '-' + - upload_date?.substring(4, 6) + - '-' + - upload_date?.substring(6, 8) - : upload_date; +export function NowPlayingWidget(props) { + const { settings, updateSettings } = useSettings(); + const meta = useAtomValue(metaAtom); + const { + album = 'Unknown Album', + artist = 'Unknown Artist', + duration, + link, + title, + upload_date = 'Unknown Data', + } = meta || {}; + + const playing = useAtomValue(playingAtom); + + const date = !Number.isNaN(upload_date) + ? upload_date?.substring(0, 4) + + '-' + + upload_date?.substring(4, 6) + + '-' + + upload_date?.substring(6, 8) + : upload_date; return ( - {(audio.playing && ( + {playing ? ( { }} > { - +
- {URL !== 'Song Link Hidden' && ( + {link !== 'Song Link Hidden' && ( - URL: {URL} + URL: {link} )} Duration: {duration} - {Artist !== 'Song Artist Hidden' && - Artist !== 'Unknown Artist' && ( + {artist !== 'Song Artist Hidden' && + artist !== 'Unknown Artist' && ( - Artist: {Artist} + Artist: {artist} )} {album !== 'Song Album Hidden' && album !== 'Unknown Album' && ( @@ -79,22 +83,14 @@ export const NowPlayingWidget = (props) => { } - )) || ( + ) : ( Nothing to play. )} - {audio.playing && ( + {playing && ( - @@ -121,14 +91,7 @@ export const ChatPageSettings = (props) => { - dispatch( - toggleAcceptedType({ - pageId: page.id, - type: typeDef.type, - }), - ) - } + onClick={() => toggleAcceptedType(typeDef.type)} > {typeDef.name} @@ -140,14 +103,7 @@ export const ChatPageSettings = (props) => { - dispatch( - toggleAcceptedType({ - pageId: page.id, - type: typeDef.type, - }), - ) - } + onClick={() => toggleAcceptedType(typeDef.type)} > {typeDef.name} diff --git a/tgui/packages/tgui-panel/chat/ChatPanel.tsx b/tgui/packages/tgui-panel/chat/ChatPanel.tsx index 9e52f7c352..8bba15c42f 100644 --- a/tgui/packages/tgui-panel/chat/ChatPanel.tsx +++ b/tgui/packages/tgui-panel/chat/ChatPanel.tsx @@ -4,80 +4,73 @@ * @license MIT */ -import { Component, createRef, type RefObject } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { useEffect, useRef } from 'react'; import { Button } from 'tgui-core/components'; -import { shallowDiffers } from 'tgui-core/react'; - +import { + chatPagesRecordAtom, + currentPageIdAtom, + scrollTrackingAtom, +} from './atoms'; import { chatRenderer } from './renderer'; +import type { Page } from './types'; -type ChatPanelTypes = { - lineHeight?: number; - fontSize?: number; +type Props = { + fontSize?: string; + lineHeight: string | number; }; -export class ChatPanel extends Component { - ref: RefObject; - handleScrollTrackingChange: (value: any) => void; - state: { scrollTracking: boolean }; - constructor(props) { - super(props); - this.ref = createRef(); - this.state = { - scrollTracking: true, - }; - this.handleScrollTrackingChange = (value) => - this.setState({ - scrollTracking: value, - }); - } +export function ChatPanel(props: Props) { + const ref = useRef(null); + const scrollTracking = useAtomValue(scrollTrackingAtom); + // Page stuff + const currentPageId = useAtomValue(currentPageIdAtom); + const [pagesRecord, setPagesRecord] = useAtom(chatPagesRecordAtom); - componentDidMount() { - chatRenderer.mount(this.ref.current); - chatRenderer.events.on( - 'scrollTrackingChanged', - this.handleScrollTrackingChange, - ); - this.componentDidUpdate(); - } + /** Mounts the renderer */ + useEffect(() => { + if (ref.current) { + chatRenderer.mount(ref.current); + } + }, []); - componentWillUnmount() { - chatRenderer.events.off( - 'scrollTrackingChanged', - this.handleScrollTrackingChange, - ); - } + /** Resets unread count when scroll tracking is enabled */ + useEffect(() => { + if (scrollTracking) { + const draft: Page = { + ...pagesRecord[currentPageId], + unreadCount: 0, + }; - componentDidUpdate(prevProps?) { - requestAnimationFrame(() => { - chatRenderer.ensureScrollTracking(); - }); - const shouldUpdateStyle = - !prevProps || shallowDiffers(this.props, prevProps); - if (shouldUpdateStyle) { - chatRenderer.assignStyle({ - width: '100%', - 'white-space': 'pre-wrap', - 'font-size': this.props.fontSize, - 'line-height': this.props.lineHeight, + setPagesRecord({ + ...pagesRecord, + [currentPageId]: draft, }); } - } + }, [scrollTracking]); - render() { - const { scrollTracking } = this.state; - return ( - <> -
- {!scrollTracking && ( - - )} - - ); - } + /** Updates the style of the chat panel */ + useEffect(() => { + chatRenderer.assignStyle({ + width: '100%', + 'white-space': 'pre-wrap', + 'font-size': props.fontSize, + 'line-height': props.lineHeight, + }); + }, [props.fontSize, props.lineHeight]); + + return ( + <> +
+ {!scrollTracking && ( + + )} + + ); } diff --git a/tgui/packages/tgui-panel/chat/ChatTabs.tsx b/tgui/packages/tgui-panel/chat/ChatTabs.tsx index 895759b667..58ff89b9b6 100644 --- a/tgui/packages/tgui-panel/chat/ChatTabs.tsx +++ b/tgui/packages/tgui-panel/chat/ChatTabs.tsx @@ -4,44 +4,45 @@ * @license MIT */ -import { useDispatch, useSelector } from 'tgui/backend'; +import { useAtom } from 'jotai'; import { Box, Button, Stack, Tabs } from 'tgui-core/components'; +import { settingsVisibleAtom } from '../settings/atoms'; +import { useChatPages } from './use-chat-pages'; -import { openChatSettings } from '../settings/actions'; -import { addChatPage, changeChatPage } from './actions'; -import { selectChatPages, selectCurrentChatPage } from './selectors'; -import type { Page } from './types'; +type UnreadCountWidgetProps = { + value: number; +}; -const UnreadCountWidget = ({ value }: { value: number }) => ( - {Math.min(value, 99)} -); +function UnreadCountWidget(props: UnreadCountWidgetProps) { + const { value } = props; + return {Math.min(value, 99)}; +} + +export function ChatTabs(props) { + const { addChatPage, changeChatPage, pages, pagesRecord, currentPageId } = + useChatPages(); + + const [, setSettingsVisible] = useAtom(settingsVisibleAtom); -export const ChatTabs = (props) => { - const pages = useSelector(selectChatPages); - const currentPage = useSelector(selectCurrentChatPage); - const dispatch = useDispatch(); return ( - {pages.map((page: Page) => ( - - dispatch( - changeChatPage({ - pageId: page.id, - }), - ) - } - > - {page.name} - {!page.hideUnreadCount && page.unreadCount > 0 && ( - - )} - - ))} + {pages.map((page) => { + const actual = pagesRecord[page]; + return ( + changeChatPage(actual)} + > + {actual.name} + {!actual.hideUnreadCount && actual.unreadCount > 0 && ( + + )} + + ); + })} @@ -49,11 +50,11 @@ export const ChatTabs = (props) => { color="transparent" icon="plus" onClick={() => { - dispatch(addChatPage()); - dispatch(openChatSettings()); + addChatPage(); + setSettingsVisible(true); }} /> ); -}; +} diff --git a/tgui/packages/tgui-panel/chat/actions.ts b/tgui/packages/tgui-panel/chat/actions.ts deleted file mode 100644 index 44aa825703..0000000000 --- a/tgui/packages/tgui-panel/chat/actions.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { createAction } from 'common/redux'; - -import { createPage } from './model'; - -export const getChatData = createAction('chat/getChatData'); -export const loadChat = createAction('chat/load'); -export const rebuildChat = createAction('chat/rebuild'); -export const clearChat = createAction('chat/clear'); -export const updateMessageCount = createAction('chat/updateMessageCount'); -export const addChatPage = createAction('chat/addPage', () => ({ - payload: createPage(), -})); -export const changeChatPage = createAction('chat/changePage'); -export const updateChatPage = createAction('chat/updatePage'); -export const toggleAcceptedType = createAction('chat/toggleAcceptedType'); -export const removeChatPage = createAction('chat/removePage'); -export const changeScrollTracking = createAction('chat/changeScrollTracking'); -export const saveChatToDisk = createAction('chat/saveToDisk'); -export const purgeChatMessageArchive = createAction('chat/purgeMessageArchive'); -export const moveChatPageLeft = createAction('chat/movePageLeft'); -export const moveChatPageRight = createAction('chat/movePageRight'); diff --git a/tgui/packages/tgui-panel/chat/atoms.ts b/tgui/packages/tgui-panel/chat/atoms.ts new file mode 100644 index 0000000000..abbf2f5948 --- /dev/null +++ b/tgui/packages/tgui-panel/chat/atoms.ts @@ -0,0 +1,34 @@ +import { atom } from 'jotai'; +import { createMainPage } from './model'; + +export const mainPage = createMainPage(); + +export const versionAtom = atom(5); +export const scrollTrackingAtom = atom(true); +export const chatPagesAtom = atom([mainPage.id]); +export const currentPageIdAtom = atom(mainPage.id); +export const chatPagesRecordAtom = atom({ + [mainPage.id]: mainPage, +}); +export const storedLinesAtom = atom([]); +export const lastRoundIDAtom = atom(null); +export const storedRoundsAtom = atom(0); +export const exportStartAtom = atom(0); +export const exportEndAtom = atom(0); + +/** Chat has been initialized from storage */ +export const chatLoadedAtom = atom(false); + +export const allChatAtom = atom((get) => ({ + version: get(versionAtom), + currentPageId: get(currentPageIdAtom), + scrollTracking: get(scrollTrackingAtom), + pages: get(chatPagesAtom), + pageById: get(chatPagesRecordAtom), +})); + +export const currentPageAtom = atom((get) => { + const pageId = get(currentPageIdAtom); + const pagesById = get(chatPagesRecordAtom); + return pagesById[pageId]; +}); diff --git a/tgui/packages/tgui-panel/chat/chatExport.ts b/tgui/packages/tgui-panel/chat/chatExport.ts index 6c7a8fb38e..d659081829 100644 --- a/tgui/packages/tgui-panel/chat/chatExport.ts +++ b/tgui/packages/tgui-panel/chat/chatExport.ts @@ -1,10 +1,11 @@ -import { useGame } from '../game'; -import type { gameState } from '../game/types'; -import { useSettings } from '../settings'; +import { store } from '../events/store'; +import { gameAtom } from '../game/atoms'; +import type { GameAtom } from '../game/types'; +import { storedSettingsAtom } from '../settings/atoms'; import { MESSAGE_TYPE_UNKNOWN, MESSAGE_TYPES } from './constants'; +import { createMessageNode } from './messageNode'; import { canPageAcceptType } from './model'; -import { createMessageNode } from './renderer'; -import type { message, Page } from './types'; +import type { Page, SerializedMessage } from './types'; export function exportToDisk( cssText: string, @@ -13,7 +14,7 @@ export function exportToDisk( hasTimestamps: boolean, page: Page | null, ) { - const game: gameState = useGame(); + const game = store.get(gameAtom); // Fetch from server database const opts: SaveFilePickerOptions = { @@ -39,16 +40,16 @@ export function exportToDisk( async function getRound( cssText: string, - game: gameState, + game: GameAtom, page: Page | null, opts: SaveFilePickerOptions, hasTimestamps: boolean, startRound?: number, endRound?: number, ) { - const settings = useSettings(); - const { ckey, token } = game.userData; - const messages: message[] = []; + const settings = store.get(storedSettingsAtom); + const { ckey, token } = game.userData!; + const messages: SerializedMessage[] = []; const d = new Date(); const utcOffset = d.getTimezoneOffset() / -60; @@ -85,7 +86,7 @@ async function getRound( created_at: number; round_id: number; }) => { - const msg: message = { + const msg: SerializedMessage = { type: obj.msg_type ? obj.msg_type : '', html: obj.text_raw, createdAt: obj.created_at, diff --git a/tgui/packages/tgui-panel/chat/constants.ts b/tgui/packages/tgui-panel/chat/constants.ts index 21ad8f7e55..d4eb3e33e7 100644 --- a/tgui/packages/tgui-panel/chat/constants.ts +++ b/tgui/packages/tgui-panel/chat/constants.ts @@ -49,8 +49,18 @@ export const MESSAGE_TYPE_ADMINLOG = 'adminlog'; export const MESSAGE_TYPE_ATTACKLOG = 'attacklog'; export const MESSAGE_TYPE_DEBUG = 'debug'; +type MessageType = { + type: string; + name: string; + description: string; +} & Partial<{ + selector: string; + important: boolean; + admin: boolean; +}>; + // Metadata for each message type -export const MESSAGE_TYPES = [ +export const MESSAGE_TYPES: MessageType[] = [ // Always-on types { type: MESSAGE_TYPE_SYSTEM, diff --git a/tgui/packages/tgui-panel/chat/handlers.ts b/tgui/packages/tgui-panel/chat/handlers.ts new file mode 100644 index 0000000000..023ccb6c90 --- /dev/null +++ b/tgui/packages/tgui-panel/chat/handlers.ts @@ -0,0 +1,60 @@ +import * as z from 'zod'; +import { chatRenderer } from './renderer'; + +const sequences: number[] = []; +const sequences_requested: number[] = []; + +const messageSchema = z.object({ + sequence: z.number().int().nonnegative(), + content: z.any(), +}); + +type ChatMessage = z.infer; + +function pushMessage(message: ChatMessage): void { + sequences.push(message.sequence); + chatRenderer.processBatch([message.content], { + doArchive: true, + }); +} + +export function chatMessage(payload: string): void { + let message: ChatMessage; + try { + const parsed = JSON.parse(payload); + message = messageSchema.parse(parsed); + } catch (err) { + console.error(err); + return; + } + + if (sequences.includes(message.sequence)) { + return; + } + + const sequences_count = sequences.length; + if (sequences_count <= 0) { + pushMessage(message); + return; + } + + if (sequences_requested.includes(message.sequence)) { + sequences_requested.splice( + sequences_requested.indexOf(message.sequence), + 1, + ); + pushMessage(message); + return; + } + + // if we are receiving a message we requested, we can stop reliability checks + const expected_sequence = sequences[sequences_count - 1] + 1; + if (message.sequence !== expected_sequence) { + for (let req = expected_sequence; req < message.sequence; req++) { + sequences_requested.push(req); + Byond.sendMessage('chat/resend', req); + } + } + + pushMessage(message); +} diff --git a/tgui/packages/tgui-panel/chat/helpers.ts b/tgui/packages/tgui-panel/chat/helpers.ts new file mode 100644 index 0000000000..ac5c8d322e --- /dev/null +++ b/tgui/packages/tgui-panel/chat/helpers.ts @@ -0,0 +1,145 @@ +import { storage } from 'common/storage'; +import { gameAtom } from 'tgui-panel/game/atoms'; +import { settingsAtom } from 'tgui-panel/settings/atoms'; +import { store } from '../events/store'; +import { + allChatAtom, + chatLoadedAtom, + chatPagesAtom, + chatPagesRecordAtom, + currentPageAtom, + exportEndAtom, + exportStartAtom, + lastRoundIDAtom, + scrollTrackingAtom, + storedRoundsAtom, +} from './atoms'; +import { canPageAcceptType, serializeMessage } from './model'; +import { chatRenderer } from './renderer'; +import type { SerializedMessage, StoredChatSettings } from './types'; + +chatRenderer.events.on( + 'batchProcessed', + (countByType: Record) => { + // Use this flag to workaround unread messages caused by + // loading them from storage. Side effect of that, is that + // message count can not be trusted, only unread count. + if (store.get(chatLoadedAtom)) { + updateMessageCount(countByType); + } + }, +); + +function updateMessageCount(countByType: Record): void { + const pagesRecord = store.get(chatPagesRecordAtom); + const pages = store.get(chatPagesAtom); + const currentPage = store.get(currentPageAtom); + const scrollTracking = store.get(scrollTrackingAtom); + + const draftpagesRecord = { ...pagesRecord }; + + for (const pageId of pages) { + const page = pagesRecord[pageId]; + let unreadCount = 0; + + for (const type in countByType) { + /// Message does not belong here + if (!canPageAcceptType(page, type)) continue; + + // Current page scroll tracked + if (page === currentPage && scrollTracking) continue; + + // This page received the same message which we can read on the current + // page + if (page !== currentPage && canPageAcceptType(currentPage, type)) { + continue; + } + unreadCount += countByType[type]; + } + + if (unreadCount > 0) { + draftpagesRecord[page.id] = { + ...page, + unreadCount: page.unreadCount + unreadCount, + }; + } + } + + store.set(chatPagesRecordAtom, draftpagesRecord); +} + +export function saveChatToStorage(): void { + saveChatMessages(); + const allChat = store.get(allChatAtom); + saveChatState(allChat); +} + +function saveChatMessages(): void { + const settings = store.get(settingsAtom); + const game = store.get(gameAtom); + const allChat = store.get(allChatAtom); + storage.set('chat-state', allChat); + + if (!game.databaseBackendEnabled) { + const fromIndex = Math.max( + 0, + chatRenderer.messages.length - settings.persistentMessageLimit, + ); + + const messages = chatRenderer.messages + .slice(fromIndex) + .map((message) => serializeMessage(message)); + + storage.set('chat-messages', messages); + storage.set( + 'chat-messages-archive', + chatRenderer.archivedMessages.map((message) => serializeMessage(message)), + ); + } +} + +export function rebuildRoundTracking(archived: SerializedMessage[]) { + const storedRounds: number[] = []; + const storedLines: number[] = []; + + let lastId: number | null = null; + let line = 0; + + for (const msg of archived) { + const id = msg.roundId ?? 0; + if (id !== lastId) { + storedRounds.push(id); + storedLines.push(line); + lastId = id; + } + line++; + } + + return { storedRounds, storedLines, lastId }; +} + +export function purgeMessageArchive() { + chatRenderer.purgeMessageArchive(); + store.set(lastRoundIDAtom, null); + store.set(storedRoundsAtom, 0); + store.set(exportStartAtom, 0); + store.set(exportEndAtom, 0); +} + +export function saveChatState(state: StoredChatSettings): void { + // Avoid persisting frequently-changing unread counts. + const pageById = Object.fromEntries( + Object.entries(state.pageById).map(([id, page]) => [ + id, + { + ...page, + unreadCount: 0, + }, + ]), + ); + + storage.set('chat-state', { + ...state, + pageById, + }); +} diff --git a/tgui/packages/tgui-panel/chat/index.ts b/tgui/packages/tgui-panel/chat/index.ts deleted file mode 100644 index d5ad6fa921..0000000000 --- a/tgui/packages/tgui-panel/chat/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export { ChatPageSettings } from './ChatPageSettings'; -export { ChatPanel } from './ChatPanel'; -export { ChatTabs } from './ChatTabs'; -export { chatMiddleware } from './middleware'; -export { chatReducer } from './reducer'; diff --git a/tgui/packages/tgui-panel/chat/messageNode.ts b/tgui/packages/tgui-panel/chat/messageNode.ts new file mode 100644 index 0000000000..8170eed01e --- /dev/null +++ b/tgui/packages/tgui-panel/chat/messageNode.ts @@ -0,0 +1,5 @@ +export function createMessageNode(): HTMLElement { + const node = document.createElement('div'); + node.className = 'ChatMessage'; + return node; +} diff --git a/tgui/packages/tgui-panel/chat/middleware.ts b/tgui/packages/tgui-panel/chat/middleware.ts deleted file mode 100644 index 2293ebf65a..0000000000 --- a/tgui/packages/tgui-panel/chat/middleware.ts +++ /dev/null @@ -1,412 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import type { Action, Store } from 'common/redux'; -import { storage } from 'common/storage'; -import DOMPurify from 'dompurify'; - -import { selectGame } from '../game/selectors'; -import { - addHighlightSetting, - importSettings, - loadSettings, - removeHighlightSetting, - updateHighlightSetting, - updateSettings, - updateToggle, -} from '../settings/actions'; -import { blacklisted_tags } from '../settings/constants'; -import { selectSettings } from '../settings/selectors'; -import { - addChatPage, - changeChatPage, - changeScrollTracking, - clearChat, - getChatData, - loadChat, - moveChatPageLeft, - moveChatPageRight, - purgeChatMessageArchive, - rebuildChat, - removeChatPage, - saveChatToDisk, - toggleAcceptedType, - updateMessageCount, -} from './actions'; -import { createMessage, serializeMessage } from './model'; -import { chatRenderer } from './renderer'; -import { selectChat, selectCurrentChatPage } from './selectors'; -import type { message } from './types'; - -// List of blacklisted tags -let storedRounds: number[] = []; -let storedLines: number[] = []; - -const saveChatToStorage = async (store: Store>) => { - const game = selectGame(store.getState()); - const state = selectChat(store.getState()); - const settings = selectSettings(store.getState()); - storage.set('chat-state', state); - if (!game.databaseBackendEnabled) { - const fromIndex = Math.max( - 0, - chatRenderer.messages.length - settings.persistentMessageLimit, - ); - const messages = chatRenderer.messages - .slice(fromIndex) - .map((message) => serializeMessage(message)); - storage.set('chat-messages', messages); - storage.set( - 'chat-messages-archive', - chatRenderer.archivedMessages.map((message) => serializeMessage(message)), - ); - } // FIXME: Better chat history -}; - -const loadChatFromStorage = async (store: Store>) => { - const [state, messages, archivedMessages] = await Promise.all([ - storage.get('chat-state'), - storage.get('chat-messages'), - storage.get('chat-messages-archive'), // FIXME: Better chat history - ]); - // Discard incompatible versions - if (state && state.version <= 4) { - store.dispatch(loadChat()); - return; - } - if (messages) { - for (const message of messages) { - if (message.html) { - message.html = DOMPurify.sanitize(message.html, { - FORBID_TAGS: blacklisted_tags, - }); - } - } - const batch = [ - ...messages, - createMessage({ - type: 'internal/reconnected', - }), - ]; - chatRenderer.processBatch(batch, { - prepend: true, - }); - } - if (archivedMessages) { - for (const archivedMessage of archivedMessages as message[]) { - if (archivedMessage.html) { - archivedMessage.html = DOMPurify.sanitize(archivedMessage.html, { - FORBID_TAGS: blacklisted_tags, - }); - } - } - const settings = selectSettings(store.getState()); - - // Checks if the setting is actually set or set to -1 (infinite) - // Otherwise make it grow infinitely - if (settings.logRetainRounds) { - storedRounds = []; - storedLines = []; - let oldId: number | null = null; - let currentLine: number = 0; - settings.storedRounds = 0; - settings.exportStart = 0; - settings.exportEnd = 0; - - for (const message of archivedMessages as message[]) { - const currentId = message.roundId || 0; - if (currentId !== oldId) { - const round = currentId; - const line = currentLine; - storedRounds.push(round || 0); - storedLines.push(line); - oldId = currentId; - currentLine++; - } - } - if (storedRounds.length > settings.logRetainRounds) { - chatRenderer.archivedMessages = archivedMessages.slice( - storedLines[storedRounds.length - settings.logRetainRounds], - ); - settings.storedRounds = settings.logRetainRounds; - } else { - chatRenderer.archivedMessages = archivedMessages; - } - settings.lastId = oldId; - } else { - chatRenderer.archivedMessages = archivedMessages; - } - } - store.dispatch(loadChat(state)); -}; - -const loadChatFromDBStorage = async ( - store: Store>, - user_payload: { ckey: string; token: string }, -) => { - const game = selectGame(store.getState()); - const settings = selectSettings(store.getState()); - const [state] = await Promise.all([storage.get('chat-state')]); - // Discard incompatible versions - if (state && state.version <= 4) { - store.dispatch(loadChat()); - return; - } - - const messages: message[] = []; // FIX ME, load from DB, first load has errors => check console - - // Thanks for inventing async/await - await new Promise((resolve) => { - fetch( - `${game.chatlogApiEndpoint}/api/logs/${user_payload.ckey}/${settings.persistentMessageLimit}`, - { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${user_payload.token}`, - 'Content-Type': 'application/json', - }, - }, - ) - .then((response) => response.json()) - .then((json) => { - json.forEach( - (obj: { - msg_type: string | null; - text_raw: string; - created_at: number; - round_id: number; - }) => { - const msg: message = { - type: obj.msg_type ? obj.msg_type : '', - html: obj.text_raw, - createdAt: obj.created_at, - roundId: obj.round_id, - }; - - messages.push(msg); - }, - ); - - if (messages) { - for (const message of messages) { - if (message.html) { - message.html = DOMPurify.sanitize(message.html, { - FORBID_TAGS: blacklisted_tags, - }); - } - } - const batch = [ - ...messages, - createMessage({ - type: 'internal/reconnected', - }), - ]; - chatRenderer.processBatch(batch, { - prepend: true, - }); - } - - store.dispatch(loadChat(state)); - resolve(); - }) - .catch(() => { - store.dispatch(loadChat(state)); - resolve(); - }); - }); -}; - -export const chatMiddleware = (store) => { - let initialized = false; - let loaded = false; - let needsUpdate = true; - const sequences: number[] = []; - const sequences_requested: number[] = []; - chatRenderer.events.on('batchProcessed', (countByType) => { - // Use this flag to workaround unread messages caused by - // loading them from storage. Side effect of that, is that - // message count can not be trusted, only unread count. - if (loaded) { - store.dispatch(updateMessageCount(countByType)); - } - }); - chatRenderer.events.on('scrollTrackingChanged', (scrollTracking) => { - store.dispatch(changeScrollTracking(scrollTracking)); - }); - return (next) => (action) => { - const { type, payload } = action; - const settings = selectSettings(store.getState()); - const game = selectGame(store.getState()); - settings.totalStoredMessages = chatRenderer.getStoredMessages(); - settings.storedRounds = storedRounds.length; - chatRenderer.setVisualChatLimits( - settings.visibleMessageLimit, - settings.combineMessageLimit, - settings.combineIntervalLimit, - settings.logEnable, - settings.logLimit, - settings.storedTypes, - game.roundId, - settings.prependTimestamps, - settings.hideImportantInAdminTab, - settings.interleave, - settings.interleaveColor, - game.databaseBackendEnabled, - settings.ttsVoice, - settings.ttsCategories, - ); - // Load the chat once settings are loaded - if (!initialized && settings.initialized) { - initialized = true; - setInterval(() => { - if (!game.databaseBackendEnabled || needsUpdate) { - saveChatToStorage(store); - needsUpdate = false; - } - }, settings.saveInterval * 1000); - // loadChatFromStorage(store); - } - if (type === 'chat/message') { - let payload_obj; - try { - payload_obj = JSON.parse(payload); - } catch (err) { - return; - } - - const sequence = payload_obj.sequence; - if (sequences.includes(sequence)) { - return; - } - - const sequence_count = sequences.length; - if (sequence_count > 0) { - if (sequences_requested.includes(sequence)) { - sequences_requested.splice(sequences_requested.indexOf(sequence), 1); - // if we are receiving a message we requested, we can stop reliability checks - } else { - // cannot do reliability if we don't have any messages - const expected_sequence = sequences[sequence_count - 1] + 1; - if (sequence !== expected_sequence) { - for ( - let requesting = expected_sequence; - requesting < sequence; - requesting++ - ) { - sequences_requested.push(requesting); - Byond.sendMessage('chat/resend', requesting); - } - } - } - } - - chatRenderer.processBatch([payload_obj.content], { - doArchive: true, - }); - sequences.push(sequence); - if (game.roundId !== settings.lastId) { - storedRounds.push(game.roundId); - storedLines.push(settings.totalStoredMessages - 1); - settings.lastId = game.roundId; - } - return; - } - if (type === loadChat.type) { - next(action); - const page = selectCurrentChatPage(store.getState()); - chatRenderer.changePage(page); - chatRenderer.onStateLoaded(); - loaded = true; - return; - } - if ( - type === changeChatPage.type || - type === addChatPage.type || - type === removeChatPage.type || - type === toggleAcceptedType.type || - type === moveChatPageLeft.type || - type === moveChatPageRight.type - ) { - next(action); - const page = selectCurrentChatPage(store.getState()); - chatRenderer.changePage(page); - needsUpdate = true; - return; - } - if (type === rebuildChat.type) { - chatRenderer.rebuildChat(settings.visibleMessages); - return next(action); - } - - if ( - type === updateSettings.type || - type === updateToggle.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, - ); - needsUpdate = true; - - return; - } - if (type === 'roundrestart') { - // Save chat as soon as possible - saveChatToStorage(store); - return next(action); - } - if (type === 'saveToDiskCommand') { - chatRenderer.saveToDisk(settings.logLineCount); - return; - } - if (type === saveChatToDisk.type) { - chatRenderer.saveToDisk( - settings.logLineCount, - storedLines[storedLines.length - settings.exportEnd], - storedLines[storedLines.length - settings.exportStart], - settings.exportStart, - settings.exportEnd, - ); - return; - } - if (type === clearChat.type) { - chatRenderer.clearChat(); - return; - } - if (type === purgeChatMessageArchive.type) { - chatRenderer.purgeMessageArchive(); - storedRounds = []; - storedLines = []; - settings.lastId = null; - settings.storedRounds = 0; - settings.exportStart = 0; - settings.exportEnd = 0; - return; - } - if (type === 'exportDownloadReady') { - const event = new Event('chatexportplaced'); - document.dispatchEvent(event); - } - if (type === getChatData.type) { - const user_payload = payload; - if (payload.token) { - loadChatFromDBStorage(store, user_payload); - } else { - loadChatFromStorage(store); - } - return; - } - return next(action); - }; -}; diff --git a/tgui/packages/tgui-panel/chat/migration.ts b/tgui/packages/tgui-panel/chat/migration.ts new file mode 100644 index 0000000000..7cd3112daa --- /dev/null +++ b/tgui/packages/tgui-panel/chat/migration.ts @@ -0,0 +1,209 @@ +import { storage } from 'common/storage'; +import { store } from '../events/store'; +import { + chatPagesAtom, + chatPagesRecordAtom, + currentPageIdAtom, + mainPage, + versionAtom, +} from './atoms'; +import { saveChatState } from './helpers'; +import { chatRenderer } from './renderer'; +import { + type Page, + type StoredChatSettings, + storedSettingsSchema, +} from './types'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizePageById(input: Record): { + pageById: Record; + idRemap: Record; + dirty: boolean; +} { + const pageById: Record = {}; + const idRemap: Record = {}; + let dirty = false; + + // Collapse duplicates and ensure keys match `page.id`. + for (const [key, rawPage] of Object.entries(input)) { + if (!isRecord(rawPage)) { + dirty = true; + continue; + } + + const page = rawPage as unknown as Page; + const pageId = typeof page.id === 'string' ? page.id : key; + + // Treat anything marked `isMain` or using the main id as the main page. + const desiredId = + page.isMain || pageId === mainPage.id || key === mainPage.id + ? mainPage.id + : pageId; + + if (desiredId !== key) { + idRemap[key] = desiredId; + dirty = true; + } + + // If the object claims a different id than its destination key, fix it. + const normalized: Page = { + ...page, + id: desiredId, + isMain: desiredId === mainPage.id ? true : page.isMain, + }; + + // If a duplicate exists, prefer the entry already under the correct key. + if (pageById[desiredId]) { + // Prefer whichever came from the correct key (stability). + const existingFromCorrectKey = desiredId in input; + const currentIsCorrectKey = key === desiredId; + if (currentIsCorrectKey && !existingFromCorrectKey) { + pageById[desiredId] = normalized; + } + dirty = true; + continue; + } + + pageById[desiredId] = normalized; + } + + return { pageById, idRemap, dirty }; +} + +function createOrderedPages( + storedPages: string[], + pageById: Record, +): { pages: string[]; dirty: boolean } { + const pageIds = new Set(Object.keys(pageById)); + const pages: string[] = []; + const seen = new Set(); + let dirty = false; + + for (const id of storedPages) { + if (!pageIds.has(id) || seen.has(id)) { + dirty = true; + continue; + } + seen.add(id); + pages.push(id); + } + + for (const id of Object.keys(pageById)) { + if (seen.has(id)) continue; + seen.add(id); + pages.push(id); + dirty = true; + } + + return { pages, dirty }; +} + +export function normalizeChatSettings(loaded: StoredChatSettings): { + settings: StoredChatSettings; + dirty: boolean; +} { + let dirty = false; + + // `pageById` is the source of truth. + const { + pageById: draft, + idRemap, + dirty: pageByIdDirty, + } = normalizePageById(loaded.pageById); + dirty ||= pageByIdDirty; + + if (!draft[mainPage.id]) { + draft[mainPage.id] = { ...mainPage }; + dirty = true; + } + + // Remap `pages` according to any id changes. + const remappedPages = loaded.pages.map((id) => idRemap[id] ?? id); + if (remappedPages.some((id, idx) => id !== loaded.pages[idx])) { + dirty = true; + } + + const { pages, dirty: pagesDirty } = createOrderedPages(remappedPages, draft); + dirty ||= pagesDirty; + + // Ensures at least one page exists. + const normalizedPages = pages.length ? pages : [mainPage.id]; + if (!pages.length) dirty = true; + + let currentPageId = idRemap[loaded.currentPageId] ?? loaded.currentPageId; + if (currentPageId !== loaded.currentPageId) dirty = true; + if (!draft[currentPageId]) { + currentPageId = normalizedPages[0]; + dirty = true; + } + + // Normalize per-page state. This is the canonical place where we + // compensate for older/broken stored settings. + const defaultFilters = mainPage.acceptedTypes; + for (const id of normalizedPages) { + const page = draft[id]; + if (!page) { + dirty = true; + continue; + } + + if (!page.acceptedTypes || typeof page.acceptedTypes !== 'object') { + page.acceptedTypes = {}; + dirty = true; + } + + for (const type of Object.keys(defaultFilters)) { + if (page.acceptedTypes[type] === undefined) { + page.acceptedTypes[type] = defaultFilters[type]; + dirty = true; + } + } + + // Avoid persisting and restoring unread counts. + if (page.unreadCount !== 0) { + page.unreadCount = 0; + dirty = true; + } + } + + return { + settings: { + ...loaded, + pages: normalizedPages, + currentPageId, + pageById: draft, + }, + dirty, + }; +} + +export function startChatStateMigration(state: StoredChatSettings): void { + const parsed = storedSettingsSchema.safeParse(state); + if (!parsed.success) { + console.error('Failed to parse stored chat settings:', parsed.error); + return; + } + const { settings: loaded, dirty: wasInconsistent } = normalizeChatSettings( + parsed.data, + ); + + if (wasInconsistent) { + console.error('Chat settings were inconsistent, rewriting to storage'); + console.log('Comparison, stored vs update:', state, loaded); + storage.set('chat-state', loaded); + } + + store.set(versionAtom, loaded.version); + store.set(chatPagesAtom, loaded.pages); + store.set(currentPageIdAtom, loaded.currentPageId); + store.set(chatPagesRecordAtom, loaded.pageById); + + chatRenderer.changePage(loaded.pageById[loaded.currentPageId]); + + saveChatState(loaded); + console.log('Restored chat settings:', loaded); +} diff --git a/tgui/packages/tgui-panel/chat/model.ts b/tgui/packages/tgui-panel/chat/model.ts index dbe04fadfe..928049839b 100644 --- a/tgui/packages/tgui-panel/chat/model.ts +++ b/tgui/packages/tgui-panel/chat/model.ts @@ -7,12 +7,13 @@ import { createUuid } from 'tgui-core/uuid'; import { MESSAGE_TYPE_INTERNAL, MESSAGE_TYPES } from './constants'; -import type { message, NewPageData, Page } from './types'; +import type { Page, SerializedMessage } from './types'; -export const canPageAcceptType = (page: Page, type: string): string | boolean => - type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type]; +export function canPageAcceptType(page: Page, type: string): boolean { + return type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type]; +} -export const typeIsImportant = (type: string): boolean => { +export function typeIsImportant(type: string): boolean { let isImportant = false; for (const typeDef of MESSAGE_TYPES) { if (typeDef.type === type && !!typeDef.important) { @@ -21,9 +22,9 @@ export const typeIsImportant = (type: string): boolean => { } } return isImportant; -}; +} -export const adminPageOnly = (page: Page): boolean => { +export function adminPageOnly(page: Page): boolean { let adminTab = true; let checked = 0; for (const typeDef of MESSAGE_TYPES) { @@ -39,14 +40,16 @@ export const adminPageOnly = (page: Page): boolean => { } } return checked > 0 && adminTab; -}; +} -export const canStoreType = ( - storedTypes: Record, +export function canStoreType( + storedTypes: Record, type: string, -) => storedTypes[type]; +) { + return storedTypes[type]; +} -export const createPage = (obj?: NewPageData): Page => { +export function createPage(obj: Record = {}): Page { const acceptedTypes = {}; for (const typeDef of MESSAGE_TYPES) { @@ -57,36 +60,40 @@ export const createPage = (obj?: NewPageData): Page => { isMain: false, id: createUuid(), name: 'New Tab', - acceptedTypes: acceptedTypes, + acceptedTypes, unreadCount: 0, hideUnreadCount: false, createdAt: Date.now(), ...obj, }; -}; +} -export const createMainPage = (): Page => { - const acceptedTypes: Record = {}; +export function createMainPage(): Page { + const acceptedTypes = {}; for (const typeDef of MESSAGE_TYPES) { acceptedTypes[typeDef.type] = true; } return createPage({ + id: 'main', isMain: true, name: 'Main', acceptedTypes, }); -}; +} -export const createMessage = (payload: { type: string }): message => ({ - ...payload, - createdAt: Date.now(), - roundId: null, -}); +export function createMessage( + payload: Record, +): SerializedMessage { + return { + createdAt: Date.now(), + ...payload, + } as SerializedMessage; +} -export const serializeMessage = ( - message: message, +export function serializeMessage( + message: SerializedMessage, archive = false, -): message => { +): SerializedMessage { let archiveM = ''; if (archive && message.node && typeof message.node !== 'string') { archiveM = message.node.outerHTML.replace(/(?:\r\n|\r|\n)/g, '
'); @@ -99,8 +106,14 @@ export const serializeMessage = ( createdAt: message.createdAt, roundId: message.roundId, }; -}; +} -export const isSameMessage = (a: message, b: message): boolean => - (typeof a.text === 'string' && a.text === b.text) || - (typeof a.html === 'string' && a.html === b.html); +export function isSameMessage( + a: SerializedMessage, + b: SerializedMessage, +): boolean { + return ( + (typeof a.text === 'string' && a.text === b.text) || + (typeof a.html === 'string' && a.html === b.html) + ); +} diff --git a/tgui/packages/tgui-panel/chat/reducer.ts b/tgui/packages/tgui-panel/chat/reducer.ts deleted file mode 100644 index bf33554fce..0000000000 --- a/tgui/packages/tgui-panel/chat/reducer.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { importSettings } from '../settings/actions'; -import { - addChatPage, - changeChatPage, - changeScrollTracking, - loadChat, - moveChatPageLeft, - moveChatPageRight, - removeChatPage, - toggleAcceptedType, - updateChatPage, - updateMessageCount, -} from './actions'; -import { canPageAcceptType, createMainPage } from './model'; -import type { Page } from './types'; - -const mainPage = createMainPage(); - -export const initialState = { - version: 5, - currentPageId: mainPage.id, - scrollTracking: true, - pages: [mainPage.id], - pageById: { - [mainPage.id]: mainPage, - }, -}; - -export const chatReducer = (state = initialState, action) => { - const { type, payload } = action; - if (type === loadChat.type) { - // Validate version and/or migrate state - if (payload?.version !== state.version) { - return state; - } - // Enable any filters that are not explicitly set, that are - // enabled by default on the main page. - // NOTE: This mutates acceptedTypes on the state. - for (const id of Object.keys(payload.pageById)) { - const page = payload.pageById[id]; - const filters = page.acceptedTypes; - const defaultFilters = mainPage.acceptedTypes; - for (const type of Object.keys(defaultFilters)) { - if (filters[type] === undefined) { - filters[type] = defaultFilters[type]; - } - } - } - // Reset page message counts - // NOTE: We are mutably changing the payload on the assumption - // that it is a copy that comes straight from the web storage. - for (const id of Object.keys(payload.pageById)) { - const page = payload.pageById[id]; - page.unreadCount = 0; - } - return { - ...state, - ...payload, - }; - } - if (type === changeScrollTracking.type) { - const scrollTracking = payload; - const nextState = { - ...state, - scrollTracking, - }; - if (scrollTracking) { - const pageId = state.currentPageId; - const page = { - ...state.pageById[pageId], - unreadCount: 0, - }; - nextState.pageById = { - ...state.pageById, - [pageId]: page, - }; - } - return nextState; - } - if (type === updateMessageCount.type) { - const countByType = payload; - const pages = state.pages.map((id) => state.pageById[id]); - const currentPage = state.pageById[state.currentPageId]; - const nextPageById = { ...state.pageById }; - for (const page of pages) { - let unreadCount = 0; - for (const type of Object.keys(countByType)) { - // Message does not belong here - if (!canPageAcceptType(page, type)) { - continue; - } - // Current page is scroll tracked - if (page === currentPage && state.scrollTracking) { - continue; - } - // This page received the same message which we can read - // on the current page. - if (page !== currentPage && canPageAcceptType(currentPage, type)) { - continue; - } - unreadCount += countByType[type]; - } - if (unreadCount > 0) { - nextPageById[page.id] = { - ...page, - unreadCount: page.unreadCount + unreadCount, - }; - } - } - return { - ...state, - pageById: nextPageById, - }; - } - if (type === addChatPage.type) { - return { - ...state, - currentPageId: payload.id, - pages: [...state.pages, payload.id], - pageById: { - ...state.pageById, - [payload.id]: payload, - }, - }; - } - if (type === importSettings.type) { - const pagesById: Record[] = payload.newPages; - if (!pagesById) { - return state; - } - const newPageIds: string[] = Object.keys(pagesById); - if (!newPageIds) { - return state; - } - - const nextState = { - ...state, - currentPageId: newPageIds[0], - pages: [...newPageIds], - pageById: { ...pagesById }, - }; - return nextState; - } - if (type === changeChatPage.type) { - const { pageId } = payload; - const page = { - ...state.pageById[pageId], - unreadCount: 0, - }; - return { - ...state, - currentPageId: pageId, - pageById: { - ...state.pageById, - [pageId]: page, - }, - }; - } - if (type === updateChatPage.type) { - const { pageId, ...update } = payload; - const page = { - ...state.pageById[pageId], - ...update, - }; - return { - ...state, - pageById: { - ...state.pageById, - [pageId]: page, - }, - }; - } - if (type === toggleAcceptedType.type) { - const { pageId, type } = payload; - const page = { ...state.pageById[pageId] }; - page.acceptedTypes = { ...page.acceptedTypes }; - page.acceptedTypes[type] = !page.acceptedTypes[type]; - return { - ...state, - pageById: { - ...state.pageById, - [pageId]: page, - }, - }; - } - if (type === removeChatPage.type) { - const { pageId } = payload; - const nextState = { - ...state, - pages: [...state.pages], - pageById: { - ...state.pageById, - }, - }; - delete nextState.pageById[pageId]; - nextState.pages = nextState.pages.filter((id) => id !== pageId); - if (nextState.pages.length === 0) { - nextState.pages.push(mainPage.id); - nextState.pageById[mainPage.id] = mainPage; - nextState.currentPageId = mainPage.id; - } - if (!nextState.currentPageId || nextState.currentPageId === pageId) { - nextState.currentPageId = nextState.pages[0]; - } - return nextState; - } - if (type === moveChatPageLeft.type) { - const { pageId } = payload; - const nextState = { - ...state, - pages: [...state.pages], - pageById: { - ...state.pageById, - }, - }; - const tmpPage = nextState.pageById[pageId]; - const fromIndex = nextState.pages.indexOf(tmpPage.id); - const toIndex = fromIndex - 1; - // don't ever move leftmost page - if (fromIndex > 0) { - // don't ever move anything to the leftmost page - if (toIndex > 0) { - const tmp = nextState.pages[fromIndex]; - nextState.pages[fromIndex] = nextState.pages[toIndex]; - nextState.pages[toIndex] = tmp; - } - } - return nextState; - } - - if (type === moveChatPageRight.type) { - const { pageId } = payload; - const nextState = { - ...state, - pages: [...state.pages], - pageById: { - ...state.pageById, - }, - }; - const tmpPage = nextState.pageById[pageId]; - const fromIndex = nextState.pages.indexOf(tmpPage.id); - const toIndex = fromIndex + 1; - // don't ever move leftmost page - if (fromIndex > 0) { - // don't ever move anything out of the array - if (toIndex < nextState.pages.length) { - const tmp = nextState.pages[fromIndex]; - nextState.pages[fromIndex] = nextState.pages[toIndex]; - nextState.pages[toIndex] = tmp; - } - } - return nextState; - } - return state; -}; diff --git a/tgui/packages/tgui-panel/chat/renderer.tsx b/tgui/packages/tgui-panel/chat/renderer.tsx index f91d972d2e..0b2a82554f 100644 --- a/tgui/packages/tgui-panel/chat/renderer.tsx +++ b/tgui/packages/tgui-panel/chat/renderer.tsx @@ -5,13 +5,12 @@ */ import { createRoot } from 'react-dom/client'; -import { useSelector } from 'tgui/backend'; import { createLogger } from 'tgui/logging'; import { Tooltip } from 'tgui-core/components'; import { EventEmitter } from 'tgui-core/events'; import { classes } from 'tgui-core/react'; - -import { selectSettings } from '../settings/selectors'; +import { store } from '../events/store'; +import { scrollTrackingAtom } from './atoms'; import { exportToDisk } from './chatExport'; import { IMAGE_RETRY_DELAY, @@ -22,6 +21,7 @@ import { MESSAGE_TYPE_UNKNOWN, MESSAGE_TYPES, } from './constants'; +import { createMessageNode } from './messageNode'; import { adminPageOnly, canPageAcceptType, @@ -32,7 +32,7 @@ import { typeIsImportant, } from './model'; import { highlightNode, linkifyNode } from './replaceInTextNode'; -import type { message, Page } from './types'; +import type { Page, SerializedMessage } from './types'; const logger = createLogger('chatRenderer'); @@ -52,40 +52,19 @@ export const TGUI_CHAT_ATTRIBUTES_TO_PROPS = { content: 'content', }; -const findNearestScrollableParent = (startingNode: HTMLElement) => { - const body = document.body; - let node = startingNode; - while (node && node !== body) { - // This definitely has a vertical scrollbar, because it reduces - // scrollWidth of the element. Might not work if element uses - // overflow: hidden. - if (node.scrollWidth < node.offsetWidth) { - return node; - } - node = node.parentNode as HTMLElement; - } - return window; -}; - -const createHighlightNode = (text: string, color: string): HTMLElement => { +function createHighlightNode(text: string, color: string): HTMLElement { const node = document.createElement('span'); node.className = 'Chat__highlight'; node.setAttribute('style', `background-color:${color}`); node.textContent = text; return node; -}; +} -export const createMessageNode = (): HTMLElement => { - const node = document.createElement('div'); - node.className = 'ChatMessage'; - return node; -}; - -const interleaveMessage = ( +function interleaveMessage( node: HTMLElement, interleave: boolean, color: string, -): HTMLElement => { +): HTMLElement { if (interleave) { node.setAttribute('style', `background-color:${color}`); node.setAttribute('display', 'block'); @@ -94,20 +73,20 @@ const interleaveMessage = ( node.removeAttribute('display'); } return node; -}; +} -const stripNewLineFlood = (text: string): string => { +function stripNewLineFlood(text: string): string { text = text.replace(/((\n)\2{2})\2+/g, '$1'); return text; -}; +} -const createReconnectedNode = (): HTMLElement => { +function createReconnectedNode(): HTMLElement { const node = document.createElement('div'); node.className = 'Chat__reconnected'; return node; -}; +} -const getChatTimestamp = (message: message): string => { +function getChatTimestamp(message: SerializedMessage): string { let stamp = ''; if (message.createdAt) { const dateTime = new Date(message.createdAt); @@ -120,9 +99,9 @@ const getChatTimestamp = (message: message): string => { '] '; } return stamp; -}; +} -const handleImageError = (e: ErrorEvent) => { +function handleImageError(e: ErrorEvent) { setTimeout(() => { /** @type {HTMLImageElement} */ const node = e.target as HTMLImageElement; @@ -140,12 +119,12 @@ const handleImageError = (e: ErrorEvent) => { node.src = `${src}#${attempts}`; node.setAttribute('data-reload-n', (attempts + 1).toString()); }, IMAGE_RETRY_DELAY); -}; +} /** * Assigns a "times-repeated" badge to the message. */ -const updateMessageBadge = (message: message) => { +function updateMessageBadge(message: SerializedMessage) { const { node, times } = message; if (!node || !times || typeof node === 'string') { // Nothing to update @@ -161,15 +140,15 @@ const updateMessageBadge = (message: message) => { if (!foundBadge) { node.appendChild(badge); } -}; +} class ChatRenderer { loaded: boolean; rootNode: HTMLElement | null; - queue: message[]; - messages: message[]; - archivedMessages: message[]; - visibleMessages: message[]; + queue: SerializedMessage[]; + messages: SerializedMessage[]; + archivedMessages: SerializedMessage[]; + visibleMessages: SerializedMessage[]; page: Page | null; events: EventEmitter; prependTimestamps: boolean; @@ -180,7 +159,7 @@ class ChatRenderer { logLimit: number; logEnable: boolean; roundId: null | number; - storedTypes: Record; + storedTypes: Record; interleave: boolean; interleaveEnabled: boolean; interleaveColor: string; @@ -205,9 +184,7 @@ class ChatRenderer { ttsCategories: Record; constructor() { - /** @type {HTMLElement} */ this.loaded = false; - /** @type {HTMLElement} */ this.rootNode = null; this.queue = []; this.messages = []; @@ -230,11 +207,10 @@ class ChatRenderer { this.hideImportantInAdminTab = false; this.databaseBackendEnabled = false; // Scroll handler - /** @type {HTMLElement} */ this.scrollNode = null; this.scrollTracking = true; this.lastScrollHeight = 0; - this.handleScroll = (type) => { + this.handleScroll = (evt) => { const node = this.scrollNode; if (!node) { return; @@ -246,15 +222,10 @@ class ChatRenderer { this.lastScrollHeight === 0; if (scrollTracking !== this.scrollTracking) { this.scrollTracking = scrollTracking; - this.events.emit('scrollTrackingChanged', scrollTracking); + store.set(scrollTrackingAtom, scrollTracking); logger.debug('tracking', this.scrollTracking); } }; - this.ensureScrollTracking = () => { - if (this.scrollTracking) { - this.scrollToBottom(); - } - }; // Periodic message pruning setInterval(() => this.pruneMessages(), MESSAGE_PRUNE_INTERVAL); } @@ -288,8 +259,8 @@ class ChatRenderer { this.processBatch(this.queue, { doArchive: doArchive }); this.queue = []; // In case we had no vaclid scroll node before + this.tryFindScrollable(); setTimeout(() => { - this.tryFindScrollable(); this.scrollToBottom(); }); } @@ -308,7 +279,7 @@ class ChatRenderer { if (!highlightSettings) { return; } - highlightSettings.map((id) => { + highlightSettings.forEach((id) => { const setting = highlightSettingById[id]; const text = setting.highlightText; const blacklist = setting.blacklistText; @@ -336,7 +307,7 @@ class ChatRenderer { let highlightRegex; // Nothing to match, reset highlighting if (lines.length === 0) { - return undefined; + return; } // Reset lastIndex so it does not mess up the next word allowedRegex.lastIndex = 0; @@ -433,7 +404,6 @@ class ChatRenderer { highlightBlacklist, blacklistregex, }); - return undefined; }); } @@ -447,16 +417,8 @@ class ChatRenderer { tryFindScrollable() { // Find scrollable parent - if (this.rootNode) { - if (!this.scrollNode || this.scrollNode.scrollHeight === undefined) { - this.scrollNode = findNearestScrollableParent( - this.rootNode, - ) as HTMLElement; - if (this.scrollNode) { - this.scrollNode.addEventListener('scroll', this.handleScroll); - } - } - } + this.scrollNode = document.getElementById('chat-pane'); + this.scrollNode?.addEventListener('scroll', this.handleScroll); } setVisualChatLimits( @@ -465,7 +427,7 @@ class ChatRenderer { combineIntervalLimit: number, logEnable: boolean, logLimit: number, - storedTypes: Record, + storedTypes: Record, roundId: number | null, prependTimestamps: boolean, hideImportantInAdminTab: boolean, @@ -532,7 +494,7 @@ class ChatRenderer { } } - getCombinableMessage(predicate: message) { + getCombinableMessage(predicate: SerializedMessage) { const now = Date.now(); const len = this.visibleMessages.length; const from = len - 1; @@ -554,7 +516,7 @@ class ChatRenderer { return null; } - tryTTS(message: message, node: HTMLElement) { + tryTTS(message: SerializedMessage, node: HTMLElement) { if (this.ttsCategories[message.type]) { const utterance = new SpeechSynthesisUtterance(node.innerText); @@ -569,14 +531,13 @@ class ChatRenderer { // eslint-disable-next-line complexity processBatch( - batch: message[], + batch: SerializedMessage[], options: { prepend?: boolean; notifyListeners?: boolean; doArchive?: boolean; } = {}, ) { - const settings = useSelector(selectSettings); const { prepend, notifyListeners = true, doArchive = false } = options; const now = Date.now(); // Queue up messages until chat is ready @@ -670,13 +631,10 @@ class ChatRenderer { const reactRoot = createRoot(childNode); reactRoot.render( - <> - - {/** biome-ignore lint/security/noDangerouslySetInnerHtml: Chat rendere */} - - - {childNode} - , + + {/** biome-ignore lint/security/noDangerouslySetInnerHtml: Chat rendere */} + + , ); } @@ -930,7 +888,7 @@ class ChatRenderer { } else { // Fetch from chat storage let messagesHtml = ''; - let tmpMsgArray: message[] = []; + let tmpMsgArray: SerializedMessage[] = []; if (startLine || endLine) { if (!endLine) { tmpMsgArray = this.archivedMessages.slice(startLine); @@ -993,18 +951,11 @@ class ChatRenderer { } } -type ChatWindow = Window & - typeof globalThis & { - __chatRenderer__: ChatRenderer; - }; - // Make chat renderer global so that we can continue using the same // instance after hot code replacement. -const chatWindow = window as ChatWindow; -if (!chatWindow.__chatRenderer__) { - chatWindow.__chatRenderer__ = new ChatRenderer(); +if (!window.__chatRenderer__) { + window.__chatRenderer__ = new ChatRenderer(); } -/** @type {ChatRenderer} */ -export const chatRenderer = chatWindow.__chatRenderer__; +export const chatRenderer: ChatRenderer = window.__chatRenderer__; diff --git a/tgui/packages/tgui-panel/chat/selectors.ts b/tgui/packages/tgui-panel/chat/selectors.ts deleted file mode 100644 index ec0496bb8f..0000000000 --- a/tgui/packages/tgui-panel/chat/selectors.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export const selectChat = (state) => state.chat; - -export const selectChatPages = (state) => - state.chat.pages.map((id: string) => state.chat.pageById[id]); - -export const selectCurrentChatPage = (state) => - state.chat.pageById[state.chat.currentPageId]; - -export const selectChatPageById = (id: string) => (state) => - state.chat.pageById[id]; diff --git a/tgui/packages/tgui-panel/chat/types.ts b/tgui/packages/tgui-panel/chat/types.ts index f99a31f2d8..e4bbcf2a38 100644 --- a/tgui/packages/tgui-panel/chat/types.ts +++ b/tgui/packages/tgui-panel/chat/types.ts @@ -1,13 +1,20 @@ -export type message = { - node?: HTMLElement | string; +import * as z from 'zod'; + +export type ChatPages = { + chatPages: Record; +}; + +export type SerializedMessage = { type: string; - text?: string; - html?: string; - times?: number; createdAt: number; roundId: number | null; - avoidHighlighting?: boolean; -}; +} & Partial<{ + text: string; + html: string; + times: number; + node?: HTMLElement | string; + avoidHighlighting: boolean; +}>; export type Page = { isMain: boolean; @@ -19,8 +26,12 @@ export type Page = { createdAt: number; }; -export type NewPageData = { - isMain: boolean; - name: string; - acceptedTypes: Record; -}; +export const storedSettingsSchema = z.object({ + version: z.number(), + scrollTracking: z.boolean(), + currentPageId: z.string(), + pages: z.array(z.string()), + pageById: z.record(z.string(), z.any()), +}); + +export type StoredChatSettings = z.infer; diff --git a/tgui/packages/tgui-panel/chat/use-chat-pages.ts b/tgui/packages/tgui-panel/chat/use-chat-pages.ts new file mode 100644 index 0000000000..cde8f54f33 --- /dev/null +++ b/tgui/packages/tgui-panel/chat/use-chat-pages.ts @@ -0,0 +1,146 @@ +import { useAtom, useAtomValue } from 'jotai'; +import { + chatPagesAtom, + chatPagesRecordAtom, + currentPageAtom, + currentPageIdAtom, + mainPage, +} from './atoms'; +import { createPage } from './model'; +import { chatRenderer } from './renderer'; +import type { Page } from './types'; + +/** Custom hook for handling chat pages state */ +export function useChatPages() { + const [pages, setPages] = useAtom(chatPagesAtom); + const [pagesRecord, setPagesRecord] = useAtom(chatPagesRecordAtom); + const [currentPageId, setCurrentPageId] = useAtom(currentPageIdAtom); + const page = useAtomValue(currentPageAtom); + + function addChatPage(): void { + const draft = createPage(); + + setCurrentPageId(draft.id); + setPages((prev) => [...prev, draft.id]); + setPagesRecord((prev) => ({ + ...prev, + [draft.id]: draft, + })); + + chatRenderer.changePage(draft); + } + + function changeChatPage(page: Page): void { + setCurrentPageId(page.id); + + const draft: Page = { + ...pagesRecord[page.id], + unreadCount: 0, + }; + + setPagesRecord({ + ...pagesRecord, + [page.id]: draft, + }); + + chatRenderer.changePage(draft); + } + + function moveChatLeft(): void { + const tmpPage = pagesRecord[currentPageId]; + const fromIndex = pages.indexOf(tmpPage.id); + const toIndex = fromIndex - 1; + + // don't ever move leftmost page + if (fromIndex <= 0) return; + // don't ever move anything to the leftmost page + if (toIndex <= 0) return; + + const draftPages = [...pages]; + const tmp = draftPages[fromIndex]; + draftPages[fromIndex] = draftPages[toIndex]; + draftPages[toIndex] = tmp; + + setPages(draftPages); + } + + function moveChatRight(): void { + const tmpPage = pagesRecord[currentPageId]; + const fromIndex = pages.indexOf(tmpPage.id); + const toIndex = fromIndex + 1; + + // don't ever move leftmost page + if (fromIndex <= 0) return; + // don't ever move anything out of the array + if (toIndex >= pages.length) return; + + const draftPages = [...pages]; + const tmp = draftPages[fromIndex]; + draftPages[fromIndex] = draftPages[toIndex]; + draftPages[toIndex] = tmp; + + setPages(draftPages); + } + + function removeChatPage(): void { + const nextPages = pages.filter((id) => id !== currentPageId); + + // Always keep at least the main page + const finalPages = nextPages.length > 0 ? nextPages : [mainPage.id]; + + const finalRecord = + nextPages.length > 0 + ? (Object.fromEntries( + finalPages.map((id) => [id, pagesRecord[id]]), + ) as Record) + : { [mainPage.id]: mainPage }; + + setPagesRecord(finalRecord); + setPages(finalPages); + setCurrentPageId(finalPages[0]); + chatRenderer.changePage(finalRecord[finalPages[0]]); + } + + function toggleAcceptedType(type: string): void { + const current = { ...pagesRecord[currentPageId] }; + + const draft: Page = { + ...current, + acceptedTypes: { + ...current.acceptedTypes, + [type]: !current.acceptedTypes[type], + }, + }; + + setPagesRecord({ + ...pagesRecord, + [currentPageId]: draft, + }); + } + + function updateChatPage(page: Partial): void { + const draft: Page = { + ...pagesRecord[currentPageId], + ...page, + }; + + setPagesRecord({ + ...pagesRecord, + [currentPageId]: draft, + }); + } + + return { + page, + pages, + pagesRecord, + currentPageId, + addChatPage, + changeChatPage, + moveChatLeft, + moveChatRight, + removeChatPage, + toggleAcceptedType, + updateChatPage, + }; +} diff --git a/tgui/packages/tgui-panel/chat/use-chat-persistence.ts b/tgui/packages/tgui-panel/chat/use-chat-persistence.ts new file mode 100644 index 0000000000..40feadeeef --- /dev/null +++ b/tgui/packages/tgui-panel/chat/use-chat-persistence.ts @@ -0,0 +1,301 @@ +import { storage } from 'common/storage'; +import DOMPurify from 'dompurify'; +import { useAtom, useAtomValue } from 'jotai'; +import { useEffect } from 'react'; +import type { UserData } from 'tgui-panel/game/types'; +import { store } from '../events/store'; +import { gameAtom } from '../game/atoms'; +import { settingsAtom, settingsLoadedAtom } from '../settings/atoms'; +import { + allChatAtom, + chatLoadedAtom, + lastRoundIDAtom, + storedLinesAtom, + storedRoundsAtom, + versionAtom, +} from './atoms'; +import { rebuildRoundTracking, saveChatToStorage } from './helpers'; +import { startChatStateMigration } from './migration'; +import { createMessage } from './model'; +import { chatRenderer } from './renderer'; +import type { SerializedMessage, StoredChatSettings } from './types'; + +// List of blacklisted tags +const FORBID_TAGS = ['a', 'iframe', 'link', 'video']; + +/** + * Custom hook that initializes chat from local storage and periodically saves + * it back + */ +export function useChatPersistence() { + const version = useAtomValue(versionAtom); + const settings = useAtomValue(settingsAtom); + const allChat = useAtomValue(allChatAtom); + const game = useAtomValue(gameAtom); + + const [loaded, setLoaded] = useAtom(chatLoadedAtom); + const settingsLoaded = useAtomValue(settingsLoadedAtom); + + /** Loads chat + chat settings */ + useEffect(() => { + if (!loaded && settingsLoaded) { + async function initChatState(): Promise { + console.log('Initializing chat'); + + const [state] = await Promise.all([storage.get('chat-state')]); + + chatRenderer.setVisualChatLimits( + settings.visibleMessageLimit, + settings.combineMessageLimit, + settings.combineIntervalLimit, + settings.logEnable, + settings.logLimit, + settings.storedTypes, + game.roundId, + settings.prependTimestamps, + settings.hideImportantInAdminTab, + settings.interleave, + settings.interleaveColor, + game.databaseBackendEnabled, + settings.ttsVoice, + settings.ttsCategories, + ); + loadChatState(state); + setLoaded(true); + } + + initChatState(); + } + }, [loaded, settingsLoaded, setLoaded]); + + /** Apply user settings */ + useEffect(() => { + if (!loaded) return; + + chatRenderer.setVisualChatLimits( + settings.visibleMessageLimit, + settings.combineMessageLimit, + settings.combineIntervalLimit, + settings.logEnable, + settings.logLimit, + settings.storedTypes, + game.roundId, + settings.prependTimestamps, + settings.hideImportantInAdminTab, + settings.interleave, + settings.interleaveColor, + game.databaseBackendEnabled, + settings.ttsVoice, + settings.ttsCategories, + ); + }, [settings]); + + /** Periodically saves chat + chat settings */ + useEffect(() => { + let saveInterval: NodeJS.Timeout; + + if (loaded && settings.saveInterval) { + saveInterval = setInterval(() => { + if (!game.databaseBackendEnabled) { + saveChatToStorage(); + } + }, settings.saveInterval * 1000); + } + + return () => clearInterval(saveInterval); + }, [loaded, settings.saveInterval]); + + /** Saves chat settings shortly after any settings change */ + useEffect(() => { + let timeout: NodeJS.Timeout; + + if (loaded) { + timeout = setTimeout(() => { + // Avoid persisting frequently-changing unread counts. + const pageById = Object.fromEntries( + Object.entries(allChat.pageById).map(([id, page]) => [ + id, + { + ...page, + unreadCount: 0, + }, + ]), + ); + + storage.set('chat-state', { + ...allChat, + pageById, + }); + }, 750); + } + + return () => clearTimeout(timeout); + }, [ + loaded, + allChat.pages, + allChat.currentPageId, + allChat.pageById[allChat.currentPageId]?.name, + allChat.pageById[allChat.currentPageId]?.acceptedTypes, + allChat.pageById[allChat.currentPageId]?.hideUnreadCount, + ]); + + /** Update our round ID information */ + useEffect(() => { + if (!loaded) return; + + const lastMessage = + chatRenderer.messages.at(-1) || chatRenderer.archivedMessages.at(-1); + if (!lastMessage?.roundId) return; + + const lastId = store.get(lastRoundIDAtom); + if (lastId !== lastMessage.roundId) { + const storedRounds = store.get(storedRoundsAtom) + 1; + const storedLines = store.get(storedLinesAtom); + store.set(lastRoundIDAtom, lastMessage.roundId); + store.set(storedRoundsAtom, storedRounds); + store.set(storedLinesAtom, [ + ...storedLines, + chatRenderer.getStoredMessages() - 1, + ]); + } + }, [loaded, chatRenderer.messages.length]); + + useEffect(() => { + async function fetchChat() { + if (!loaded || !game.userData) { + return; + } + + let messages: SerializedMessage[] = []; + + if (game.userData.token) { + messages = await loadChatFromDBStorage(game.userData); + } else { + messages = await loadChatFromStorage(); + } + + if (messages) { + handleMessages(messages); + } + + chatRenderer.onStateLoaded(); + } + + fetchChat(); + }, [loaded, game.userData]); + + async function loadChatFromStorage(): Promise { + const [messages, archived] = await Promise.all([ + storage.get('chat-messages'), + storage.get('chat-messages-archive'), + ]); + + if (archived) { + for (const msg of archived) { + if (msg.html) { + msg.html = DOMPurify.sanitize(msg.html, { FORBID_TAGS }); + } + } + const { storedRounds, storedLines, lastId } = + rebuildRoundTracking(archived); + + store.set(lastRoundIDAtom, lastId); + store.set(storedRoundsAtom, storedRounds.length); + store.set(storedLinesAtom, storedLines); + + chatRenderer.archivedMessages = archived; + if ( + settings.logRetainRounds > 0 && + storedRounds.length > settings.logRetainRounds + ) { + chatRenderer.archivedMessages = chatRenderer.archivedMessages.slice( + storedLines[storedRounds.length - settings.logRetainRounds], + ); + } + } + + return messages; + } + + async function loadChatFromDBStorage( + userData: UserData, + ): Promise { + const messages: SerializedMessage[] = []; + + await new Promise((resolve) => { + fetch( + `${game.chatlogApiEndpoint}/api/logs/${userData.ckey}/${settings.persistentMessageLimit}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${userData.token}`, + 'Content-Type': 'application/json', + }, + }, + ) + .then((response) => response.json()) + .then((json) => { + json.forEach( + (obj: { + msg_type: string | null; + text_raw: string; + created_at: number; + round_id: number; + }) => { + const msg: SerializedMessage = { + type: obj.msg_type ? obj.msg_type : '', + html: obj.text_raw, + createdAt: obj.created_at, + roundId: obj.round_id, + }; + + messages.push(msg); + }, + ); + resolve(); + }) + .catch(() => { + resolve(); + }); + }); + + return messages; + } + + function loadChatState(state: StoredChatSettings | null) { + // Empty settings, set defaults + if (!state) { + console.log('Initialized chat with default settings'); + } else if (state && 'version' in state && state.version === version) { + console.log('Loaded chat state from storage:', state); + startChatStateMigration(state); + } else { + // Discard incompatible versions + console.log('Discarded incompatible chat state from storage:', state); + } + } + + function handleMessages(messages: SerializedMessage[]): void { + for (const message of messages) { + if (message.html) { + message.html = DOMPurify.sanitize(message.html, { + FORBID_TAGS, + }); + } + } + + const batch = [ + ...messages, + createMessage({ + type: 'internal/reconnected', + }), + ]; + + chatRenderer.processBatch(batch, { + prepend: true, + }); + + console.log(`Restored chat with ${messages.length} messages`); + } +} diff --git a/tgui/packages/tgui-panel/events/handlers/assets.ts b/tgui/packages/tgui-panel/events/handlers/assets.ts new file mode 100644 index 0000000000..fc814f9fd6 --- /dev/null +++ b/tgui/packages/tgui-panel/events/handlers/assets.ts @@ -0,0 +1,8 @@ +import { loadMappings } from 'common/assets'; + +const loadedMappings = {} as Record; + +/** This just lets us load in our own independent map */ +export function handleLoadAssets(payload: Record): void { + loadMappings(payload, loadedMappings); +} diff --git a/tgui/packages/tgui-panel/events/handlers/connected.ts b/tgui/packages/tgui-panel/events/handlers/connected.ts new file mode 100644 index 0000000000..d1b61c8ea6 --- /dev/null +++ b/tgui/packages/tgui-panel/events/handlers/connected.ts @@ -0,0 +1,15 @@ +import type { GameConnectedPayload } from 'packages/tgui-panel/game/types'; +import { + chatlogApiEndpointAtom, + databaseBackendEnabledAtom, + databaseStoredRoundsAtom, + roundIdAtom, +} from '../../game/atoms'; +import { store } from '../store'; + +export function connected(payload: GameConnectedPayload) { + store.set(roundIdAtom, payload.round_id); + store.set(databaseBackendEnabledAtom, payload.chatlog_db_backend); + store.set(chatlogApiEndpointAtom, payload.chatlog_api_endpoint); + store.set(databaseStoredRoundsAtom, payload.chatlog_stored_rounds); +} diff --git a/tgui/packages/tgui-panel/events/handlers/roundrestart.ts b/tgui/packages/tgui-panel/events/handlers/roundrestart.ts new file mode 100644 index 0000000000..1bba7c1a12 --- /dev/null +++ b/tgui/packages/tgui-panel/events/handlers/roundrestart.ts @@ -0,0 +1,8 @@ +import { saveChatToStorage } from '../../chat/helpers'; +import { roundRestartedAtAtom } from '../../game/atoms'; +import { store } from '../store'; + +export function roundrestart() { + store.set(roundRestartedAtAtom, Date.now()); + saveChatToStorage(); +} diff --git a/tgui/packages/tgui-panel/events/listeners.ts b/tgui/packages/tgui-panel/events/listeners.ts new file mode 100644 index 0000000000..797aaf96b6 --- /dev/null +++ b/tgui/packages/tgui-panel/events/listeners.ts @@ -0,0 +1,30 @@ +import { loadStyleSheet } from 'common/assets'; +import { EventBus } from 'tgui-core/eventbus'; +import { playMusic, stopMusic } from '../audio/handlers'; +import { chatMessage } from '../chat/handlers'; +import { pingReply, pingSoft } from '../ping/handlers'; +import { + handleTelemetryData, + telemetryRequest, + testTelemetryCommand, +} from '../telemetry/handlers'; +import { handleLoadAssets } from './handlers/assets'; +import { connected } from './handlers/connected'; +import { roundrestart } from './handlers/roundrestart'; + +const listeners = { + 'asset/stylesheet': loadStyleSheet, + 'asset/mappings': handleLoadAssets, + 'audio/playMusic': playMusic, + 'audio/stopMusic': stopMusic, + 'chat/message': chatMessage, + connected, + 'ping/reply': pingReply, + 'ping/soft': pingSoft, + roundrestart, + 'telemetry/request': telemetryRequest, + testTelemetryCommand, + update: handleTelemetryData, +} as const; + +export const bus = new EventBus(listeners); diff --git a/tgui/packages/tgui-panel/events/store.ts b/tgui/packages/tgui-panel/events/store.ts new file mode 100644 index 0000000000..09a7aa5b39 --- /dev/null +++ b/tgui/packages/tgui-panel/events/store.ts @@ -0,0 +1,3 @@ +import { createStore } from 'jotai'; + +export const store = createStore(); diff --git a/tgui/packages/tgui-panel/game/actions.ts b/tgui/packages/tgui-panel/game/actions.ts deleted file mode 100644 index f012bb474c..0000000000 --- a/tgui/packages/tgui-panel/game/actions.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { createAction } from 'common/redux'; - -export const roundRestarted = createAction('roundrestart'); -export const connectionLost = createAction('game/connectionLost'); -export const connectionRestored = createAction('game/connectionRestored'); -export const dismissWarning = createAction('game/dismissWarning'); -export const updateExportData = createAction('game/updateExportData'); diff --git a/tgui/packages/tgui-panel/game/atoms.ts b/tgui/packages/tgui-panel/game/atoms.ts new file mode 100644 index 0000000000..b14ce66fc4 --- /dev/null +++ b/tgui/packages/tgui-panel/game/atoms.ts @@ -0,0 +1,52 @@ +import { atom } from 'jotai'; +import { lastPingedAtAtom } from '../ping/atoms'; +import { CONNECTION_LOST_AFTER } from './constants'; + +export const roundIdAtom = atom(null); +export const roundTimeAtom = atom(null); +export const roundRestartedAtAtom = atom(null); +export const databaseBackendEnabledAtom = atom(false); +export const chatlogApiEndpointAtom = atom(''); +export const databaseStoredRoundsAtom = atom([]); +export const userDataAtom = atom<{ ckey: string; token: string } | null>(null); + +/** + * Ticking clock atom. Only runs while something is subscribed to it. + * (And only after we’ve had at least one ping, see connectionLostAtAtom.) + */ +const nowAtom = atom(0); +nowAtom.onMount = (set) => { + set(Date.now()); + const id = setInterval(() => set(Date.now()), 1000); + return () => clearInterval(id); +}; + +/** + * Returns the timestamp at which we *became* "lost" (deadline), + * or null if we're not currently lost. + * + * Note: returns a stable value while lost, so `nowAtom` ticking won’t cause + * rerenders once lost (value stays equal). + */ +export const connectionLostAtAtom = atom((get) => { + const lastPingedAt = get(lastPingedAtAtom); + if (!lastPingedAt) return null; + + const now = get(nowAtom); + const deadline = lastPingedAt + CONNECTION_LOST_AFTER; + + return now >= deadline ? deadline : null; +}); + +//------- Convenience --------------------------------------------------------// + +export const gameAtom = atom((get) => ({ + roundId: get(roundIdAtom), + roundTime: get(roundTimeAtom), + roundRestartedAt: get(roundRestartedAtAtom), + connectionLostAt: get(connectionLostAtAtom), + databaseBackendEnabled: get(databaseBackendEnabledAtom), + chatlogApiEndpoint: get(chatlogApiEndpointAtom), + databaseStoredRounds: get(databaseStoredRoundsAtom), + userData: get(userDataAtom), +})); diff --git a/tgui/packages/tgui-panel/game/hooks.ts b/tgui/packages/tgui-panel/game/hooks.ts deleted file mode 100644 index 40a74ff44a..0000000000 --- a/tgui/packages/tgui-panel/game/hooks.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { useSelector } from 'tgui/backend'; - -import { selectGame } from './selectors'; - -export const useGame = () => { - return useSelector(selectGame); -}; diff --git a/tgui/packages/tgui-panel/game/index.ts b/tgui/packages/tgui-panel/game/index.ts deleted file mode 100644 index 3cca2e6557..0000000000 --- a/tgui/packages/tgui-panel/game/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export { useGame } from './hooks'; -export { gameMiddleware } from './middleware'; -export { gameReducer } from './reducer'; diff --git a/tgui/packages/tgui-panel/game/middleware.ts b/tgui/packages/tgui-panel/game/middleware.ts deleted file mode 100644 index 1cf80a7ac6..0000000000 --- a/tgui/packages/tgui-panel/game/middleware.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { pingSoft, pingSuccess } from '../ping/actions'; -import { connectionLost, connectionRestored, roundRestarted } from './actions'; -import { CONNECTION_LOST_AFTER } from './constants'; -import { selectGame } from './selectors'; - -const withTimestamp = (action) => ({ - ...action, - meta: { - ...action.meta, - now: Date.now(), - }, -}); - -export const gameMiddleware = (store) => { - let lastPingedAt; - - setInterval(() => { - const state = store.getState(); - if (!state) { - return; - } - const game = selectGame(state); - const pingsAreFailing = - lastPingedAt && Date.now() >= lastPingedAt + CONNECTION_LOST_AFTER; - if (!game.connectionLostAt && pingsAreFailing) { - store.dispatch(withTimestamp(connectionLost())); - } - if (game.connectionLostAt && !pingsAreFailing) { - store.dispatch(withTimestamp(connectionRestored())); - } - }, 1000); - - return (next) => (action) => { - const { type } = action; - - if (type === pingSuccess.type || type === pingSoft.type) { - lastPingedAt = Date.now(); - return next(action); - } - - if (type === roundRestarted.type) { - return next(withTimestamp(action)); - } - - return next(action); - }; -}; diff --git a/tgui/packages/tgui-panel/game/reducer.ts b/tgui/packages/tgui-panel/game/reducer.ts deleted file mode 100644 index 75ac4f2325..0000000000 --- a/tgui/packages/tgui-panel/game/reducer.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { - connectionLost, - connectionRestored, - dismissWarning, - updateExportData, -} from './actions'; -import type { gameState } from './types'; - -const initialState: gameState = { - // TODO: This is where round info should be. - roundId: null, - roundTime: null, - roundRestartedAt: null, - connectionLostAt: null, - dismissedConnectionWarning: false, - databaseBackendEnabled: false, - chatlogApiEndpoint: '', - databaseStoredRounds: [], - userData: { ckey: '', token: '' }, -}; - -export const gameReducer = (state = initialState, action) => { - const { type, meta, payload } = action; - if (type === 'roundrestart') { - return { - ...state, - roundRestartedAt: meta.now, - }; - } - if (type === 'connected') { - if (state.roundId !== payload.round_id) { - return { - ...state, - roundId: payload.round_id, - databaseBackendEnabled: payload.chatlog_db_backend, - chatlogApiEndpoint: payload.chatlog_api_endpoint, - databaseStoredRounds: payload.chatlog_stored_rounds, - }; - } - } - if (type === connectionLost.type) { - return { - ...state, - connectionLostAt: meta.now, - }; - } - if (type === connectionRestored.type) { - return { - ...state, - connectionLostAt: null, - dismissedConnectionWarning: false, - }; - } - if (type === dismissWarning.type) { - return { - ...state, - dismissedConnectionWarning: true, - }; - } - if (type === updateExportData.type) { - return { - ...state, - userData: payload, - }; - } - return state; -}; diff --git a/tgui/packages/tgui-panel/game/selectors.ts b/tgui/packages/tgui-panel/game/selectors.ts deleted file mode 100644 index a3b93b327d..0000000000 --- a/tgui/packages/tgui-panel/game/selectors.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export const selectGame = (state) => state.game; diff --git a/tgui/packages/tgui-panel/game/types.ts b/tgui/packages/tgui-panel/game/types.ts index e9084bb5ac..2c4f5e2aa0 100644 --- a/tgui/packages/tgui-panel/game/types.ts +++ b/tgui/packages/tgui-panel/game/types.ts @@ -1,11 +1,34 @@ -export type gameState = { +import type { BooleanLike } from 'tgui-core/react'; + +export type GameConnectedPayload = { + round_id: number | null; + chatlog_db_backend: boolean; + chatlog_api_endpoint: string; + chatlog_stored_rounds: string[]; +}; + +export type TelemetryUpdatePayload = { + config: { + client: { + ckey: string; + chatlog_token: string; + address: string; + computer_id: string; + }; + server: { round_id: number }; + window: { fancy: BooleanLike; locked: BooleanLike }; + }; +}; + +export type GameAtom = { roundId: number | null; roundTime: null | number; roundRestartedAt: null | number; connectionLostAt: null | number; - dismissedConnectionWarning: boolean; databaseBackendEnabled: boolean; chatlogApiEndpoint: string; databaseStoredRounds: string[]; - userData: { ckey: string; token: string }; + userData: UserData | null; }; + +export type UserData = { ckey: string; token: string }; diff --git a/tgui/packages/tgui-panel/game/use-keep-alive.ts b/tgui/packages/tgui-panel/game/use-keep-alive.ts new file mode 100644 index 0000000000..b9a0cba67f --- /dev/null +++ b/tgui/packages/tgui-panel/game/use-keep-alive.ts @@ -0,0 +1,27 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { useEffect, useRef } from 'react'; +import { lastPingedAtAtom } from '../ping/atoms'; +import { connectionLostAtAtom } from './atoms'; + +/** Custom hook that checks whether the panel is still receiving pings */ +export function useKeepAlive( + onDismissWarning: React.Dispatch>, +) { + // Ensure the derived atom (and thus the clock) is subscribed. + const lostAt = useAtomValue(connectionLostAtAtom); + const prevLostAt = useRef(null); + + // Clears stale ping timestamp across HMR/reloads to avoid a one-frame “lost” flash. + const setLastPingedAt = useSetAtom(lastPingedAtAtom); + useEffect(() => { + setLastPingedAt(null); + }, [setLastPingedAt]); + + // Fire our connection restored event + useEffect(() => { + if (prevLostAt.current !== null && lostAt === null) { + onDismissWarning(false); + } + prevLostAt.current = lostAt; + }, [lostAt]); +} diff --git a/tgui/packages/tgui-panel/index.tsx b/tgui/packages/tgui-panel/index.tsx index 17b65f926b..0041681e34 100644 --- a/tgui/packages/tgui-panel/index.tsx +++ b/tgui/packages/tgui-panel/index.tsx @@ -4,52 +4,24 @@ * @license MIT */ -// Themes import './styles/main.scss'; import './styles/themes/light.scss'; import './styles/themes/vchatlight.scss'; import './styles/themes/vchatdark.scss'; -import { perf } from 'common/perf'; -import { combineReducers } from 'common/redux'; -import { setGlobalStore } from 'tgui/backend'; -import { captureExternalLinks } from 'tgui/links'; -import { render } from 'tgui/renderer'; -import { configureStore } from 'tgui/store'; +import { createRoot } from 'react-dom/client'; import { setupGlobalEvents } from 'tgui-core/events'; +import { captureExternalLinks } from 'tgui-core/links'; import { setupHotReloading } from 'tgui-dev-server/link/client'; - -import { audioMiddleware, audioReducer } from './audio'; -import { chatMiddleware, chatReducer } from './chat'; -import { gameMiddleware, gameReducer } from './game'; -import { Panel } from './Panel'; +import { App } from './app'; +import { bus } from './events/listeners'; import { setupPanelFocusHacks } from './panelFocus'; -import { pingMiddleware, pingReducer } from './ping'; -import { settingsMiddleware, settingsReducer } from './settings'; -import { telemetryMiddleware } from './telemetry'; -perf.mark('inception', window.performance?.timeOrigin); -perf.mark('init'); +const root = createRoot(document.getElementById('react-root')!); -const store = configureStore({ - reducer: combineReducers({ - audio: audioReducer, - chat: chatReducer, - game: gameReducer, - ping: pingReducer, - settings: settingsReducer, - }), - middleware: { - pre: [ - chatMiddleware, - pingMiddleware, - telemetryMiddleware, - settingsMiddleware, - audioMiddleware, - gameMiddleware, - ], - }, -}); +function render(component: React.ReactElement) { + root.render(component); +} function setupApp() { // Delay setup @@ -61,8 +33,6 @@ function setupApp() { // Force speechSynthesis to check the server for voices as soon as the chat loads, so it's ready for us window.speechSynthesis.getVoices(); - setGlobalStore(store); - setupGlobalEvents({ ignoreWindowFocus: true, }); @@ -70,14 +40,13 @@ function setupApp() { setupPanelFocusHacks(); captureExternalLinks(); - // Re-render UI on store updates - store.subscribe(() => render()); + render(); // Dispatch incoming messages as store actions - Byond.subscribe((type, payload) => store.dispatch({ type, payload })); + Byond.subscribe((type, payload) => bus.dispatch({ type, payload })); // Unhide the panel - Byond.winset('legacy_output_selector', { + Byond.winset('outputwindow.legacy_output_selector', { left: 'output_browser', }); @@ -92,21 +61,9 @@ function setupApp() { if (import.meta.webpackHot) { setupHotReloading(); - import.meta.webpackHot.accept( - [ - './audio', - './chat', - './game', - './Notifications', - './Panel', - './ping', - './settings', - './telemetry', - ], - () => { - render(); - }, - ); + import.meta.webpackHot.accept(['./app'], () => { + render(); + }); } } diff --git a/tgui/packages/tgui-panel/package.json b/tgui/packages/tgui-panel/package.json index 6a115ebe3f..97995ad6fd 100644 --- a/tgui/packages/tgui-panel/package.json +++ b/tgui/packages/tgui-panel/package.json @@ -4,12 +4,14 @@ "dependencies": { "common": "workspace:*", "dompurify": "^3.3.1", - "es-toolkit": "^1.42.0", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "es-toolkit": "^1.43.0", + "jotai": "^2.16.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "tgui": "workspace:*", - "tgui-core": "^5.5.10", - "tgui-dev-server": "workspace:*" + "tgui-core": "^5.7.2", + "tgui-dev-server": "workspace:*", + "zod": "^4.3.5" }, "private": true } diff --git a/tgui/packages/tgui-panel/panelFocus.ts b/tgui/packages/tgui-panel/panelFocus.ts index efbc089c6e..7f700358cc 100644 --- a/tgui/packages/tgui-panel/panelFocus.ts +++ b/tgui/packages/tgui-panel/panelFocus.ts @@ -15,17 +15,22 @@ import { vecLength, vecSubtract } from 'tgui-core/vector'; // text you can select with the mouse. const MIN_SELECTION_DISTANCE = 10; -const deferredFocusMap = () => setTimeout(() => focusMap()); +function deferredFocusMap(): void { + setTimeout(focusMap); +} -export const setupPanelFocusHacks = () => { +export function setupPanelFocusHacks(): void { let focusStolen: boolean = false; let clickStartPos: number[] | null = null; + window.addEventListener('focusin', (e) => { focusStolen = canStealFocus(e.target as HTMLElement); }); + window.addEventListener('mousedown', (e) => { clickStartPos = [e.screenX, e.screenY]; }); + window.addEventListener('mouseup', (e) => { if (clickStartPos) { const clickEndPos = [e.screenX, e.screenY]; @@ -41,10 +46,11 @@ export const setupPanelFocusHacks = () => { deferredFocusMap(); } }); + globalEvents.on('keydown', (key) => { if (key.isModifierKey()) { return; } deferredFocusMap(); }); -}; +} diff --git a/tgui/packages/tgui-panel/ping/PingIndicator.tsx b/tgui/packages/tgui-panel/ping/PingIndicator.tsx index 0f50c127f0..d202fc96d5 100644 --- a/tgui/packages/tgui-panel/ping/PingIndicator.tsx +++ b/tgui/packages/tgui-panel/ping/PingIndicator.tsx @@ -4,24 +4,25 @@ * @license MIT */ -import { useSelector } from 'tgui/backend'; +import { useAtomValue } from 'jotai'; import { Color } from 'tgui-core/color'; import { Box } from 'tgui-core/components'; +import { pingAtom } from './atoms'; -import { selectPing } from './selectors'; - -export const PingIndicator = (props) => { - const ping = useSelector(selectPing); +export function PingIndicator(props) { + const ping = useAtomValue(pingAtom); const color = Color.lookup(ping.networkQuality, [ new Color(220, 40, 40), new Color(220, 200, 40), new Color(60, 220, 40), ]).toString(); + const roundtrip = ping.roundtrip ? ping.roundtrip.toFixed() : '--'; + return (
{roundtrip}
); -}; +} diff --git a/tgui/packages/tgui-panel/ping/actions.ts b/tgui/packages/tgui-panel/ping/actions.ts deleted file mode 100644 index 27f103302c..0000000000 --- a/tgui/packages/tgui-panel/ping/actions.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { createAction } from 'common/redux'; - -export const pingReply = createAction('ping/reply'); - -/** - * Soft ping from the server. - * It's intended to send periodic server-side metadata about the client, - * e.g. its AFK status. - */ -export const pingSoft = createAction('ping/soft'); - -export const pingSuccess = createAction('ping/success', (ping) => ({ - payload: { - lastId: ping.id, - roundtrip: (Date.now() - ping.sentAt) * 0.5, - }, -})); - -export const pingFail = createAction('ping/fail'); diff --git a/tgui/packages/tgui-panel/ping/atoms.ts b/tgui/packages/tgui-panel/ping/atoms.ts new file mode 100644 index 0000000000..bead759744 --- /dev/null +++ b/tgui/packages/tgui-panel/ping/atoms.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai'; + +export type PingState = { + roundtrip: number | undefined; + roundtripAvg: number | undefined; + failCount: number; + networkQuality: number; +}; + +export const lastPingedAtAtom = atom(null); +export const pingAtom = atom({} as PingState); diff --git a/tgui/packages/tgui-panel/ping/handlers.ts b/tgui/packages/tgui-panel/ping/handlers.ts new file mode 100644 index 0000000000..819d28b05a --- /dev/null +++ b/tgui/packages/tgui-panel/ping/handlers.ts @@ -0,0 +1,38 @@ +import { store } from '../events/store'; +import { lastPingedAtAtom } from './atoms'; +import { pingSuccess, pings, sendPing } from './helpers'; + +type SoftPingPayload = { + afk: boolean; +}; + +/** + * Soft ping from the server. + * It's intended to send periodic server-side metadata about the client, + * e.g. its AFK status. + */ +export function pingSoft(payload: SoftPingPayload): void { + const { afk } = payload; + store.set(lastPingedAtAtom, Date.now()); + // On each soft ping where client is not flagged as afk, + // initiate a new ping. + if (!afk) { + sendPing(); + } +} + +type ReplyPingPayload = { + index: number; +}; + +export function pingReply(payload: ReplyPingPayload) { + const { index } = payload; + + const ping = pings[index]; + if (!ping) return; // This ping was already marked as failed due to timeout. + + pings[index] = null; + const roundtrip = (Date.now() - ping.sentAt) * 0.5; + + pingSuccess(roundtrip); +} diff --git a/tgui/packages/tgui-panel/ping/helpers.ts b/tgui/packages/tgui-panel/ping/helpers.ts new file mode 100644 index 0000000000..ff22c82abb --- /dev/null +++ b/tgui/packages/tgui-panel/ping/helpers.ts @@ -0,0 +1,74 @@ +import { scale } from 'tgui-core/math'; +import { store } from '../events/store'; +import { lastPingedAtAtom, pingAtom } from './atoms'; +import { + PING_QUEUE_SIZE, + PING_ROUNDTRIP_BEST, + PING_ROUNDTRIP_WORST, + PING_TIMEOUT, +} from './constants'; + +let index = 0; +export const pings: Ping[] = []; + +type Ping = { + index: number; + sentAt: number; +} | null; + +/** Sends a ping to byond */ +export function sendPing(): void { + for (let i = 0; i < PING_QUEUE_SIZE; i++) { + const ping = pings[i]; + if (ping && Date.now() - ping.sentAt > PING_TIMEOUT) { + pings[i] = null; + pingFail(); + } + } + + const ping = { index, sentAt: Date.now() }; + pings[index] = ping; + Byond.sendMessage('ping', { index }); + index = (index + 1) % PING_QUEUE_SIZE; +} + +export function pingSuccess(roundtrip: number): void { + const state = store.get(pingAtom); + const prevRoundtrip = state.roundtripAvg || roundtrip; + const roundtripAvg = Math.round(prevRoundtrip * 0.4 + roundtrip * 0.6); + + const networkQuality = + 1 - scale(roundtripAvg, PING_ROUNDTRIP_BEST, PING_ROUNDTRIP_WORST); + + store.set(pingAtom, { + roundtrip, + roundtripAvg, + failCount: 0, + networkQuality, + }); + + store.set(lastPingedAtAtom, Date.now()); +} + +export function pingFail(): void { + const state = store.get(pingAtom); + const { failCount = 0 } = state; + + const networkQuality = Math.max( + 0, + state.networkQuality - failCount / PING_QUEUE_SIZE, + ); + + const nextState = { + ...state, + failCount: failCount + 1, + networkQuality, + }; + + if (failCount > PING_QUEUE_SIZE) { + nextState.roundtrip = undefined; + nextState.roundtripAvg = undefined; + } + + store.set(pingAtom, nextState); +} diff --git a/tgui/packages/tgui-panel/ping/index.ts b/tgui/packages/tgui-panel/ping/index.ts deleted file mode 100644 index 1bbbe3ed34..0000000000 --- a/tgui/packages/tgui-panel/ping/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export { pingMiddleware } from './middleware'; -export { PingIndicator } from './PingIndicator'; -export { pingReducer } from './reducer'; diff --git a/tgui/packages/tgui-panel/ping/middleware.ts b/tgui/packages/tgui-panel/ping/middleware.ts deleted file mode 100644 index 8e467631ad..0000000000 --- a/tgui/packages/tgui-panel/ping/middleware.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { pingFail, pingReply, pingSoft, pingSuccess } from './actions'; -import { PING_QUEUE_SIZE, PING_TIMEOUT } from './constants'; - -export const pingMiddleware = (store) => { - let initialized = false; - let index = 0; - const pings: ({ sentAt: number } | null)[] = []; - - const sendPing = () => { - for (let i = 0; i < PING_QUEUE_SIZE; i++) { - const ping = pings[i]; - if (ping && Date.now() - ping.sentAt > PING_TIMEOUT) { - pings[i] = null; - store.dispatch(pingFail()); - } - } - const ping = { index, sentAt: Date.now() }; - pings[index] = ping; - Byond.sendMessage('ping', { index }); - index = (index + 1) % PING_QUEUE_SIZE; - }; - - return (next) => (action) => { - const { type, payload } = action; - - if (!initialized) { - initialized = true; - sendPing(); - } - - if (type === pingSoft.type) { - const { afk } = payload; - // On each soft ping where client is not flagged as afk, - // initiate a new ping. - if (!afk) { - sendPing(); - } - return next(action); - } - - if (type === pingReply.type) { - const { index } = payload; - const ping = pings[index]; - // Received a timed out ping - if (!ping) { - return; - } - pings[index] = null; - return next(pingSuccess(ping)); - } - - return next(action); - }; -}; diff --git a/tgui/packages/tgui-panel/ping/reducer.ts b/tgui/packages/tgui-panel/ping/reducer.ts deleted file mode 100644 index 2a25c9d3c1..0000000000 --- a/tgui/packages/tgui-panel/ping/reducer.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { clamp01, scale } from 'tgui-core/math'; - -import { pingFail, pingSuccess } from './actions'; -import { - PING_MAX_FAILS, - PING_ROUNDTRIP_BEST, - PING_ROUNDTRIP_WORST, -} from './constants'; - -type PingState = { - roundtrip: number | undefined; - roundtripAvg: number | undefined; - failCount: number; - networkQuality: number; -}; - -export const pingReducer = (state = {} as PingState, action) => { - const { type, payload } = action; - - if (type === pingSuccess.type) { - const { roundtrip } = payload; - const prevRoundtrip = state.roundtripAvg || roundtrip; - const roundtripAvg = Math.round(prevRoundtrip * 0.4 + roundtrip * 0.6); - const networkQuality = - 1 - scale(roundtripAvg, PING_ROUNDTRIP_BEST, PING_ROUNDTRIP_WORST); - return { - roundtrip, - roundtripAvg, - failCount: 0, - networkQuality, - }; - } - - if (type === pingFail.type) { - const { failCount = 0 } = state; - const networkQuality = clamp01( - state.networkQuality - failCount / PING_MAX_FAILS, - ); - const nextState: PingState = { - ...state, - failCount: failCount + 1, - networkQuality, - }; - if (failCount > PING_MAX_FAILS) { - nextState.roundtrip = undefined; - nextState.roundtripAvg = undefined; - } - return nextState; - } - - return state; -}; diff --git a/tgui/packages/tgui-panel/ping/selectors.ts b/tgui/packages/tgui-panel/ping/selectors.ts deleted file mode 100644 index 1b8bfb4ee4..0000000000 --- a/tgui/packages/tgui-panel/ping/selectors.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export const selectPing = (state) => state.ping; diff --git a/tgui/packages/tgui-panel/reconnect.tsx b/tgui/packages/tgui-panel/reconnect.tsx index a7505cadd3..9e9f8782f9 100644 --- a/tgui/packages/tgui-panel/reconnect.tsx +++ b/tgui/packages/tgui-panel/reconnect.tsx @@ -1,8 +1,5 @@ -import { useDispatch } from 'tgui/backend'; import { Button, Stack } from 'tgui-core/components'; -import { dismissWarning } from './game/actions'; - let url: string | null = null; setInterval(() => { @@ -14,11 +11,14 @@ setInterval(() => { }); }, 5000); -export const ReconnectButton = (props) => { +export function ReconnectButton(props: { + onDismissedWarning: React.Dispatch>; +}) { if (!url) { return null; } - const dispatch = useDispatch(); + const { onDismissedWarning } = props; + return ( @@ -47,7 +47,7 @@ export const ReconnectButton = (props) => { @@ -88,7 +78,7 @@ export const ExportTab = (props) => { {game.databaseBackendEnabled ? 'Enabled' : 'Disabled'} - {logEnable && !game.databaseBackendEnabled && ( + {settings.logEnable && !game.databaseBackendEnabled && ( <> @@ -99,18 +89,16 @@ export const ExportTab = (props) => { stepPixelSize={10} minValue={1} maxValue={8} - value={logRetainRounds} + value={settings.logRetainRounds} format={(value) => value.toFixed()} onChange={(value) => - dispatch( - updateSettings({ - logRetainRounds: value, - }), - ) + updateSettings({ + logRetainRounds: value, + }) } />   - {logRetainRounds > 3 && ( + {settings.logRetainRounds > 3 && ( Warning, might crash! @@ -124,24 +112,22 @@ export const ExportTab = (props) => { stepPixelSize={10} minValue={0} maxValue={50000} - value={logLimit} + value={settings.logLimit} format={(value) => value.toFixed()} onChange={(value) => - dispatch( - updateSettings({ - logLimit: value, - }), - ) + updateSettings({ + logLimit: value, + }) } />   - {logLimit > 0 && ( + {settings.logLimit > 0 && ( 10000 ? 'red' : 'label'} + color={settings.logLimit > 10000 ? 'red' : 'label'} > - {logLimit > 15000 + {settings.logLimit > 15000 ? 'Warning, might crash! Takes priority above round retention.' : 'Takes priority above round retention.'} @@ -153,13 +139,14 @@ export const ExportTab = (props) => { {MESSAGE_TYPES.map((typeDef) => ( - dispatch( - updateToggle({ - type: typeDef.type, - }), - ) + updateSettings({ + storedTypes: toggleInObject( + settings.storedTypes, + typeDef.type, + ), + }) } > {typeDef.name} @@ -176,28 +163,16 @@ export const ExportTab = (props) => { <> - dispatch( - updateSettings({ - exportStart: value, - }), - ) - } + onSelected={(value) => setExportStart(value)} options={game.databaseStoredRounds} - selected={exportStart} + selected={exportStart.toString()} /> - dispatch( - updateSettings({ - exportEnd: value, - }), - ) - } + onSelected={(value) => setExportEnd(value)} options={game.databaseStoredRounds} - selected={exportEnd} + selected={exportEnd.toString()} /> @@ -213,13 +188,7 @@ export const ExportTab = (props) => { maxValue={exportEnd === 0 ? 0 : exportEnd - 1} value={exportStart} format={(value) => value.toFixed()} - onChange={(value) => - dispatch( - updateSettings({ - exportStart: value, - }), - ) - } + onChange={(value) => setExportStart(value)} /> @@ -232,13 +201,7 @@ export const ExportTab = (props) => { maxValue={storedRounds} value={exportEnd} format={(value) => value.toFixed()} - onChange={(value) => - dispatch( - updateSettings({ - exportEnd: value, - }), - ) - } + onChange={(value) => setExportEnd(value)} /> @@ -265,20 +228,18 @@ export const ExportTab = (props) => { stepPixelSize={10} minValue={0} maxValue={50000} - value={logLineCount} + value={settings.logLineCount} format={(value) => value.toFixed()} onChange={(value) => - dispatch( - updateSettings({ - logLineCount: value, - }), - ) + updateSettings({ + logLineCount: value, + }) } /> {!game.databaseBackendEnabled && ( - {totalStoredMessages} + {chatRenderer.getStoredMessages()} )} @@ -288,7 +249,7 @@ export const ExportTab = (props) => { @@ -298,7 +259,7 @@ export const ExportTab = (props) => { accept=".json" tooltip="Import chat settings" icon="arrow-up-from-bracket" - onSelectFiles={(files) => importChatSettings(files)} + onSelectFiles={importChatSettings} > Import settings @@ -307,7 +268,15 @@ export const ExportTab = (props) => { @@ -316,7 +285,7 @@ export const ExportTab = (props) => { dispatch(clearChat())} + onClick={() => chatRenderer.clearChat()} > Clear chat @@ -332,7 +301,7 @@ export const ExportTab = (props) => { confirmColor="red" confirmContent="Are you sure?" onClick={() => { - dispatch(purgeChatMessageArchive()); + purgeMessageArchive(); setPurgeButtonText('Purged!'); setTimeout(() => { setPurgeButtonText('Purge message archive'); diff --git a/tgui/packages/tgui-panel/settings/SettingTabs/MessageLimits.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/MessageLimits.tsx index 5074360d1f..ce2b443674 100644 --- a/tgui/packages/tgui-panel/settings/SettingTabs/MessageLimits.tsx +++ b/tgui/packages/tgui-panel/settings/SettingTabs/MessageLimits.tsx @@ -1,20 +1,11 @@ -import { useDispatch, useSelector } from 'tgui/backend'; +import { useAtomValue } from 'jotai'; import { Box, LabeledList, NumberInput, Section } from 'tgui-core/components'; - -import { useGame } from '../../game'; -import { updateSettings } from '../actions'; -import { selectSettings } from '../selectors'; +import { gameAtom } from '../../game/atoms'; +import { useSettings } from '../use-settings'; export const MessageLimits = (props) => { - const dispatch = useDispatch(); - const game = useGame(); - const { - visibleMessageLimit, - persistentMessageLimit, - combineMessageLimit, - combineIntervalLimit, - saveInterval, - } = useSelector(selectSettings); + const game = useAtomValue(gameAtom); + const { settings, updateSettings } = useSettings(); return (
@@ -26,18 +17,16 @@ export const MessageLimits = (props) => { stepPixelSize={2} minValue={500} maxValue={10000} - value={visibleMessageLimit} + value={settings.visibleMessageLimit} format={(value) => value.toFixed()} onChange={(value) => - dispatch( - updateSettings({ - visibleMessageLimit: value, - }), - ) + updateSettings({ + visibleMessageLimit: value, + }) } />   - {visibleMessageLimit >= 5000 && ( + {settings.visibleMessageLimit >= 5000 && ( Impacts performance! @@ -51,18 +40,16 @@ export const MessageLimits = (props) => { stepPixelSize={2} minValue={0} maxValue={10000} - value={persistentMessageLimit} + value={settings.persistentMessageLimit} format={(value) => value.toFixed()} onChange={(value) => - dispatch( - updateSettings({ - persistentMessageLimit: value, - }), - ) + updateSettings({ + persistentMessageLimit: value, + }) } />   - {persistentMessageLimit >= 2500 && ( + {settings.persistentMessageLimit >= 2500 && ( Delays initialization! @@ -76,14 +63,12 @@ export const MessageLimits = (props) => { stepPixelSize={10} minValue={0} maxValue={10} - value={combineMessageLimit} + value={settings.combineMessageLimit} format={(value) => value.toFixed()} onChange={(value) => - dispatch( - updateSettings({ - combineMessageLimit: value, - }), - ) + updateSettings({ + combineMessageLimit: value, + }) } /> @@ -95,15 +80,13 @@ export const MessageLimits = (props) => { stepPixelSize={10} minValue={0} maxValue={10} - value={combineIntervalLimit} + value={settings.combineIntervalLimit} unit="s" format={(value) => value.toFixed()} onChange={(value) => - dispatch( - updateSettings({ - combineIntervalLimit: value, - }), - ) + updateSettings({ + combineIntervalLimit: value, + }) } /> @@ -116,19 +99,17 @@ export const MessageLimits = (props) => { stepPixelSize={5} minValue={1} maxValue={10} - value={saveInterval} + value={settings.saveInterval} unit="s" format={(value) => value.toFixed()} onChange={(value) => - dispatch( - updateSettings({ - saveInterval: value, - }), - ) + updateSettings({ + saveInterval: value, + }) } />   - {saveInterval <= 3 && ( + {settings.saveInterval <= 3 && ( Warning, experimental! Might crash! diff --git a/tgui/packages/tgui-panel/settings/SettingTabs/SettingsGeneral.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/SettingsGeneral.tsx index d05009141f..c0b80caed2 100644 --- a/tgui/packages/tgui-panel/settings/SettingTabs/SettingsGeneral.tsx +++ b/tgui/packages/tgui-panel/settings/SettingTabs/SettingsGeneral.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { useDispatch, useSelector } from 'tgui/backend'; import { Box, Button, @@ -12,26 +11,17 @@ import { Stack, } from 'tgui-core/components'; import { capitalize } from 'tgui-core/string'; +import { chatRenderer } from '../../chat/renderer'; +import { FONTS, THEMES } from '../constants'; +import { resetPaneSplitters, setEditPaneSplitters } from '../scaling'; +import { useSettings } from '../use-settings'; -import { rebuildChat } from '../../chat/actions'; -import { THEMES } from '../../themes'; -import { updateSettings } from '../actions'; -import { FONTS } from '../constants'; -import { selectSettings } from '../selectors'; - -export const SettingsGeneral = (props) => { - const { - theme, - fontFamily, - fontSize, - lineHeight, - showReconnectWarning, - prependTimestamps, - interleave, - interleaveColor, - } = useSelector(selectSettings); - const dispatch = useDispatch(); +export function SettingsGeneral(props) { + const { settings, updateSettings } = useSettings(); const [freeFont, setFreeFont] = useState(false); + + const [editingPanes, setEditingPanes] = useState(false); + return (
@@ -39,26 +29,47 @@ export const SettingsGeneral = (props) => { {THEMES.map((THEME) => ( ))} + + + + + + + + + + {!freeFont ? ( { @@ -222,4 +222,4 @@ export const SettingsGeneral = (props) => {
); -}; +} diff --git a/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/SettingsStatPanel.tsx similarity index 69% rename from tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx rename to tgui/packages/tgui-panel/settings/SettingTabs/SettingsStatPanel.tsx index 34bdd26da3..83225471a9 100644 --- a/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx +++ b/tgui/packages/tgui-panel/settings/SettingTabs/SettingsStatPanel.tsx @@ -1,4 +1,3 @@ -import { useDispatch, useSelector } from 'tgui/backend'; import { Button, LabeledList, @@ -8,19 +7,17 @@ import { Stack, } from 'tgui-core/components'; 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 (
@@ -28,14 +25,12 @@ export function SettingsStatPanel(props) { - {TabsViews.map((view) => ( + {tabViews.map((view) => ( @@ -56,7 +51,7 @@ export function SettingsStatPanel(props) { unit="px" format={(value) => value.toFixed()} onChange={(e, value) => - dispatch(updateSettings({ statFontSize: value })) + updateSettings({ statFontSize: value }) } /> )} @@ -70,9 +65,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/SettingTabs/TTSSettings.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/TTSSettings.tsx index ebe6807da2..9aa21057b5 100644 --- a/tgui/packages/tgui-panel/settings/SettingTabs/TTSSettings.tsx +++ b/tgui/packages/tgui-panel/settings/SettingTabs/TTSSettings.tsx @@ -1,4 +1,3 @@ -import { useDispatch, useSelector } from 'tgui/backend'; import { Button, Collapsible, @@ -8,12 +7,10 @@ import { } from 'tgui-core/components'; import { MESSAGE_TYPES } from '../../chat/constants'; -import { toggleTTSSetting, updateSettings } from '../actions'; -import { selectSettings } from '../selectors'; +import { useSettings } from '../use-settings'; export const TTSSettings = (props) => { - const dispatch = useDispatch(); - const { ttsCategories, ttsVoice } = useSelector(selectSettings); + const { settings, updateSettings, toggleInObject } = useSettings(); const voices = window.speechSynthesis.getVoices(); @@ -25,13 +22,11 @@ export const TTSSettings = (props) => { voice.name)} onSelected={(option) => { - dispatch( - updateSettings({ - ttsVoice: option, - }), - ); + updateSettings({ + ttsVoice: option, + }); }} - selected={ttsVoice} + selected={settings.ttsVoice} /> @@ -40,8 +35,15 @@ export const TTSSettings = (props) => { {MESSAGE_TYPES.filter((typeDef) => !typeDef.admin).map((typeDef) => ( dispatch(toggleTTSSetting({ type: typeDef.type }))} + checked={settings.ttsCategories[typeDef.type]} + onClick={() => + updateSettings({ + ttsCategories: toggleInObject( + settings.ttsCategories, + typeDef.type, + ), + }) + } > {typeDef.name} @@ -52,8 +54,15 @@ export const TTSSettings = (props) => { ).map((typeDef) => ( dispatch(toggleTTSSetting({ type: typeDef.type }))} + checked={settings.ttsCategories[typeDef.type]} + onClick={() => + updateSettings({ + ttsCategories: toggleInObject( + settings.ttsCategories, + typeDef.type, + ), + }) + } > {typeDef.name} diff --git a/tgui/packages/tgui-panel/settings/SettingTabs/TextHighlightSettings.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/TextHighlightSettings.tsx index c427a419e4..f62ea6f18d 100644 --- a/tgui/packages/tgui-panel/settings/SettingTabs/TextHighlightSettings.tsx +++ b/tgui/packages/tgui-panel/settings/SettingTabs/TextHighlightSettings.tsx @@ -1,4 +1,4 @@ -import { useDispatch, useSelector } from 'tgui/backend'; +import { useAtomValue } from 'jotai'; import { Box, Button, @@ -9,22 +9,18 @@ import { Stack, TextArea, } from 'tgui-core/components'; - -import { rebuildChat } from '../../chat/actions'; -import { - addHighlightSetting, - removeHighlightSetting, - updateHighlightSetting, -} from '../actions'; +import { chatRenderer } from '../../chat/renderer'; +import { settingsAtom } from '../atoms'; import { MAX_HIGHLIGHT_SETTINGS } from '../constants'; -import { - selectHighlightSettingById, - selectHighlightSettings, -} from '../selectors'; +import { useHighlights } from '../use-highlights'; export const TextHighlightSettings = (props) => { - const highlightSettings = useSelector(selectHighlightSettings); - const dispatch = useDispatch(); + const { + highlights: { highlightSettings }, + addHighlight, + } = useHighlights(); + const settings = useAtomValue(settingsAtom); + return (
@@ -41,9 +37,7 @@ export const TextHighlightSettings = (props) => { @@ -53,7 +47,10 @@ export const TextHighlightSettings = (props) => {
- @@ -66,8 +63,11 @@ export const TextHighlightSettings = (props) => { const TextHighlightSetting = (props) => { const { id, ...rest } = props; - const highlightSettingById = useSelector(selectHighlightSettingById); - const dispatch = useDispatch(); + const { + highlights: { highlightSettingById }, + updateHighlight, + removeHighlight, + } = useHighlights(); const { highlightColor, highlightText, @@ -84,13 +84,11 @@ const TextHighlightSetting = (props) => { icon="times" color="transparent" onClick={() => - dispatch( - updateHighlightSetting({ - id: id, - highlightText: '', - blacklistText: '', - }), - ) + updateHighlight({ + id, + highlightText: '', + blacklistText: '', + }) } > Reset @@ -100,13 +98,7 @@ const TextHighlightSetting = (props) => { - dispatch( - removeHighlightSetting({ - id: id, - }), - ) - } + onClick={() => removeHighlight(id)} > Delete @@ -119,12 +111,10 @@ const TextHighlightSetting = (props) => { tooltip="If this option is selected, you can blacklist senders not to highlight their messages." mr="5px" onClick={() => - dispatch( - updateHighlightSetting({ - id: id, - highlightBlacklist: !highlightBlacklist, - }), - ) + updateHighlight({ + id, + highlightBlacklist: !highlightBlacklist, + }) } > Highlight Blacklist @@ -136,12 +126,10 @@ const TextHighlightSetting = (props) => { tooltip="If this option is selected, the entire message will be highlighted in yellow." mr="5px" onClick={() => - dispatch( - updateHighlightSetting({ - id: id, - highlightWholeMessage: !highlightWholeMessage, - }), - ) + updateHighlight({ + id, + highlightWholeMessage: !highlightWholeMessage, + }) } > Whole Message @@ -153,12 +141,10 @@ const 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 @@ -169,12 +155,10 @@ const 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 @@ -188,12 +172,10 @@ const TextHighlightSetting = (props) => { placeholder="#ffffff" value={highlightColor} onBlur={(value) => - dispatch( - updateHighlightSetting({ - id: id, - highlightColor: value, - }), - ) + updateHighlight({ + id, + highlightColor: value, + }) } /> @@ -204,12 +186,10 @@ const 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, + highlightText: value, + }) } /> {!!highlightBlacklist && ( @@ -219,12 +199,10 @@ const TextHighlightSetting = (props) => { value={blacklistText} placeholder="Put names of senders you don't want highlighted here. Separate names with commas, i.e. (name1, name2, name3)" onBlur={(value) => - dispatch( - updateHighlightSetting({ - id: id, - blacklistText: value, - }), - ) + updateHighlight({ + id, + blacklistText: value, + }) } /> )} diff --git a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx index 8716bc957a..83995b0071 100644 --- a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx +++ b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx @@ -4,24 +4,25 @@ * @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 { ChatPageSettings } from '../chat/ChatPageSettings'; import { SETTINGS_TABS } from './constants'; -import { SettingsStatPanel } from './SettingsStatPanel'; import { AdminSettings } from './SettingTabs/AdminSettings'; import { ExportTab } from './SettingTabs/ExportTab'; import { MessageLimits } from './SettingTabs/MessageLimits'; import { SettingsGeneral } from './SettingTabs/SettingsGeneral'; +import { SettingsStatPanel } from './SettingTabs/SettingsStatPanel'; import { TextHighlightSettings } from './SettingTabs/TextHighlightSettings'; import { TTSSettings } from './SettingTabs/TTSSettings'; -import { selectActiveTab } from './selectors'; +import { useSettings } from './use-settings'; export const SettingsPanel = (props) => { - const activeTab = useSelector(selectActiveTab); - const dispatch = useDispatch(); + const { + settings: { view }, + updateSettings, + } = useSettings(); + const { activeTab } = view; + return ( @@ -32,11 +33,12 @@ export const SettingsPanel = (props) => { key={tab.id} selected={tab.id === activeTab} onClick={() => - dispatch( - changeSettingsTab({ - tabId: tab.id, - }), - ) + updateSettings({ + view: { + ...view, + activeTab: tab.id, + }, + }) } > {tab.name} @@ -47,12 +49,12 @@ export const SettingsPanel = (props) => { {activeTab === 'general' && } + {activeTab === 'adminSettings' && } {activeTab === 'limits' && } {activeTab === 'export' && } - {activeTab === 'chatPage' && } {activeTab === 'textHighlight' && } + {activeTab === 'chatPage' && } {activeTab === 'statPanel' && } - {activeTab === 'adminSettings' && } {activeTab === 'ttsSettings' && } diff --git a/tgui/packages/tgui-panel/settings/actions.ts b/tgui/packages/tgui-panel/settings/actions.ts deleted file mode 100644 index 4b3edd4791..0000000000 --- a/tgui/packages/tgui-panel/settings/actions.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { createAction } from 'common/redux'; - -import { createHighlightSetting } from './model'; - -export const updateToggle = createAction('settings/updateToggle'); -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 toggleTTSSetting = createAction('settings/toggleTTSSetting'); -export const exportSettings = createAction('settings/export'); -export const importSettings = createAction( - 'settings/import', - (settings, pages) => ({ - payload: { newSettings: settings, newPages: pages }, - }), -); diff --git a/tgui/packages/tgui-panel/settings/atoms.ts b/tgui/packages/tgui-panel/settings/atoms.ts new file mode 100644 index 0000000000..c1dca966b9 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/atoms.ts @@ -0,0 +1,75 @@ +import { atom } from 'jotai'; +import { FONTS, SETTINGS_TABS } from './constants'; +import type { HighlightSetting, HighlightState, SettingsState } from './types'; + +export const defaultSettings: SettingsState = { + adminMusicVolume: 0.5, + combineIntervalLimit: 5, + combineMessageLimit: 5, + fontFamily: FONTS[0], + fontSize: 13, + hideImportantInAdminTab: false, + initialized: false, + interleave: false, + interleaveColor: '#909090', + lineHeight: 1.2, + logEnable: true, + logLimit: 10000, + logLineCount: 0, + logRetainRounds: 2, + persistentMessageLimit: 1000, + prependTimestamps: false, + saveInterval: 10, + showReconnectWarning: true, + statFontSize: 12, + statLinked: true, + statTabsStyle: 'default', + storedTypes: {}, + theme: 'light', + ttsCategories: {}, + ttsVoice: '', + version: 1, + view: { + activeTab: SETTINGS_TABS[0].id, + visible: false, + }, + visibleMessageLimit: 2500, + visibleMessages: 0, +}; + +export const defaultHighlightSetting: HighlightSetting = { + blacklistText: '', + id: 'default', + highlightBlacklist: false, + 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 ae1e37dfae..b25be7f871 100644 --- a/tgui/packages/tgui-panel/settings/constants.ts +++ b/tgui/packages/tgui-panel/settings/constants.ts @@ -4,6 +4,8 @@ * @license MIT */ +export const THEMES = ['light', 'dark', 'vchatlight', 'vchatdark'] as const; + export const SETTINGS_TABS = [ { id: 'general', @@ -37,7 +39,7 @@ export const SETTINGS_TABS = [ id: 'ttsSettings', name: 'TTS/Accessibility', }, -]; +] as const; export const FONTS_DISABLED = 'Default'; @@ -53,8 +55,6 @@ export const FONTS = [ 'Trebuchet MS', 'Courier New', 'Lucida Console', -]; +] as const; export const MAX_HIGHLIGHT_SETTINGS = 10; - -export const blacklisted_tags = ['a', 'iframe', 'link', 'video']; diff --git a/tgui/packages/tgui-panel/settings/helpers.ts b/tgui/packages/tgui-panel/settings/helpers.ts new file mode 100644 index 0000000000..5f02f47d32 --- /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 b37b49a22c..0000000000 --- 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 7d5dcda427..0000000000 --- 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 60bec5c7b9..0000000000 --- a/tgui/packages/tgui-panel/settings/middleware.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { storage } from 'common/storage'; - -import { setClientTheme } from '../themes'; -import { - addHighlightSetting, - exportSettings, - importSettings, - loadSettings, - removeHighlightSetting, - toggleTTSSetting, - updateHighlightSetting, - updateSettings, - updateToggle, -} from './actions'; -import { FONTS_DISABLED } from './constants'; -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; - 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 !== updateToggle.type && - type !== loadSettings.type && - type !== addHighlightSetting.type && - type !== removeHighlightSetting.type && - type !== updateHighlightSetting.type && - type !== toggleTTSSetting.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 0000000000..0340a7e162 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/migration.ts @@ -0,0 +1,133 @@ +import { storage } from 'common/storage'; +import { smoothMerge } from 'common/type-safety'; +import { omit, pick } from 'es-toolkit'; +import { setMusicVolume } from '../audio/handlers'; +import { MESSAGE_TYPES } from '../chat/constants'; +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; +} + +function normalizeStoredTypes( + storedTypes: Record | undefined, +): Record { + const result: Record = {}; + + for (const typeDef of MESSAGE_TYPES) { + const value = storedTypes?.[typeDef.type]; + result[typeDef.type] = value === null || value === undefined ? true : value; + } + + return result; +} + +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) { + const initialized: SettingsState = { + ...defaultSettings, + storedTypes: normalizeStoredTypes(defaultSettings.storedTypes), + initialized: true, + }; + storage.set('panel-settings', initialized); + store.set(settingsAtom, initialized); + console.log('Initialized settings with defaults.'); + 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); + setMusicVolume(draftSettings.adminMusicVolume); + store.set(settingsAtom, draftSettings); + console.log('Migrated panel settings:', draftSettings); + + const migratedHighlights = migrateHighlights(highlightPart); + + storage.set('panel-settings', { ...draftSettings, ...migratedHighlights }); + // Just exit if no valid version was found + if (!next.version) { + 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 fd4c662483..0000000000 --- a/tgui/packages/tgui-panel/settings/model.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @file - */ -import { createUuid } from 'tgui-core/uuid'; - -export const createHighlightSetting = (obj?: Record) => ({ - id: createUuid(), - highlightText: '', - blacklistText: '', - highlightColor: '#ffdd44', - highlightBlacklist: false, - 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 8ee6482f7e..0000000000 --- a/tgui/packages/tgui-panel/settings/reducer.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { MESSAGE_TYPES } from '../chat/constants'; -import { - addHighlightSetting, - changeSettingsTab, - importSettings, - loadSettings, - openChatSettings, - removeHighlightSetting, - toggleSettings, - toggleTTSSetting, - updateHighlightSetting, - updateSettings, - updateToggle, -} from './actions'; -import { FONTS, MAX_HIGHLIGHT_SETTINGS, 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, - }, - showReconnectWarning: true, - prependTimestamps: false, - visibleMessageLimit: 2500, - persistentMessageLimit: 1000, - saveInterval: 10, - combineMessageLimit: 5, - combineIntervalLimit: 5, - totalStoredMessages: 0, - logRetainRounds: 2, - logEnable: true, - logLineCount: 0, - logLimit: 10000, - storedRounds: 0, - exportStart: 0, - exportEnd: 0, - lastId: null, - initialized: false, - storedTypes: {}, - hideImportantInAdminTab: false, - interleave: false, - interleaveColor: '#909090', - statLinked: true, - statFontSize: 12, - statTabsStyle: 'default', - ttsCategories: {}, - ttsVoice: '', -} 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 updateToggle.type: { - const { type } = payload; - state.storedTypes[type] = !state.storedTypes[type]; - return { - ...state, - storedTypes: { ...state.storedTypes }, - }; - } - - 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; - const newFilters = {}; - for (const typeDef of MESSAGE_TYPES) { - if ( - nextState.storedTypes[typeDef.type] === null || - nextState.storedTypes[typeDef.type] === undefined - ) { - newFilters[typeDef.type] = true; - } else { - newFilters[typeDef.type] = nextState.storedTypes[typeDef.type]; - } - } - nextState.storedTypes = newFilters; - // 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 toggleTTSSetting.type: { - const { type } = payload; - const ttsCategories = { ...state.ttsCategories }; - - ttsCategories[type] = !ttsCategories[type]; - - return { - ...state, - ttsCategories, - }; - } - - case addHighlightSetting.type: { - const highlightSetting = payload; - - if (state.highlightSettings.length >= MAX_HIGHLIGHT_SETTINGS) { - return state; - } - - return { - ...state, - highlightSettings: [...state.highlightSettings, highlightSetting.id], - highlightSettingById: { - ...state.highlightSettingById, - [highlightSetting.id]: highlightSetting, - }, - }; - } - - 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) { - 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/scaling.ts b/tgui/packages/tgui-panel/settings/scaling.ts new file mode 100644 index 0000000000..0e0de5f3b7 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/scaling.ts @@ -0,0 +1,55 @@ +// This is the elements from the skin.dmf that we need to adjust the fontsize of +const ELEMENTS_TO_ADJUST = [ + 'rpane.wikib', + 'rpane.forumb', + 'rpane.rulesb', + 'rpane.mapb', + 'rpane.github', + 'rpane.discord', + 'rpane.patreon', + 'rpane.textb', + 'rpane.infob', +]; + +const DEFAULT_BUTTON_FONT_SIZE = 4; + +export async function setDisplayScaling() { + if (window.devicePixelRatio === 1) { + return; + } + + const newSizes: { [element: string]: number } = {}; + + for (const element of ELEMENTS_TO_ADJUST) { + newSizes[`${element}.font-size`] = + DEFAULT_BUTTON_FONT_SIZE * window.devicePixelRatio; + } + + Byond.winset(null, newSizes); +} + +const PANE_SPLITTERS = { + info_button_child: 2, + input_buttons_child: 80, + output_input_child: 96, +}; + +export function setEditPaneSplitters(editing: boolean) { + const toSet: { [element: string]: any } = {}; + + for (const pane of Object.keys(PANE_SPLITTERS)) { + toSet[`${pane}.show-splitter`] = editing; + } + + Byond.winset(null, toSet); +} + +export function resetPaneSplitters() { + const toSet: { [element: string]: any } = {}; + + for (const default_obj of Object.entries(PANE_SPLITTERS)) { + toSet[`${default_obj[0]}.splitter`] = default_obj[1]; + } + + Byond.winset(null, toSet); +} diff --git a/tgui/packages/tgui-panel/settings/selectors.ts b/tgui/packages/tgui-panel/settings/selectors.ts deleted file mode 100644 index 75f9ecb498..0000000000 --- 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 912c4dee8d..9c0596f51e 100644 --- a/tgui/packages/tgui-panel/settings/settingsImExport.ts +++ b/tgui/packages/tgui-panel/settings/settingsImExport.ts @@ -1,12 +1,16 @@ -import { useDispatch } from 'tgui/backend'; +import { omit } from 'es-toolkit'; +import { chatPagesRecordAtom, mainPage } from '../chat/atoms'; +import { startChatStateMigration } from '../chat/migration'; +import type { Page, StoredChatSettings } from '../chat/types'; +import { store } from '../events/store'; +import { storedSettingsAtom } from './atoms'; +import { startSettingsMigration } from './migration'; +import type { ExportedSettings } from './types'; -import type { Page } from '../chat/types'; -import { importSettings } from './actions'; +export function exportChatSettings(): void { + const chatPages = store.get(chatPagesRecordAtom); + 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`, @@ -18,9 +22,7 @@ export function exportChatSettings( ], }; - const pagesEntry = { chatPages: pages }; - - const exportObject = Object.assign(settings, pagesEntry); + const exportObject = { ...settings, chatPages }; window .showSaveFilePicker(opts) @@ -38,17 +40,53 @@ export function exportChatSettings( }); } -export function importChatSettings(settings: string | string[]) { - const dispatch = useDispatch(); - if (Array.isArray(settings)) { - return; - } - const ourImport = JSON.parse(settings); - if (!ourImport?.version) { - return; - } - const pageRecord = ourImport.chatPages; - delete ourImport.chatPages; +export function importChatSettings(settings: string | string[]): void { + if (Array.isArray(settings)) return; - dispatch(importSettings(ourImport, pageRecord)); + let ourImport: ExportedSettings; + try { + ourImport = JSON.parse(settings); + } catch (err) { + console.error(err); + return; + } + + const settingsPart = omit(ourImport, ['chatPages']); + + if ('chatPages' in ourImport && ourImport.chatPages) { + const chatPart = rebuildChatState(ourImport.chatPages); + if (chatPart) { + startChatStateMigration(chatPart); + } + } + + startSettingsMigration(settingsPart); +} + +/** Reconstructs chat settings from just the record */ +function rebuildChatState( + pageRecord: Record, +): StoredChatSettings | undefined { + const newPageIds: string[] = Object.keys(pageRecord); + if (newPageIds.length === 0) return; + + // Correct any missing keys from the import + const merged: Record = { ...pageRecord }; + for (const page of newPageIds) { + merged[page] = { + ...mainPage, + ...pageRecord[page], + unreadCount: 0, + }; + } + + const rebuiltState: StoredChatSettings = { + version: 5, + scrollTracking: true, + currentPageId: newPageIds[0], + pages: newPageIds, + pageById: merged, + }; + + return rebuiltState; } diff --git a/tgui/packages/tgui-panel/themes.ts b/tgui/packages/tgui-panel/settings/themes.ts similarity index 98% rename from tgui/packages/tgui-panel/themes.ts rename to tgui/packages/tgui-panel/settings/themes.ts index f7bef0970e..e42c527078 100644 --- a/tgui/packages/tgui-panel/themes.ts +++ b/tgui/packages/tgui-panel/settings/themes.ts @@ -4,8 +4,6 @@ * @license MIT */ -export const THEMES = ['light', 'dark', 'vchatlight', 'vchatdark']; - const COLOR_DARK_BG = '#202020'; const COLOR_DARK_BG_DARKER = '#171717'; const COLOR_DARK_TEXT = '#a4bad6'; @@ -22,7 +20,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) { // Transmit once for fast updates and again in a little while in case we won // the race against statbrowser init. clearInterval(setClientThemeTimer); @@ -185,4 +183,4 @@ export const setClientTheme = (name) => { 'input.text-color': COLOR_DARK_TEXT, }); } -}; +} diff --git a/tgui/packages/tgui-panel/settings/types.ts b/tgui/packages/tgui-panel/settings/types.ts new file mode 100644 index 0000000000..5566884380 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/types.ts @@ -0,0 +1,65 @@ +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(), + combineMessageLimit: z.number(), + combineIntervalLimit: z.number(), + fontFamily: z.string(), + fontSize: z.number(), + hideImportantInAdminTab: z.boolean(), + initialized: z.boolean(), + interleave: z.boolean(), + interleaveColor: z.string(), + lineHeight: z.number(), + logEnable: z.boolean(), + logLimit: z.number(), + logLineCount: z.number(), + logRetainRounds: z.number(), + persistentMessageLimit: z.number(), + prependTimestamps: z.boolean(), + saveInterval: z.number(), + showReconnectWarning: z.boolean(), + statFontSize: z.number(), + statLinked: z.boolean(), + statTabsStyle: z.string(), + storedTypes: z.record(z.string(), z.boolean()), + theme: z.enum(['light', 'dark', 'vchatlight', 'vchatdark']), + ttsCategories: z.record(z.string(), z.boolean()), + ttsVoice: z.string(), + version: z.number(), + view: viewSchema, + visibleMessages: z.number(), + visibleMessageLimit: z.number(), +}); + +export type HighlightSetting = { + blacklistText: string; + highlightBlacklist: boolean; + highlightColor: string; + highlightText: string; + highlightWholeMessage: boolean; + id: string; + matchCase: boolean; + matchWord: boolean; +}; + +export type HighlightState = { + highlightSettings: string[]; + highlightSettingById: Record; + highlightText: string; + highlightColor: 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 0000000000..b96cf67b8e --- /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 0000000000..c2bc820fb5 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/use-settings.ts @@ -0,0 +1,66 @@ +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); + } + + function toggleInObject< + TObject extends Record, + TKey extends keyof TObject, + >(object: TObject, key: TKey): TObject { + return { + ...object, + [key]: !object[key], + }; + } + + return { settings, updateSettings, toggleInObject }; +} diff --git a/tgui/packages/tgui-panel/telemetry.ts b/tgui/packages/tgui-panel/telemetry.ts deleted file mode 100644 index 1e7a133ee9..0000000000 --- a/tgui/packages/tgui-panel/telemetry.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { storage } from 'common/storage'; -import { createLogger } from 'tgui/logging'; - -import { getChatData } from './chat/actions'; -import { updateExportData } from './game/actions'; - -const logger = createLogger('telemetry'); - -const MAX_CONNECTIONS_STORED = 10; - -type Client = { - ckey: string; - chatlog_token: string; - address: string; - computer_id: string; -}; -type Telemetry = { limits: { connections: number }[]; connections: Client[] }; - -const connectionsMatch = (a: Client, b: Client) => - a.ckey === b.ckey && - a.chatlog_token === b.chatlog_token && - a.address === b.address && - a.computer_id === b.computer_id; - -export const telemetryMiddleware = (store) => { - let telemetry: Telemetry; - let wasRequestedWithPayload: Telemetry | null; - let firstMutate = true; - return (next) => (action) => { - const { type, payload } = action; - // Handle telemetry requests - if (type === 'telemetry/request') { - // Defer telemetry request until we have the actual telemetry - if (!telemetry) { - logger.debug('deferred'); - wasRequestedWithPayload = payload; - return; - } - logger.debug('sending'); - const limits = payload?.limits || {}; - // Trim connections according to the server limit - const connections = telemetry.connections.slice(0, limits.connections); - Byond.sendMessage('telemetry', { connections }); - return; - } - // For whatever reason we didn't get the telemetry, re-request - if (type === 'testTelemetryCommand') { - setTimeout(() => { - if (!telemetry) { - Byond.sendMessage('ready'); - } - }, 500); - } - // Keep telemetry up to date - if (type === 'backend/update') { - next(action); - (async () => { - // Extract client data - const client = payload?.config?.client; - if (!client) { - logger.error('backend/update payload is missing client data!'); - return; - } - // Load telemetry - if (!telemetry) { - telemetry = (await storage.get('telemetry')) || {}; - if (!telemetry.connections) { - telemetry.connections = []; - } - logger.debug('retrieved telemetry from storage', telemetry); - } - // Append a connection record - let telemetryMutated = false; - - const duplicateConnection = telemetry.connections.find((conn) => - connectionsMatch(conn, client), - ); - if (!duplicateConnection) { - telemetryMutated = true; - telemetry.connections.unshift(client); - if (telemetry.connections.length > MAX_CONNECTIONS_STORED) { - telemetry.connections.pop(); - } - } - if (firstMutate || telemetryMutated) { - firstMutate = false; - store.dispatch( - getChatData({ ckey: client.ckey, token: client.chatlog_token }), - ); - store.dispatch( - updateExportData({ - ckey: client.ckey, - token: client.chatlog_token, - }), - ); - } - // Save telemetry - if (telemetryMutated) { - logger.debug('saving telemetry to storage', telemetry); - storage.set('telemetry', telemetry); - } - // Continue deferred telemetry requests - if (wasRequestedWithPayload) { - const payload = wasRequestedWithPayload; - wasRequestedWithPayload = null; - store.dispatch({ - type: 'telemetry/request', - payload, - }); - } - })(); - return; - } - return next(action); - }; -}; diff --git a/tgui/packages/tgui-panel/telemetry/constants.ts b/tgui/packages/tgui-panel/telemetry/constants.ts new file mode 100644 index 0000000000..9c419e6958 --- /dev/null +++ b/tgui/packages/tgui-panel/telemetry/constants.ts @@ -0,0 +1 @@ +export const MAX_CONNECTIONS_STORED = 10; diff --git a/tgui/packages/tgui-panel/telemetry/handlers.ts b/tgui/packages/tgui-panel/telemetry/handlers.ts new file mode 100644 index 0000000000..f4da241313 --- /dev/null +++ b/tgui/packages/tgui-panel/telemetry/handlers.ts @@ -0,0 +1,103 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +import { storage } from 'common/storage'; +import { createLogger } from 'tgui/logging'; +import { store } from '../events/store'; +import { userDataAtom } from '../game/atoms'; +import type { TelemetryUpdatePayload } from '../game/types'; +import { MAX_CONNECTIONS_STORED } from './constants'; +import { type ConnectionRecord, connectionsMatch } from './helpers'; + +type Telemetry = { + connections: ConnectionRecord[]; +}; + +type TelemetryRequestPayload = { + limits?: { + connections: number; + }; +}; + +const logger = createLogger('telemetry'); + +let telemetry: Telemetry | null = null; +let wasRequestedWithPayload: TelemetryRequestPayload | null = null; + +export function telemetryRequest(payload: TelemetryRequestPayload): void { + // Defer telemetry request until we have the actual telemetry + if (!telemetry) { + logger.debug('deferred telemetry'); + wasRequestedWithPayload = payload; + return; + } + + logger.debug('sending telemetry'); + const limits = payload?.limits?.connections; + // Trim connections according to the server limit + const connections = telemetry.connections.slice(0, limits); + Byond.sendMessage('telemetry', { connections }); +} + +export function testTelemetryCommand() { + setTimeout(() => { + if (!telemetry) { + Byond.sendMessage('ready'); + } + }, 500); +} + +export async function handleTelemetryData( + payload: TelemetryUpdatePayload, +): Promise { + // Extract client data + const client = payload?.config?.client; + if (!client) { + logger.error('backend/update payload is missing client data!'); + return; + } + store.set(userDataAtom, { + ckey: payload.config.client.ckey, + token: payload.config.client.chatlog_token, + }); + + // Load telemetry + if (!telemetry) { + const stored = await storage.get('telemetry'); + telemetry = { + connections: stored?.connections ?? [], + }; + logger.debug('Retrieved telemetry from storage', telemetry); + } + + // Append a connection record + let telemetryMutated = false; + + const duplicateConnection = telemetry!.connections.find((conn) => + connectionsMatch(conn, client), + ); + + if (!duplicateConnection) { + telemetryMutated = true; + telemetry!.connections.unshift(client); + if (telemetry!.connections.length > MAX_CONNECTIONS_STORED) { + telemetry!.connections.pop(); + } + } + + // Save telemetry + if (telemetryMutated) { + logger.debug('Saving telemetry to storage', telemetry); + storage.set('telemetry', telemetry); + } + + // Continue deferred telemetry requests + if (wasRequestedWithPayload) { + const deferred = wasRequestedWithPayload; + wasRequestedWithPayload = null; + telemetryRequest(deferred); + } +} diff --git a/tgui/packages/tgui-panel/telemetry/helpers.ts b/tgui/packages/tgui-panel/telemetry/helpers.ts new file mode 100644 index 0000000000..3ab6f992e5 --- /dev/null +++ b/tgui/packages/tgui-panel/telemetry/helpers.ts @@ -0,0 +1,16 @@ +export type ConnectionRecord = { + ckey: string; + address: string; + computer_id: string; +}; + +export function connectionsMatch( + a: ConnectionRecord, + b: ConnectionRecord, +): boolean { + return ( + a.ckey === b.ckey && + a.address === b.address && + a.computer_id === b.computer_id + ); +} diff --git a/tgui/packages/tgui-say/TguiSay.tsx b/tgui/packages/tgui-say/TguiSay.tsx index c743e65b39..8b59288426 100644 --- a/tgui/packages/tgui-say/TguiSay.tsx +++ b/tgui/packages/tgui-say/TguiSay.tsx @@ -139,7 +139,7 @@ export function TguiSay() { const prefix = currentPrefix ?? ''; if (value?.length) { - if (value.length < maxLength) { + if (value.length <= maxLength) { chatHistory.current.add(value); Byond.sendMessage('entry', { channel: iterator.current(), diff --git a/tgui/packages/tgui-say/package.json b/tgui/packages/tgui-say/package.json index 2cfc89f12c..f8ef73175e 100644 --- a/tgui/packages/tgui-say/package.json +++ b/tgui/packages/tgui-say/package.json @@ -3,10 +3,10 @@ "version": "1.0.0", "dependencies": { "common": "workspace:*", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "tgui": "workspace:*", - "tgui-core": "^5.5.10" + "tgui-core": "^5.7.2" }, "private": true } diff --git a/tgui/packages/tgui-setup/helpers.js b/tgui/packages/tgui-setup/helpers.js index e964edf6b2..8b0f290039 100644 --- a/tgui/packages/tgui-setup/helpers.js +++ b/tgui/packages/tgui-setup/helpers.js @@ -195,7 +195,7 @@ if (message.payload !== null && message.payload !== undefined) { message.payload = JSON.stringify(message.payload); - if (!Byond.TRIDENT && message.payload.length > MAX_PACKET_SIZE) { + if (!Byond.TRIDENT && message.payload.length > MAX_PACKET_SIZE && type !== "payloadChunk") { const chunks = []; for ( diff --git a/tgui/packages/tgui/App.tsx b/tgui/packages/tgui/App.tsx index fcb2fdf1dc..bb128901bf 100644 --- a/tgui/packages/tgui/App.tsx +++ b/tgui/packages/tgui/App.tsx @@ -1,14 +1,11 @@ -import { globalStore } from './backend'; -import { IconProvider } from './Icons'; +import { Provider } from 'jotai'; +import { store } from './events/store'; +import { RoutedComponent } from './routes'; export function App() { - const { getRoutedComponent } = require('./routes'); - const Component = getRoutedComponent(globalStore); - return ( - <> - - - + + + ); } diff --git a/tgui/packages/tgui/Icons.tsx b/tgui/packages/tgui/Icons.tsx deleted file mode 100644 index 5745ec64aa..0000000000 --- a/tgui/packages/tgui/Icons.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Suspense, useEffect } from 'react'; -import { fetchRetry } from 'tgui-core/http'; - -import { resolveAsset } from './assets'; -import { logger } from './logging'; - -function setIconRefMap(map: Record): void { - Byond.iconRefMap = map; -} - -function loadIconMap(): void { - fetchRetry(resolveAsset('icon_ref_map.json')) - .then((res) => res.json()) - .then(setIconRefMap) - .catch((error) => logger.log(error)); -} - -function IconMapLoader(): null { - useEffect(() => { - if (Object.keys(Byond.iconRefMap).length === 0) { - loadIconMap(); - } - }, []); - - return null; -} - -export function IconProvider() { - return ( - - - - ); -} diff --git a/tgui/packages/tgui/assets.ts b/tgui/packages/tgui/assets.ts index 6ca5d5f167..4ca45f7f17 100644 --- a/tgui/packages/tgui/assets.ts +++ b/tgui/packages/tgui/assets.ts @@ -1,43 +1,5 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ +export const loadedMappings: Record = {}; -import type { Action, AnyAction, Dispatch, Middleware } from 'common/redux'; - -const EXCLUDED_PATTERNS = [/v4shim/i]; -const loadedMappings: Record = {}; - -export const resolveAsset = (name: string): string => - loadedMappings[name] || name; - -export const assetMiddleware: Middleware = - (storeApi) => - (next: Dispatch) => - (action: ActionType) => { - const { type, payload } = action as AnyAction; - if (type === 'asset/stylesheet') { - Byond.loadCss(payload); - return; - } - if (type === 'asset/mappings') { - for (const name of Object.keys(payload)) { - // Skip anything that matches excluded patterns - if (EXCLUDED_PATTERNS.some((regex) => regex.test(name))) { - continue; - } - const url = payload[name]; - const ext = name.split('.').pop(); - loadedMappings[name] = url; - if (ext === 'css') { - Byond.loadCss(url); - } - if (ext === 'js') { - Byond.loadJs(url); - } - } - return; - } - next(action); - }; +export function resolveAsset(name: string): string { + return loadedMappings[name] || name; +} diff --git a/tgui/packages/tgui/backend.ts b/tgui/packages/tgui/backend.ts index 180bcb623e..5996951ed6 100644 --- a/tgui/packages/tgui/backend.ts +++ b/tgui/packages/tgui/backend.ts @@ -1,294 +1,32 @@ +import { sendAct } from './events/act'; +import { backendStateAtom, sharedAtom, store } from './events/store'; +import type { BackendState } from './events/types'; + /** - * This file provides a clear separation layer between backend updates - * and what state our React app sees. + * Reactive backend state hook. Please use a type to define what the data is + * intended to be, e.g.: * - * Sometimes backend can response without a "data" field, but our final - * state will still contain previous "data" because we are merging - * the response with already existing state. + * ```ts + * type Data = { + * username: string; + * }; * - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { perf } from 'common/perf'; -import { createAction } from 'common/redux'; -import type { BooleanLike } from 'tgui-core/react'; - -import { setupDrag } from './drag'; -import { focusMap } from './focus'; -import { createLogger } from './logging'; -import { resumeRenderer, suspendRenderer } from './renderer'; - -const logger = createLogger('backend'); - -export let globalStore; - -export const setGlobalStore = (store) => { - globalStore = store; -}; - -export const backendUpdate = createAction('backend/update'); -export const backendSetSharedState = createAction('backend/setSharedState'); -export const backendSuspendStart = createAction('backend/suspendStart'); - -export const backendSuspendSuccess = () => ({ - type: 'backend/suspendSuccess', - payload: { - timestamp: Date.now(), - }, -}); - -const initialState = { - config: {}, - data: {}, - shared: {}, - // Start as suspended - suspended: Date.now(), - suspending: false, -}; - -export const backendReducer = (state = initialState, action) => { - const { type, payload } = action; - - if (type === 'backend/update') { - // Merge config - const config = { - ...state.config, - ...payload.config, - }; - // Merge data - const data = { - ...state.data, - ...payload.static_data, - ...payload.data, - }; - // Merge shared states - const shared = { ...state.shared }; - if (payload.shared) { - for (const key of Object.keys(payload.shared)) { - const value = payload.shared[key]; - if (value === '') { - shared[key] = undefined; - } else { - shared[key] = JSON.parse(value); - } - } - } - // Return new state - return { - ...state, - config, - data, - shared, - suspended: false, - }; - } - - if (type === 'backend/setSharedState') { - const { key, nextState } = payload; - return { - ...state, - shared: { - ...state.shared, - [key]: nextState, - }, - }; - } - - if (type === 'backend/suspendStart') { - return { - ...state, - suspending: true, - }; - } - - if (type === 'backend/suspendSuccess') { - const { timestamp } = payload; - return { - ...state, - data: {}, - shared: {}, - config: { - ...state.config, - title: '', - status: 1, - }, - suspending: false, - suspended: timestamp, - }; - } - - return state; -}; - -export const backendMiddleware = (store) => { - let fancyState; - let suspendInterval; - - return (next) => (action) => { - const { suspended } = selectBackend(store.getState()); - const { type, payload } = action; - - if (type === 'update') { - store.dispatch(backendUpdate(payload)); - return; - } - - if (type === 'suspend') { - store.dispatch(backendSuspendSuccess()); - return; - } - - if (type === 'ping') { - Byond.sendMessage('ping/reply'); - return; - } - - if (type === 'backend/suspendStart' && !suspendInterval) { - logger.log(`suspending (${Byond.windowId})`); - // Keep sending suspend messages until it succeeds. - // It may fail multiple times due to topic rate limiting. - const suspendFn = () => Byond.sendMessage('suspend'); - suspendFn(); - suspendInterval = setInterval(suspendFn, 2000); - } - - if (type === 'backend/suspendSuccess') { - suspendRenderer(); - clearInterval(suspendInterval); - suspendInterval = undefined; - Byond.winset(Byond.windowId, { - 'is-visible': false, - }); - setTimeout(() => focusMap()); - } - - if (type === 'backend/update') { - const fancy = payload.config?.window?.fancy; - // Initialize fancy state - if (fancyState === undefined) { - fancyState = fancy; - } - // React to changes in fancy - else if (fancyState !== fancy) { - logger.log('changing fancy mode to', fancy); - fancyState = fancy; - Byond.winset(Byond.windowId, { - titlebar: !fancy, - 'can-resize': !fancy, - }); - } - } - - // Resume on incoming update - if (type === 'backend/update' && suspended) { - // Show the payload - logger.log('backend/update', payload); - // Signal renderer that we have resumed - resumeRenderer(); - // Setup drag - setupDrag(); - // We schedule this for the next tick here because resizing and unhiding - // during the same tick will flash with a white background. - setTimeout(() => { - perf.mark('resume/start'); - // Doublecheck if we are not re-suspended. - const { suspended } = selectBackend(store.getState()); - if (suspended) { - return; - } - Byond.winset(Byond.windowId, { - 'is-visible': true, - }); - Byond.sendMessage('visible'); - perf.mark('resume/finish'); - if (process.env.NODE_ENV !== 'production') { - logger.log( - 'visible in', - perf.measure('render/finish', 'resume/finish'), - ); - } - }); - } - - return next(action); - }; -}; - -/** - * Sends an action to `ui_act` on `src_object` that this tgui window - * is associated with. - */ -export const sendAct = (action: string, payload: object = {}) => { - // Validate that payload is an object - // prettier-ignore - const isObject = - typeof payload === 'object' && payload !== null && !Array.isArray(payload); - if (!isObject) { - logger.error(`Payload for act() must be an object, got this:`, payload); - return; - } - Byond.sendMessage(`act/${action}`, payload); -}; - -type BackendState = { - config: { - title: string; - status: number; - interface: { - name: string; - layout: string; - }; - refreshing: boolean; - map: string; // Vorestation Add - mapZLevel: number; // Vorestation Add - mapInfo: { - maxx: number; // Vorestation Add - maxy: number; // Vorestation Add - }; // Vorestation Add - window: { - key: string; - size: [number, number]; - fancy: BooleanLike; - locked: BooleanLike; - scale: BooleanLike; - }; - client: { - ckey: string; - address: string; - computer_id: string; - }; - user: { - name: string; - observer: number; - }; - }; - data: TData; - shared: Record; - suspending: boolean; - suspended: boolean; -}; - -/** - * Selects a backend-related slice of Redux state - */ -export const selectBackend = (state: any): BackendState => - state.backend || {}; - -/** - * Get data from tgui backend. + * const { data } = useBackend(); * - * Includes the `act` function for performing DM actions. + * console.log(data.username); // You'll get type safety here + * ``` */ -export const useBackend = () => { - const state: BackendState = globalStore?.getState()?.backend; +export function useBackend< + TData extends Record, +>(): BackendState { + const state = store.get(backendStateAtom); return { - ...state, act: sendAct, + ...state, + data: state.data as TData, }; -}; +} /** * A tuple that contains the state and a setter function for it. @@ -296,26 +34,56 @@ export const useBackend = () => { type StateWithSetter = [T, (nextState: T) => void]; /** - * Allocates state on Redux store, and **shares** it with other clients + * Allocates state in the Jotai store without sharing it with other clients. + * + * Use it when you want to have a stateful variable in your component + * that persists between renders, but will be forgotten after you close + * the UI. + * + * It is a lot more performant than `setSharedState`. + * + * @param key Key which uniquely identifies this state in Zustand store. + * @param initialState Initializes your global variable with this value. + * @deprecated Use useState and useEffect when you can. Pass the state as a prop. + */ +export const useLocalState = ( + key: string, + initialState: TState, +): StateWithSetter => { + const sharedStates = store.get(sharedAtom); + const sharedState = key in sharedStates ? sharedStates[key] : initialState; + + return [ + sharedState, + (nextState) => { + store.set(sharedAtom, (prev) => ({ + ...prev, + [key]: nextState, + })); + }, + ]; +}; + +/** + * Allocates state on Jotai store, and **shares** it with other clients * in the game. * * Use it when you want to have a stateful variable in your component * that persists not only between renders, but also gets pushed to other * clients that observe this UI. * - * This makes creation of observable s + * This makes creation of observable UIs easier. * - * @param context React context. - * @param key Key which uniquely identifies this state in Redux store. + * @param key Key which uniquely identifies this state in Jotai store. * @param initialState Initializes your global variable with this value. */ -export const useSharedState = ( +export const useSharedState = ( key: string, - initialState: T, -): StateWithSetter => { - const state = globalStore?.getState()?.backend; - const sharedStates = state?.shared ?? {}; + initialState: TState, +): StateWithSetter => { + const sharedStates = store.get(sharedAtom); const sharedState = key in sharedStates ? sharedStates[key] : initialState; + return [ sharedState, (nextState) => { @@ -332,11 +100,3 @@ export const useSharedState = ( }, ]; }; - -export const useDispatch = () => { - return globalStore.dispatch; -}; - -export const useSelector = (selector: (state: any) => any) => { - return selector(globalStore?.getState()); -}; diff --git a/tgui/packages/tgui/debug/actions.ts b/tgui/packages/tgui/debug/actions.ts deleted file mode 100644 index 076fdb169b..0000000000 --- a/tgui/packages/tgui/debug/actions.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { createAction } from 'common/redux'; - -export const toggleKitchenSink = createAction('debug/toggleKitchenSink'); -export const toggleDebugLayout = createAction('debug/toggleDebugLayout'); -export const openExternalBrowser = createAction('debug/openExternalBrowser'); diff --git a/tgui/packages/tgui/debug/hooks.ts b/tgui/packages/tgui/debug/hooks.ts deleted file mode 100644 index 47199e9bc3..0000000000 --- a/tgui/packages/tgui/debug/hooks.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { useSelector } from 'tgui/backend'; -import { selectDebug } from './selectors'; - -export function useDebug() { - return useSelector(selectDebug); -} diff --git a/tgui/packages/tgui/debug/index.ts b/tgui/packages/tgui/debug/index.ts deleted file mode 100644 index 56ed11f34b..0000000000 --- a/tgui/packages/tgui/debug/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export { useDebug } from './hooks'; -export { KitchenSink } from './KitchenSink'; -export { debugMiddleware, relayMiddleware } from './middleware'; -export { debugReducer } from './reducer'; diff --git a/tgui/packages/tgui/debug/middleware.ts b/tgui/packages/tgui/debug/middleware.ts deleted file mode 100644 index e102362f8f..0000000000 --- a/tgui/packages/tgui/debug/middleware.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { globalEvents } from 'tgui-core/events'; -import { acquireHotKey } from 'tgui-core/hotkeys'; -import { KEY_BACKSPACE, KEY_F10, KEY_F11, KEY_F12 } from 'tgui-core/keycodes'; - -import { - openExternalBrowser, - toggleDebugLayout, - toggleKitchenSink, -} from './actions'; - -const relayedTypes = ['backend/update', 'chat/message']; - -export function debugMiddleware(store) { - acquireHotKey(KEY_F11); - acquireHotKey(KEY_F12); - globalEvents.on('keydown', (key) => { - if (key.code === KEY_F11) { - store.dispatch(toggleDebugLayout()); - } - if (key.code === KEY_F12) { - store.dispatch(toggleKitchenSink()); - } - if (key.ctrl && key.alt && key.code === KEY_BACKSPACE) { - // NOTE: We need to call this in a timeout, because we need a clean - // stack in order for this to be a fatal error. - setTimeout(() => { - throw new Error( - 'OOPSIE WOOPSIE!! UwU We made a fucky wucky!! A wittle' + - ' fucko boingo! The code monkeys at our headquarters are' + - ' working VEWY HAWD to fix this!', - ); - }); - } - }); - return (next) => (action) => next(action); -} - -export function relayMiddleware(store) { - const devServer = require('tgui-dev-server/link/client.ts'); - const externalBrowser = location.search === '?external'; - if (externalBrowser) { - devServer.subscribe((msg) => { - const { type, payload } = msg; - if (type === 'relay' && payload.windowId === Byond.windowId) { - store.dispatch({ - ...payload.action, - relayed: true, - }); - } - }); - } else { - acquireHotKey(KEY_F10); - globalEvents.on('keydown', (key) => { - if (key === KEY_F10) { - store.dispatch(openExternalBrowser()); - } - }); - } - return (next) => (action) => { - const { type, relayed } = action; - if (type === openExternalBrowser.type) { - window.open(`${location.href}?external`, '_blank'); - return; - } - if (relayedTypes.includes(type) && !relayed && !externalBrowser) { - devServer.sendMessage({ - type: 'relay', - payload: { - windowId: Byond.windowId, - action, - }, - }); - } - return next(action); - }; -} diff --git a/tgui/packages/tgui/debug/reducer.ts b/tgui/packages/tgui/debug/reducer.ts deleted file mode 100644 index 88d67953e5..0000000000 --- a/tgui/packages/tgui/debug/reducer.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -type DebugState = { - kitchenSink: boolean; - debugLayout: boolean; -}; - -export function debugReducer(state = {} as DebugState, action) { - const { type } = action; - if (type === 'debug/toggleKitchenSink') { - return { - ...state, - kitchenSink: !state.kitchenSink, - }; - } - if (type === 'debug/toggleDebugLayout') { - return { - ...state, - debugLayout: !state.debugLayout, - }; - } - return state; -} diff --git a/tgui/packages/tgui/debug/selectors.ts b/tgui/packages/tgui/debug/selectors.ts deleted file mode 100644 index 77d13f11b6..0000000000 --- a/tgui/packages/tgui/debug/selectors.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export function selectDebug(state: Record) { - return state.debug; -} diff --git a/tgui/packages/tgui/debug/use-debug.ts b/tgui/packages/tgui/debug/use-debug.ts new file mode 100644 index 0000000000..1f2f73edad --- /dev/null +++ b/tgui/packages/tgui/debug/use-debug.ts @@ -0,0 +1,27 @@ +import { globalEvents } from 'tgui-core/events'; +import { acquireHotKey } from 'tgui-core/hotkeys'; +import { KEY_BACKSPACE, KEY_F11, KEY_F12 } from 'tgui-core/keycodes'; +import { kitchenSinkAtom, store } from '../events/store'; + +export function setDebugHotKeys(): void { + acquireHotKey(KEY_F11); + acquireHotKey(KEY_F12); // Just to avoid the HUD disappearing on F12 + + globalEvents.on('keydown', (evt) => { + if (evt.code === KEY_F11) { + store.set(kitchenSinkAtom, (prev) => !prev); + } + + if (evt.ctrl && evt.alt && evt.code === KEY_BACKSPACE) { + // NOTE: We need to call this in a timeout, because we need a clean + // stack in order for this to be a fatal error. + setTimeout(() => { + throw new Error( + 'OOPSIE WOOPSIE!! UwU We made a fucky wucky!! A wittle' + + ' fucko boingo! The code monkeys at our headquarters are' + + ' working VEWY HAWD to fix this!', + ); + }); + } + }); +} diff --git a/tgui/packages/tgui/events/README.md b/tgui/packages/tgui/events/README.md new file mode 100644 index 0000000000..07904f29b6 --- /dev/null +++ b/tgui/packages/tgui/events/README.md @@ -0,0 +1,113 @@ +# Tgui event dispatch + +## Philosophy + +In the previous TGUI backend state, both DM messages and UI actions were handled through an event message system using actions, selectors, reducers, and middleware. This event system is designed to handle only DM messages - separating UI actions into direct state access (eg setSomeState(true)) or helpers (eg updateSetting({ thing: true})). + +The idea behind this was to reduce the amount of abstractions needed and draw a clear line between what is a server message and what is a UI action. + +There are three layers to this system: + +1. The event bus, which maintains the list of handlers. +2. The handlers, which delegate backend calls and update application state. +3. The store, ie the application state. + +## Usage + +### 1. Creating your event handler: + +> events/handlers/someFunc.ts + +```ts +type ExpectedPayload = { + greeting: string; +}; + +export function someFunc(payload: ExpectedPayload) { + // Do something with the payload + logger.log('Received: ', payload.greeting); +} +``` + +### 2. Building the event handler: + +> events/listeners.ts + +```ts +import { someFunc } from 'handlers/someFunc'; + +export const listeners = { + eventType: someFunc, +} as const; +``` + +You can even shorten this by naming your function the same as the event type. An event of type 'myType' would call the myType function directly, + +```ts +export function myType(payload: MyPayload) { + logger.log('whatever!'); +} + +export const listeners = { + myType, +}; +``` + +### 3. Setting up your event dispatch + +> index.tsx + +```ts +import { EventBus } from 'common/eventbus'; + +const listeners = { + myType, + 'some/Thing': someThingHandler, // valid + 'other/Thing': () => store.setState(true); // technically okay but can get messy :). +}; + +export const busName = new EventBus(listeners); +``` + +### 4. Subscribe to byond events + +> index.tsx + +```ts +/// somewhere in setup logic +Byond.subscribe((type, payload) => busName.dispatch({ type, payload } as any)); +``` + +## Converting the old system over + +This process takes time! But it's not impossible. For the most part, backend calls can be directly extracted into a function with similar state management. + +> middleware.js +> Ex: + +```ts +if (type === 'audio/playMusic') { + const { url, ...options } = payload; + player.play(url, options); + return next(action); +} +``` + +Becomes: + +```ts +function audioPlayMusic(payload: PayloadType): void { + const { url, ...options } = payload; + player.play(url, options); + + // no need to call next, it's all handled here +} +``` + +### When to use store.set/get vs useAtom + +Functionally, there is no difference. If it makes it simpler, you can leave UI actions as external javascript helpers with store.set/get. I've only wrapped functions into React components as the 'technically correct' way to do things. The React way tends to be more concise in the long run. + +### When to create handlers vs direct state access + +Is it a backend call from DM? Put it on the event bus! If it isn't, there's no reason not to have your UI directly interface with a function or the state directly. diff --git a/tgui/packages/tgui/events/act.ts b/tgui/packages/tgui/events/act.ts new file mode 100644 index 0000000000..081e17cda2 --- /dev/null +++ b/tgui/packages/tgui/events/act.ts @@ -0,0 +1,101 @@ +import { logger } from '../logging'; +import { createQueue } from './handlers/chunking'; + +/** + * Sends an action to `ui_act` on `src_object` that this tgui window + * is associated with. + */ +export function sendAct( + action: string, + payload: Record = {}, +): void { + // Validate that payload is an object + const isObject = + typeof payload === 'object' && payload !== null && !Array.isArray(payload); + if (!isObject) { + logger.error(`Payload for act() must be an object, got this:`, payload); + return; + } + + const stringifiedPayload = JSON.stringify(payload); + const urlSize = Object.entries({ + type: `act/${action}`, + payload: stringifiedPayload, + tgui: 1, + windowId: Byond.windowId, + }).reduce( + (url, [key, value], i) => + url + + `${i > 0 ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + '', + ).length; + + if (urlSize > 2048) { + const chunks: string[] = stringifiedPayload.split(chunkSplitter); + const id = `${Date.now()}`; + createQueue({ id, chunks }); + Byond.sendMessage('oversizedPayloadRequest', { + type: `act/${action}`, + id, + chunkCount: chunks.length, + }); + return; + } + + Byond.sendMessage(`act/${action}`, payload); +} + +function encodedLengthBinarySearch(haystack: string[], length: number): number { + const haystackLength = haystack.length; + let high = haystackLength - 1; + let low = 0; + let mid = 0; + + while (low < high) { + mid = Math.round((low + high) / 2); + const substringLength = encodeURIComponent( + haystack.slice(0, mid).join(''), + ).length; + if (substringLength === length) { + break; + } + if (substringLength < length) { + low = mid + 1; + } else { + high = mid - 1; + } + } + + return mid; +} + +const chunkSplitter = { + [Symbol.split]: (string: string) => { + const charSeq = string[Symbol.iterator]().toArray(); + const length = charSeq.length; + const chunks: string[] = []; + let startIndex = 0; + let endIndex = 1024; + while (startIndex < length) { + const cut = charSeq.slice( + startIndex, + endIndex < length ? endIndex : undefined, + ); + const cutString = cut.join(''); + if (encodeURIComponent(cutString).length > 1024) { + const splitIndex = startIndex + encodedLengthBinarySearch(cut, 1024); + chunks.push( + charSeq + .slice(startIndex, splitIndex < length ? splitIndex : undefined) + .join(''), + ); + startIndex = splitIndex; + } else { + chunks.push(cutString); + startIndex = endIndex; + } + endIndex = startIndex + 1024; + } + return chunks; + }, +}; diff --git a/tgui/packages/tgui/events/handlers/assets.ts b/tgui/packages/tgui/events/handlers/assets.ts new file mode 100644 index 0000000000..89b32ce07e --- /dev/null +++ b/tgui/packages/tgui/events/handlers/assets.ts @@ -0,0 +1,31 @@ +import { loadMappings } from 'common/assets'; +import { fetchRetry } from 'tgui-core/http'; +import { loadedMappings } from '../../assets'; + +/// --------- Handlers ------------------------------------------------------/// + +export function handleLoadAssets(payload: Record): void { + loadMappings(payload, loadedMappings); + + if ( + 'icon_ref_map.json' in payload && + Byond.iconRefMap && + Object.keys(Byond.iconRefMap).length === 0 + ) { + fetchRetry(payload['icon_ref_map.json']) + .then((res) => res.json()) + .then(setIconRefMap) + .catch(console.error); + } +} + +export function getIconFromRefMap(icon: string): string | undefined { + return Byond.iconRefMap[icon]; +} + +/// --------- Helpers -------------------------------------------------------/// + +// https://biomejs.dev/linter/rules/no-assign-in-expressions/ +function setIconRefMap(map: Record): void { + Byond.iconRefMap = map; +} diff --git a/tgui/packages/tgui/events/handlers/chunking.ts b/tgui/packages/tgui/events/handlers/chunking.ts new file mode 100644 index 0000000000..4e467fd889 --- /dev/null +++ b/tgui/packages/tgui/events/handlers/chunking.ts @@ -0,0 +1,66 @@ +import { chunkingAtom, store } from '../store'; + +/// --------- Handlers ------------------------------------------------------/// + +type OversizePayload = { + allow: boolean; + id: string; +}; + +export function oversizePayloadResponse(payload: OversizePayload): void { + const { allow, id } = payload; + + if (allow) { + nextChunk(id); + } else { + store.set(chunkingAtom, (prev) => { + const { [id]: _, ...otherQueues } = prev; + return otherQueues; + }); + } +} + +export function acknowledgePayloadChunk(payload: OversizePayload): void { + const { id } = payload; + + store.set(chunkingAtom, (prev) => { + const { [id]: targetQueue, ...otherQueues } = prev; + const [_, ...rest] = targetQueue || []; + + return rest.length + ? { + ...otherQueues, + [id]: rest, + } + : otherQueues; + }); + nextChunk(id); +} + +/// --------- Helpers -------------------------------------------------------/// + +function nextChunk(id: string): void { + const queues = store.get(chunkingAtom); + const chunk = queues[id]?.[0]; + + if (chunk) { + Byond.sendMessage('payloadChunk', { + id, + chunk, + }); + } +} + +type CreateQueueParams = { + id: string; + chunks: string[]; +}; + +export function createQueue(payload: CreateQueueParams): void { + const { id, chunks } = payload; + + store.set(chunkingAtom, (prev) => ({ + ...prev, + [id]: chunks, + })); +} diff --git a/tgui/packages/tgui/events/handlers/ping.ts b/tgui/packages/tgui/events/handlers/ping.ts new file mode 100644 index 0000000000..1fef71bacc --- /dev/null +++ b/tgui/packages/tgui/events/handlers/ping.ts @@ -0,0 +1,5 @@ +/// --------- Handlers ------------------------------------------------------/// + +export function ping(): void { + Byond.sendMessage('ping/reply'); +} diff --git a/tgui/packages/tgui/events/handlers/suspense.ts b/tgui/packages/tgui/events/handlers/suspense.ts new file mode 100644 index 0000000000..7d7a352d73 --- /dev/null +++ b/tgui/packages/tgui/events/handlers/suspense.ts @@ -0,0 +1,55 @@ +import { focusMap } from '../../focus'; +import { logger } from '../../logging'; +import { suspendRenderer } from '../../renderer'; +import { + configAtom, + resetStore, + store, + suspendedAtom, + suspendingAtom, +} from '../store'; + +/// --------- Handlers ------------------------------------------------------/// + +let suspendInterval: NodeJS.Timeout | null = null; + +/** Resets all state and refocuses byond window */ +export function suspend(): void { + suspendRenderer(); + resetStore(); + + if (suspendInterval) clearInterval(suspendInterval); + + store.set(configAtom, (prev) => ({ + ...prev, + title: '', + status: 1, + })); + store.set(suspendingAtom, false); + store.set(suspendedAtom, Date.now()); + + Byond.winset(Byond.windowId, { + 'is-visible': false, + }); + + focusMap(); +} + +/// --------- Helpers -------------------------------------------------------/// + +const TWO_SECONDS = 2000; + +function suspendMsg(): void { + Byond.sendMessage('suspend'); +} + +/** Signals Byond to dismiss the window */ +export function suspendStart(): void { + if (suspendInterval) clearInterval(suspendInterval); + + store.set(suspendingAtom, true); + + logger.log(`suspending (${Byond.windowId})`); + suspendMsg(); + suspendInterval = setInterval(suspendMsg, TWO_SECONDS); +} diff --git a/tgui/packages/tgui/events/handlers/update.ts b/tgui/packages/tgui/events/handlers/update.ts new file mode 100644 index 0000000000..8bde870fe6 --- /dev/null +++ b/tgui/packages/tgui/events/handlers/update.ts @@ -0,0 +1,100 @@ +import { perf } from 'common/perf'; +import { setupDrag } from '../../drag'; +import { logger } from '../../logging'; +import { resumeRenderer } from '../../renderer'; +import { + configAtom, + gameDataAtom, + gameStaticDataAtom, + sharedAtom, + store, + suspendedAtom, +} from '../store'; +import type { BackendState } from '../types'; + +/// --------- Handlers ------------------------------------------------------/// + +type UpdatePayload = Omit>, 'act'> & { + static_data: Record; +}; + +export function update(payload: UpdatePayload): void { + if (store.get(suspendedAtom)) { + resume(payload); + store.set(suspendedAtom, false); + } + updateData(payload); +} + +/// --------- Helpers -------------------------------------------------------/// + +/** Resumes the tgui window if suspended */ +function resume(payload: UpdatePayload): void { + // Show the payload + logger.log('Resuming:', payload); + // Signal renderer that we have resumed + resumeRenderer(); + // Setup drag + setupDrag(); + // We schedule this for the next tick here because resizing and unhiding + // during the same tick will flash with a white background. + setTimeout(() => { + perf.mark('resume/start'); + // Doublecheck if we are not re-suspended. + if (store.get(suspendedAtom)) { + return; + } + + Byond.winset(Byond.windowId, { + 'is-visible': true, + }); + Byond.sendMessage('visible'); + perf.mark('resume/finish'); + + if (process.env.NODE_ENV !== 'production') { + logger.log('visible in', perf.measure('render/finish', 'resume/finish')); + } + }); +} + +/** Delegates update data to the appropriate store */ +function updateData(payload: UpdatePayload): void { + if (payload.config) { + store.set(configAtom, (prev) => ({ + ...prev, + ...payload.config, + })); + } + + if (payload.static_data) { + store.set(gameStaticDataAtom, (prev) => ({ + ...prev, + ...payload.static_data, + })); + } + + if (payload.data) { + store.set(gameDataAtom, (prev) => ({ + ...prev, + ...payload.data, + })); + } + + if (payload.shared) { + const newShared = {} as Record; + + for (const key in payload.shared) { + const value = payload.shared[key]; + if (value === '') { + newShared[key] = undefined; + } else { + newShared[key] = JSON.parse(value); + } + } + + store.set(sharedAtom, (prev) => ({ + ...prev, + ...newShared, + })); + } +} diff --git a/tgui/packages/tgui/events/listeners.ts b/tgui/packages/tgui/events/listeners.ts new file mode 100644 index 0000000000..928282b9d5 --- /dev/null +++ b/tgui/packages/tgui/events/listeners.ts @@ -0,0 +1,29 @@ +import { loadStyleSheet } from 'common/assets'; +import { EventBus } from 'tgui-core/eventbus'; +import { handleLoadAssets } from './handlers/assets'; +import { + acknowledgePayloadChunk, + oversizePayloadResponse, +} from './handlers/chunking'; +import { ping } from './handlers/ping'; +import { suspend } from './handlers/suspense'; +import { update } from './handlers/update'; + +/** + * A string/handler map. + * Ideally, these reference a function named after the respective event type. + */ +const listeners = { + // Assets + 'asset/mappings': handleLoadAssets, + 'asset/stylesheet': loadStyleSheet, + // Standard window events + ping, + suspend, + update, + // Chunking + oversizePayloadResponse, + acknowledgePayloadChunk, +} as const; + +export const bus = new EventBus(listeners); diff --git a/tgui/packages/tgui/events/store.ts b/tgui/packages/tgui/events/store.ts new file mode 100644 index 0000000000..3127eb86e7 --- /dev/null +++ b/tgui/packages/tgui/events/store.ts @@ -0,0 +1,37 @@ +import { atom, createStore } from 'jotai'; +import type { Config } from './types'; + +export const chunkingAtom = atom>({}); +export const configAtom = atom({} as Config); +export const debugLayoutAtom = atom(false); +export const gameDataAtom = atom>({}); +export const gameStaticDataAtom = atom>({}); +export const kitchenSinkAtom = atom(false); +export const sharedAtom = atom>({}); +export const suspendedAtom = atom(Date.now()); // Start as suspended +export const suspendingAtom = atom(false); + +export const backendStateAtom = atom((get) => ({ + config: get(configAtom), + data: { + ...get(gameDataAtom), + ...get(gameStaticDataAtom), + }, + debug: { + debugLayout: get(debugLayoutAtom), + kitchenSink: get(kitchenSinkAtom), + }, + outgoingPayloadQueues: get(chunkingAtom), + shared: get(sharedAtom), + staticData: get(gameStaticDataAtom), + suspended: get(suspendedAtom), + suspending: get(suspendingAtom), +})); + +export const store = createStore(); + +export function resetStore() { + store.set(gameDataAtom, {}); + store.set(gameStaticDataAtom, {}); + store.set(sharedAtom, {}); +} diff --git a/tgui/packages/tgui/events/types.ts b/tgui/packages/tgui/events/types.ts new file mode 100644 index 0000000000..3c94e78feb --- /dev/null +++ b/tgui/packages/tgui/events/types.ts @@ -0,0 +1,54 @@ +import type { ExtractAtomValue } from 'jotai'; +import type { sendAct } from 'tgui/events/act'; +import type { backendStateAtom } from './store'; + +type BinaryIO = 0 | 1; + +type Client = { + address: string; + ckey: string; + computer_id: string; +}; + +type IFace = { + layout: string; + name: string; +}; + +type TguiWindow = { + fancy: BinaryIO; + key: string; + locked: BinaryIO; + scale: BinaryIO; + size: [number, number]; +}; + +type User = { + name: string; + observer: number; +}; + +type MapData = { maxx: number; maxy: number }; + +export type Config = { + client: Client; + interface: IFace; + refreshing: BinaryIO; + status: number; + map: string; + mapZLevel: number; + mapInfo: MapData; + title: string; + user: User; + window: TguiWindow; +}; + +export type DebugState = { + debugLayout: boolean; + kitchenSink: boolean; +}; + +export type BackendState = ExtractAtomValue & { + act: typeof sendAct; + data: TData; +}; diff --git a/tgui/packages/tgui/hotkeys.ts b/tgui/packages/tgui/hotkeys.ts deleted file mode 100644 index be9d92ee86..0000000000 --- a/tgui/packages/tgui/hotkeys.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { globalEvents, type KeyEvent } from 'tgui-core/events'; -import * as keycodes from 'tgui-core/keycodes'; - -// BYOND macros, in `key: command` format. -const byondMacros: Record = {}; - -// Default set of acquired keys, which will not be sent to BYOND. -const hotKeysAcquired = [ - keycodes.KEY_ESCAPE, - keycodes.KEY_ENTER, - keycodes.KEY_SPACE, - keycodes.KEY_TAB, - keycodes.KEY_CTRL, - keycodes.KEY_SHIFT, - keycodes.KEY_UP, - keycodes.KEY_DOWN, - keycodes.KEY_LEFT, - keycodes.KEY_RIGHT, - keycodes.KEY_F5, -]; - -// State of passed-through keys. -const keyState: Record = {}; - -// Custom listeners for key events -const keyListeners: ((key: KeyEvent) => void)[] = []; - -/** - * Converts a browser keycode to BYOND keycode. - */ -function keyCodeToByond(keyCode: number) { - if (keyCode === 16) return 'Shift'; - if (keyCode === 17) return 'Ctrl'; - if (keyCode === 18) return 'Alt'; - if (keyCode === 33) return 'Northeast'; - if (keyCode === 34) return 'Southeast'; - if (keyCode === 35) return 'Southwest'; - if (keyCode === 36) return 'Northwest'; - if (keyCode === 37) return 'West'; - if (keyCode === 38) return 'North'; - if (keyCode === 39) return 'East'; - if (keyCode === 40) return 'South'; - if (keyCode === 45) return 'Insert'; - if (keyCode === 46) return 'Delete'; - - if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) { - return String.fromCharCode(keyCode); - } - if (keyCode >= 96 && keyCode <= 105) { - return `Numpad${keyCode - 96}`; - } - if (keyCode >= 112 && keyCode <= 123) { - return `F${keyCode - 111}`; - } - if (keyCode === 188) return ','; - if (keyCode === 189) return '-'; - if (keyCode === 190) return '.'; -} - -/** - * Keyboard passthrough logic. This allows you to keep doing things - * in game while the browser window is focused. - */ -function handlePassthrough(key: KeyEvent) { - const keyString = String(key); - // In addition to F5, support reloading with Ctrl+R and Ctrl+F5 - if (keyString === 'Ctrl+F5' || keyString === 'Ctrl+R') { - location.reload(); - return; - } - // Prevent passthrough on Ctrl+F - if (keyString === 'Ctrl+F') { - return; - } - // NOTE: Alt modifier is pretty bad and sticky in IE11. - - if ( - key.event.defaultPrevented || - key.isModifierKey() || - hotKeysAcquired.includes(key.code) || - key.repeat // no repeating - ) { - return; - } - const byondKeyCode = keyCodeToByond(key.code); - if (!byondKeyCode) { - return; - } - let byondKeyCodeIdent = byondKeyCode; - if (key.isUp()) { - byondKeyCodeIdent += '+UP'; - } - // Macro - const macro = byondMacros[byondKeyCodeIdent]; - if (macro) { - return Byond.command(macro); - } - // KeyDown - if (key.isDown() && !keyState[byondKeyCode]) { - keyState[byondKeyCode] = true; - const command = keyPassthroughConfig.verbParamsFn( - keyPassthroughConfig.keyDownVerb, - byondKeyCode, - ); - return Byond.command(command); - } - // KeyUp - if (key.isUp() && keyState[byondKeyCode]) { - keyState[byondKeyCode] = false; - const command = keyPassthroughConfig.verbParamsFn( - keyPassthroughConfig.keyUpVerb, - byondKeyCode, - ); - return Byond.command(command); - } -} - -/** - * Acquires a lock on the hotkey, which prevents it from being - * passed through to BYOND. - */ -export function acquireHotKey(keyCode: number) { - hotKeysAcquired.push(keyCode); -} - -/** - * Makes the hotkey available to BYOND again. - */ -export function releaseHotKey(keyCode: number) { - const index = hotKeysAcquired.indexOf(keyCode); - if (index >= 0) { - hotKeysAcquired.splice(index, 1); - } -} - -export function releaseHeldKeys() { - for (const byondKeyCode in keyState) { - if (keyState[byondKeyCode]) { - keyState[byondKeyCode] = false; - Byond.command( - keyPassthroughConfig.verbParamsFn( - keyPassthroughConfig.keyUpVerb, - byondKeyCode, - ), - ); - } - } -} - -type ByondSkinMacro = { - command: string; - name: string; -}; - -let keyPassthroughConfig: KeyPassthroughConfig = { - keyDownVerb: 'KeyDown', - keyUpVerb: 'KeyUp', - verbParamsFn: (verb, keyCode) => `${verb} "${keyCode}"`, -}; - -export type KeyPassthroughConfig = { - keyUpVerb: string; - keyDownVerb: string; - verbParamsFn: (verb: string, keyCode: string) => string; -}; - -export function setupHotKeys(config?: KeyPassthroughConfig) { - if (config) { - keyPassthroughConfig = config; - } - // Read macros - Byond.winget(null, 'macros').then((data: string) => { - const separated = data.split(';'); - - const promises: Promise[] = []; - for (const set of separated) { - promises.push(Byond.winget(`${set}.*`)); - } - - Promise.all(promises).then((sets: Record[]) => { - // Group each macro by ref - const groupedByRef: Record = {}; - for (const set of sets) { - for (const key of Object.keys(set)) { - const keyPath = key.split('.'); - const ref = keyPath[1]; - const prop = keyPath[2]; - - if (ref && prop) { - // This piece of code imperatively adds each property to a - // ByondSkinMacro object in the order we meet it, which is hard - // to express safely in typescript. - if (!groupedByRef[ref]) { - groupedByRef[ref] = {} as any; - } - groupedByRef[ref][prop] = set[key]; - } - } - } - - // Insert macros - const escapedQuotRegex = /\\"/g; - - function unEscape(str: string) { - return str.substring(1, str.length - 1).replace(escapedQuotRegex, '"'); - } - - for (const ref of Object.keys(groupedByRef)) { - const macro = groupedByRef[ref]; - const byondKeyName = unEscape(macro.name); - byondMacros[byondKeyName] = unEscape(macro.command); - } - }); - }); - - // Setup event handlers - globalEvents.on('window-blur', () => { - releaseHeldKeys(); - }); - globalEvents.on('input-focus', () => { - releaseHeldKeys(); - }); - startKeyPassthrough(); -} - -export function startKeyPassthrough() { - globalEvents.on('key', keyEvent); -} - -export function stopKeyPassthrough() { - globalEvents.off('key', keyEvent); -} - -function keyEvent(key: KeyEvent) { - for (const keyListener of keyListeners) { - keyListener(key); - } - handlePassthrough(key); -} - -/** - * Registers for any key events, such as key down or key up. - * This should be preferred over directly connecting to keydown/keyup - * as it lets tgui prevent the key from reaching BYOND. - * - * If using in a component, prefer KeyListener, which automatically handles - * stopping listening when unmounting. - * - * @param callback The function to call whenever a key event occurs - * @returns A callback to stop listening - */ -export function listenForKeyEvents(callback: (key: KeyEvent) => void) { - keyListeners.push(callback); - - let removed = false; - - return () => { - if (removed) { - return; - } - - removed = true; - keyListeners.splice(keyListeners.indexOf(callback), 1); - }; -} diff --git a/tgui/packages/tgui/index.tsx b/tgui/packages/tgui/index.tsx index 39910c3534..0e1c1829bc 100644 --- a/tgui/packages/tgui/index.tsx +++ b/tgui/packages/tgui/index.tsx @@ -32,21 +32,15 @@ import './styles/themes/abstract.scss'; import './styles/themes/bingle.scss'; import './styles/themes/algae.scss'; -import { perf } from 'common/perf'; import { setupGlobalEvents } from 'tgui-core/events'; +import { captureExternalLinks } from 'tgui-core/links'; import { setupHotReloading } from 'tgui-dev-server/link/client'; import { App } from './App'; -import { setGlobalStore } from './backend'; -import { setupHotKeys } from './hotkeys'; -import { captureExternalLinks } from './links'; +import { setDebugHotKeys } from './debug/use-debug'; +import { bus } from './events/listeners'; import { render } from './renderer'; -import { configureStore } from './store'; - -perf.mark('inception', window.performance?.timeOrigin); -perf.mark('init'); - -const store = configureStore(); +import { createStackAugmentor } from './stack'; function setupApp() { // Delay setup @@ -55,25 +49,22 @@ function setupApp() { return; } - setGlobalStore(store); + window.__augmentStack__ = createStackAugmentor(); setupGlobalEvents(); - setupHotKeys(); captureExternalLinks(); - store.subscribe(() => render()); + Byond.subscribe((type, payload) => bus.dispatch({ type, payload })); // Dispatch incoming messages as store actions - Byond.subscribe((type, payload) => store.dispatch({ type, payload })); + render(); // Enable hot module reloading if (import.meta.webpackHot) { + setDebugHotKeys(); setupHotReloading(); - import.meta.webpackHot.accept( - ['./debug', './layouts', './routes', './App'], - () => { - render(); - }, + import.meta.webpackHot.accept(['./layouts', './routes', './App'], () => + render(), ); } } diff --git a/tgui/packages/tgui/interfaces/Changelog.tsx b/tgui/packages/tgui/interfaces/Changelog.tsx index 5ab64c3471..924b885890 100644 --- a/tgui/packages/tgui/interfaces/Changelog.tsx +++ b/tgui/packages/tgui/interfaces/Changelog.tsx @@ -14,6 +14,7 @@ import { Table, } from 'tgui-core/components'; import { classes } from 'tgui-core/react'; +import { sendAct as act } from '../events/act'; const icons = { add: { icon: 'check-circle', color: 'green' }, @@ -77,7 +78,6 @@ export class Changelog extends Component { } getData = (date, attemptNumber = 1) => { - const { act } = useBackend(); const self = this; const maxAttempts = 6; diff --git a/tgui/packages/tgui/interfaces/ChemDispenser/ChemDispenserSettings.tsx b/tgui/packages/tgui/interfaces/ChemDispenser/ChemDispenserSettings.tsx index d2fcabdb8f..735cf2915b 100644 --- a/tgui/packages/tgui/interfaces/ChemDispenser/ChemDispenserSettings.tsx +++ b/tgui/packages/tgui/interfaces/ChemDispenser/ChemDispenserSettings.tsx @@ -21,10 +21,7 @@ export const ChemDispenserSettings = (props: { const { selectedAmount, availableAmounts, minAmount, maxAmount, amountAct } = props; return ( -
+
diff --git a/tgui/packages/tgui/interfaces/Pda/index.tsx b/tgui/packages/tgui/interfaces/Pda/index.tsx index b8c832281e..8fb241bace 100644 --- a/tgui/packages/tgui/interfaces/Pda/index.tsx +++ b/tgui/packages/tgui/interfaces/Pda/index.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { useBackend } from 'tgui/backend'; import { Window } from 'tgui/layouts'; +import { RoutingErrorWindow } from 'tgui/routes'; /* This is all basically stolen from routes.js. */ -import { routingError } from 'tgui/routes'; import { Box, Button, @@ -33,20 +33,24 @@ type Data = { const requirePdaInterface = require.context('./pda_screens', false, /\.tsx$/); -function getPdaApp(name: string) { +function getPdaApp(name: string): () => React.JSX.Element { let appModule: __WebpackModuleApi.RequireContext; + try { appModule = requirePdaInterface(`./${name}.tsx`); - } catch (err) { + } catch (err: any) { if (err.code === 'MODULE_NOT_FOUND') { - return routingError('notFound', name); + return () => ; } throw err; } - const Component: () => React.JSX.Element = appModule[name]; + + const Component = appModule[name] as (() => React.JSX.Element) | undefined; + if (!Component) { - return routingError('missingExport', name); + return () => ; } + return Component; } diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/SubtabBody.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/SubtabBody.tsx index 475593c262..6fa3424464 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/SubtabBody.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/SubtabBody.tsx @@ -27,7 +27,6 @@ import { is_organ, OrganStatus, proper_organ_name, - type Species, } from './data'; export const SubtabBody = (props: { @@ -55,7 +54,7 @@ export const SubtabBody = (props: { } = data; const { species: species_list } = serverData; // if it's not there our entire UI is fucked anyways - const our_species = species_list.find((x) => x.name === species) as Species; + const our_species = species_list.find((x) => x.name === species); const { act } = useBackend(); const [visiblePopup, setVisiblePopup] = useState(BodyPopup.None); @@ -70,6 +69,8 @@ export const SubtabBody = (props: { ); } + const appearanceFlags = our_species?.appearance_flags ?? 0; + return ( @@ -94,8 +95,7 @@ export const SubtabBody = (props: { - {our_species.appearance_flags & - AppearanceFlags.HAS_EYE_COLOR ? ( + {appearanceFlags & AppearanceFlags.HAS_EYE_COLOR ? ( ) : null} - {our_species.appearance_flags & - AppearanceFlags.HAS_SKIN_COLOR ? ( + {appearanceFlags & AppearanceFlags.HAS_SKIN_COLOR ? ( diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/SubtabInfo.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/SubtabInfo.tsx index 44ded063de..062343f2aa 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/SubtabInfo.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/SubtabInfo.tsx @@ -30,18 +30,18 @@ export const GenderButton = (props: { placement="right-end" content={ - {Object.keys(Gender).map((x) => ( + {Object.values(Gender).map((x) => ( ); case TraitPrefType.TRAIT_PREF_TYPE_COLOR: diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Facial.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Facial.tsx index 1fed9ff5c6..0bbc9f9645 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Facial.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Facial.tsx @@ -1,7 +1,7 @@ import { type PropsWithChildren, useCallback, useState } from 'react'; import { useBackend } from 'tgui/backend'; +import { getIconFromRefMap } from 'tgui/events/handlers/assets'; import { Button, ImageButton, Input, Section } from 'tgui-core/components'; - import { ColorizedImageButton, ColorPicker, @@ -31,9 +31,9 @@ export const FacialImageButton = ( const renderHuman = useCallback( async (ctx: OffscreenCanvasRenderingContext2D) => { ctx.globalCompositeOperation = 'destination-over'; - const image = await getImage( - `${Byond.iconRefMap['icons/mob/human.dmi']}?state=body_f_s&dir=2`, - ); + const iconRef = getIconFromRefMap('icons/mob/human.dmi'); + if (!iconRef) return; + const image = await getImage(`${iconRef}?state=body_f_s&dir=2`); ctx.drawImage(image, 0, 0, 32, 10, 0, 0, 64, 20); }, diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Hair.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Hair.tsx index 96cf902433..9429870ea0 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Hair.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Hair.tsx @@ -1,7 +1,7 @@ import { type PropsWithChildren, useCallback, useState } from 'react'; import { useBackend } from 'tgui/backend'; +import { getIconFromRefMap } from 'tgui/events/handlers/assets'; import { Button, ImageButton, Input, Section } from 'tgui-core/components'; - import { ColorizedImageButton, ColorPicker, @@ -31,9 +31,9 @@ export const HairImageButton = ( const renderHuman = useCallback( async (ctx: OffscreenCanvasRenderingContext2D) => { ctx.globalCompositeOperation = 'destination-over'; - const image = await getImage( - `${Byond.iconRefMap['icons/mob/human.dmi']}?state=body_f_s&dir=2`, - ); + const iconRef = getIconFromRefMap('icons/mob/human.dmi'); + if (!iconRef) return; + const image = await getImage(`${iconRef}?state=body_f_s&dir=2`); ctx.drawImage(image, 0, 0, 32, 10, 0, 0, 64, 20); }, diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Markings.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Markings.tsx index af6d9ab2ad..5723448d48 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Markings.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/general/body/Markings.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useBackend } from 'tgui/backend'; +import { getIconFromRefMap } from 'tgui/events/handlers/assets'; import { Box, Button, @@ -13,7 +14,6 @@ import { } from 'tgui-core/components'; import type { BooleanLike } from 'tgui-core/react'; import { capitalize } from 'tgui-core/string'; - import { ColorizedImage, ColorizedImageButton, @@ -115,9 +115,12 @@ export const MarkingsPopup = (props: { postRender={async (ctx) => { if (showHuman) { ctx.globalCompositeOperation = 'destination-over'; + const iconRef = getIconFromRefMap( + 'icons/mob/human.dmi', + ); + if (!iconRef) return; const background = await getImage( - Byond.iconRefMap['icons/mob/human.dmi'] + - '?state=body_m_s&dir=2', + `${iconRef}?state=body_m_s&dir=2`, ); ctx.drawImage(background, 0, 0, 64, 64); } @@ -361,9 +364,10 @@ export const AddMarkingWindow = (props: { if (showHuman) { ctx.save(); ctx.globalCompositeOperation = 'destination-over'; + const iconRef = getIconFromRefMap('icons/mob/human.dmi'); + if (!iconRef) return; const background = await getImage( - Byond.iconRefMap['icons/mob/human.dmi'] + - '?state=body_m_s&dir=2', + `${iconRef}?state=body_m_s&dir=2`, ); ctx.drawImage(background, 0, 0, 64, 64); ctx.restore(); diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/helper_components.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/helper_components.tsx index 5e3f2d03f7..47e73a71ec 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/helper_components.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/bay_prefs/helper_components.tsx @@ -13,8 +13,8 @@ export const getImage = async (url: string): Promise => { image.onload = () => { resolve(image); }; - image.onerror = (error) => { - reject(error); + image.onerror = (event) => { + reject(event); }; image.src = url; }); @@ -55,7 +55,7 @@ export const CanvasBackedImage = (props: { }; }, [props.render]); - return ; + return bitmap ? : null; }; export const drawColorizedIconToOffscreenCanvas = async ( diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts index 279a9faf4c..22f4763256 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts @@ -1,4 +1,4 @@ -import type { sendAct } from 'tgui/backend'; +import type { sendAct } from 'tgui/events/act'; import type { LegacyConstant } from './bay_prefs/data'; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx index eeb912e640..8131dac3da 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx @@ -5,7 +5,8 @@ import { useEffect, useState, } from 'react'; -import { type sendAct, useBackend } from 'tgui/backend'; +import { useBackend } from 'tgui/backend'; +import type { sendAct } from 'tgui/events/act'; import { Box, Button, diff --git a/tgui/packages/tgui/interfaces/PublicLibraryWiki/WikiCommon/WikiColorIcon.tsx b/tgui/packages/tgui/interfaces/PublicLibraryWiki/WikiCommon/WikiColorIcon.tsx index 63435b4a35..dee1c1b8c7 100644 --- a/tgui/packages/tgui/interfaces/PublicLibraryWiki/WikiCommon/WikiColorIcon.tsx +++ b/tgui/packages/tgui/interfaces/PublicLibraryWiki/WikiCommon/WikiColorIcon.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { getIconFromRefMap } from 'tgui/events/handlers/assets'; import { Box, Icon } from 'tgui-core/components'; export const getImage = async (url: string): Promise => { @@ -7,6 +8,9 @@ export const getImage = async (url: string): Promise => { image.onload = () => { resolve(image); }; + image.onerror = (event) => { + reject(event); + }; image.src = url; }); }; @@ -65,7 +69,6 @@ export const CanvasBackedImage = (props: { onError={() => setLoadFailed(true)} style={{ visibility: bitmap && !loadFailed ? 'visible' : 'hidden', - imageRendering: 'pixelated', }} /> ); @@ -79,7 +82,7 @@ export const ColorizedImage = (props: { }) => { const { icon, iconState, color, fillLevel = 1 } = props; - const iconRef = icon ? Byond.iconRefMap?.[icon] : null; + const iconRef = icon ? getIconFromRefMap(icon) : null; const iconSize = 64; const realFill = iconSize * (1 - fillLevel); diff --git a/tgui/packages/tgui/interfaces/RadiationConfig.tsx b/tgui/packages/tgui/interfaces/RadiationConfig.tsx index 3fb4579cd0..e21d2a8cb5 100644 --- a/tgui/packages/tgui/interfaces/RadiationConfig.tsx +++ b/tgui/packages/tgui/interfaces/RadiationConfig.tsx @@ -16,10 +16,7 @@ type Data = { export const RadiationConfig = (props) => { const { act, data } = useBackend(); - const { - radiation_color, - glowing, - } = data; + const { radiation_color, glowing } = data; return ( @@ -55,5 +52,5 @@ export const RadiationConfig = (props) => { - ); - }; + ); +}; diff --git a/tgui/packages/tgui/interfaces/ResearchConsole/pages/LockScreen.tsx b/tgui/packages/tgui/interfaces/ResearchConsole/pages/LockScreen.tsx index aa9ef9e822..d6bce682db 100644 --- a/tgui/packages/tgui/interfaces/ResearchConsole/pages/LockScreen.tsx +++ b/tgui/packages/tgui/interfaces/ResearchConsole/pages/LockScreen.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import { useBackend } from 'tgui/backend'; +import { getIconFromRefMap } from 'tgui/events/handlers/assets'; import { Box, Button, Section, Stack } from 'tgui-core/components'; export const LockScreen = (props) => { @@ -8,7 +9,10 @@ export const LockScreen = (props) => { const image = useRef(document.createElement('img')); useEffect(() => { - image.current.src = `${Byond.iconRefMap['icons/obj/machines/research.dmi']}?state=protolathe`; + const iconRef = getIconFromRefMap('icons/obj/machines/research.dmi'); + if (iconRef) { + image.current.src = `${iconRef}?state=protolathe`; + } }, [image]); let randomSizeDiff = 0; diff --git a/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/MultiOverlayImage.tsx b/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/MultiOverlayImage.tsx index 6601fc0935..6d50e13c8b 100644 --- a/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/MultiOverlayImage.tsx +++ b/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/MultiOverlayImage.tsx @@ -1,17 +1,26 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { getIconFromRefMap } from 'tgui/events/handlers/assets'; import { getImage } from '../../PublicLibraryWiki/WikiCommon/WikiColorIcon'; import type { Overlay } from '../types'; const imageCache = new Map>(); function cachedGetImage(url: string) { - if (!imageCache.has(url)) { - imageCache.set( - url, - getImage(url).catch(() => null), - ); - } - return imageCache.get(url)!; + const existing = imageCache.get(url); + if (existing) return existing; + + const promise = getImage(url) + .then((img) => { + imageCache.set(url, Promise.resolve(img)); + return img; + }) + .catch(() => { + imageCache.delete(url); + return null; + }); + + imageCache.set(url, promise); + return promise; } export const MultiOverlayImage = (props: { @@ -32,7 +41,7 @@ export const MultiOverlayImage = (props: { const images = await Promise.all( overlays.map(async (o, i) => { - const iconRef = o.icon ? Byond.iconRefMap?.[o.icon] : null; + const iconRef = o.icon ? getIconFromRefMap(o.icon) : null; if (!iconRef) return null; const url = `${iconRef}?state=${o.iconState}`; const img = await cachedGetImage(url); diff --git a/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/VorePanelTextArea.tsx b/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/VorePanelTextArea.tsx index ae91ad5953..a6de3a5a6b 100644 --- a/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/VorePanelTextArea.tsx +++ b/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/VorePanelTextArea.tsx @@ -66,12 +66,12 @@ const CountedTextElement = (props: { fluid maxLength={limit} value={entry} + onChange={setTextInput} onBlur={(value) => { if (value !== entry) { action(value, index); } }} - onChange={setTextInput} /> diff --git a/tgui/packages/tgui/interfaces/VorePanel/VoreUserPreferencesTabs/test.tsx b/tgui/packages/tgui/interfaces/VorePanel/VoreUserPreferencesTabs/test.tsx new file mode 100644 index 0000000000..fd93cf8874 --- /dev/null +++ b/tgui/packages/tgui/interfaces/VorePanel/VoreUserPreferencesTabs/test.tsx @@ -0,0 +1,41 @@ +import { useBackend } from 'tgui/backend'; +import { Window } from 'tgui/layouts'; +import { Box, Button, Section, Stack } from 'tgui-core/components'; +import type { BooleanLike } from 'tgui-core/react'; + +type Data = { + unlocked: BooleanLike; + buttons: Record; +}; + +export const LockdownButton = (props) => { + const { act, data } = useBackend(); + const { unlocked, buttons } = data; + + return ( + + +
+ {!unlocked ? ( + Swipe ID card to unlock. + ) : ( + + {Object.entries(buttons).map(([button, tooltip]) => ( + + + + ))} + + )} +
+
+
+ ); +}; diff --git a/tgui/packages/tgui/layouts/Pane.tsx b/tgui/packages/tgui/layouts/Pane.tsx index 83bed1ace6..b8ee2cc072 100644 --- a/tgui/packages/tgui/layouts/Pane.tsx +++ b/tgui/packages/tgui/layouts/Pane.tsx @@ -5,7 +5,6 @@ */ import { useBackend } from 'tgui/backend'; -import { useDebug } from 'tgui/debug'; import { Box } from 'tgui-core/components'; import { classes } from 'tgui-core/react'; @@ -14,20 +13,19 @@ import { Layout } from './Layout'; type BoxProps = React.ComponentProps; type Props = Partial<{ + canSuspend: boolean; theme: string; }> & BoxProps; export function Pane(props: Props) { - const { theme, children, className, ...rest } = props; + const { theme, canSuspend, children, className, ...rest } = props; const { suspended } = useBackend(); - const { debugLayout = false } = useDebug(); + const isSuspended = canSuspend && suspended; return ( - - {!suspended && children} - + {!isSuspended && children} ); } diff --git a/tgui/packages/tgui/layouts/TitleBar.tsx b/tgui/packages/tgui/layouts/TitleBar.tsx index 54f8af8d6e..d3249d2079 100644 --- a/tgui/packages/tgui/layouts/TitleBar.tsx +++ b/tgui/packages/tgui/layouts/TitleBar.tsx @@ -1,10 +1,10 @@ +import { useSetAtom } from 'jotai'; import type { PropsWithChildren } from 'react'; -import { globalStore } from 'tgui/backend'; -import { toggleKitchenSink } from 'tgui/debug/actions'; import { Button, Icon } from 'tgui-core/components'; import { UI_DISABLED, UI_INTERACTIVE, UI_UPDATE } from 'tgui-core/constants'; import { type BooleanLike, classes } from 'tgui-core/react'; import { toTitleCase } from 'tgui-core/string'; +import { kitchenSinkAtom } from '../events/store'; type TitleBarProps = Partial<{ className: string; @@ -39,7 +39,8 @@ export function TitleBar(props: TitleBarProps) { onClose, children, } = props; - const dispatch = globalStore.dispatch; + + const setKitchenSink = useSetAtom(kitchenSinkAtom); const finalTitle = (typeof title === 'string' && @@ -68,7 +69,7 @@ export function TitleBar(props: TitleBarProps) {