From bb9328acbf884cabd4af93908d54f0fc5a494488 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 14 Mar 2025 19:52:29 +0300 Subject: [PATCH 1/7] feat: refactor all app storage managment fixing bugs and cleaning up a lot of code --- src/appConfig.ts | 3 + src/optionsGuiScheme.tsx | 89 +++++++++++++++++++- src/optionsStorage.ts | 33 ++++++-- src/react/AddServerOrConnect.tsx | 18 +--- src/react/Chat.css | 5 +- src/react/Input.tsx | 18 +++- src/react/SelectOption.tsx | 131 ++++++++++++++++++++++++++---- src/react/ServersList.tsx | 77 +++++++++--------- src/react/ServersListProvider.tsx | 95 ++++------------------ src/react/appStorageProvider.ts | 97 ++++++++++++++++++++++ src/react/serversStorage.ts | 62 ++++---------- src/react/storageProvider.ts | 13 --- 12 files changed, 417 insertions(+), 224 deletions(-) create mode 100644 src/react/appStorageProvider.ts delete mode 100644 src/react/storageProvider.ts diff --git a/src/appConfig.ts b/src/appConfig.ts index 3d6d8f93..e0242c50 100644 --- a/src/appConfig.ts +++ b/src/appConfig.ts @@ -1,6 +1,7 @@ import { disabledSettings, options, qsOptions } from './optionsStorage' import { miscUiState } from './globalState' import { setLoadingScreenStatus } from './appStatus' +import { setDefaultDataOnConfigLoad } from './react/appStorageProvider' export type AppConfig = { // defaultHost?: string @@ -42,6 +43,8 @@ export const loadAppConfig = (appConfig: AppConfig) => { } } } + + setDefaultDataOnConfigLoad() } export const isBundledConfigUsed = !!process.env.INLINED_APP_CONFIG diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 7032e644..7c1780d0 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -3,7 +3,7 @@ import { useSnapshot } from 'valtio' import { openURL } from 'renderer/viewer/lib/simpleUtils' import { noCase } from 'change-case' import { gameAdditionalState, miscUiState, openOptionsMenu, showModal } from './globalState' -import { AppOptions, options } from './optionsStorage' +import { AppOptions, getChangedSettings, options } from './optionsStorage' import Button from './react/Button' import { OptionMeta, OptionSlider } from './react/OptionsItems' import Slider from './react/Slider' @@ -12,10 +12,12 @@ import { setLoadingScreenStatus } from './appStatus' import { openFilePicker, resetLocalStorageWithoutWorld } from './browserfs' import { completeResourcepackPackInstall, getResourcePackNames, resourcePackState, uninstallResourcePack } from './resourcePack' import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy' -import { showOptionsModal } from './react/SelectOption' +import { showInputsModal, showOptionsModal } from './react/SelectOption' import supportedVersions from './supportedVersions.mjs' import { getVersionAutoSelect } from './connect' import { createNotificationProgressReporter } from './core/progressReporter' +import { customKeymaps } from './controls' +import { appStorage } from './react/appStorageProvider' export const guiOptionsScheme: { [t in OptionsGroupType]: Array<{ [K in keyof AppOptions]?: Partial> } & { custom? }> @@ -460,6 +462,11 @@ export const guiOptionsScheme: { return Developer }, }, + { + custom () { + return + } + }, + { + custom () { + return + } + }, + { + custom () { + return + } + }, + { + custom () { + return + } + } + ], } -export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR' +export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR' | 'export-import' const Category = ({ children }) =>
>) => { export type AppOptions = typeof defaultOptions -// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts -const localStorageKey = process.env?.SINGLE_FILE_BUILD ? 'minecraftWebClientOptions' : 'options' +const isDeepEqual = (a: any, b: any): boolean => { + if (a === b) return true + if (typeof a !== typeof b) return false + if (typeof a !== 'object') return false + if (a === null || b === null) return a === b + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + return a.every((item, index) => isDeepEqual(item, b[index])) + } + const keysA = Object.keys(a) + const keysB = Object.keys(b) + if (keysA.length !== keysB.length) return false + return keysA.every(key => isDeepEqual(a[key], b[key])) +} + +export const getChangedSettings = () => { + return Object.fromEntries( + Object.entries(options).filter(([key, value]) => !isDeepEqual(defaultOptions[key], value)) + ) +} + export const options: AppOptions = proxy({ ...defaultOptions, ...initialAppConfig.defaultSettings, - ...migrateOptions(JSON.parse(localStorage[localStorageKey] || '{}')), + ...migrateOptions(appStorage.options), ...qsOptions }) @@ -181,14 +198,14 @@ export const resetOptions = () => { Object.defineProperty(window, 'debugChangedOptions', { get () { - return Object.fromEntries(Object.entries(options).filter(([key, v]) => defaultOptions[key] !== v)) + return getChangedSettings() }, }) subscribe(options, () => { // Don't save disabled settings to localStorage const saveOptions = omitObj(options, [...disabledSettings.value] as any) - localStorage[localStorageKey] = JSON.stringify(saveOptions) + appStorage.options = saveOptions }) type WatchValue = >(proxy: T, callback: (p: T, isChanged: boolean) => void) => () => void diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index 1186fd9a..c25db793 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -3,7 +3,7 @@ import { appQueryParams } from '../appParams' import { fetchServerStatus, isServerValid } from '../api/mcStatusApi' import { parseServerAddress } from '../parseServerAddress' import Screen from './Screen' -import Input from './Input' +import Input, { INPUT_LABEL_WIDTH, InputWithLabel } from './Input' import Button from './Button' import SelectGameVersion from './SelectGameVersion' import { usePassesScaledDimensions } from './UIProvider' @@ -32,8 +32,6 @@ interface Props { allowAutoConnect?: boolean } -const ELEMENTS_WIDTH = 190 - export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => { const isSmallHeight = !usePassesScaledDimensions(null, 350) const qsParamName = parseQs ? appQueryParams.name : undefined @@ -256,20 +254,8 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ const ButtonWrapper = ({ ...props }: React.ComponentProps) => { props.style ??= {} - props.style.width = ELEMENTS_WIDTH + props.style.width = INPUT_LABEL_WIDTH return
} + +export default Input + +export const INPUT_LABEL_WIDTH = 190 + +export const InputWithLabel = ({ label, span, ...props }: React.ComponentProps & { label, span? }) => { + return
+ + +
+} diff --git a/src/react/SelectOption.tsx b/src/react/SelectOption.tsx index 9db20c86..4df471c0 100644 --- a/src/react/SelectOption.tsx +++ b/src/react/SelectOption.tsx @@ -1,10 +1,14 @@ import { proxy, useSnapshot } from 'valtio' +import { useEffect, useRef } from 'react' +import { noCase } from 'change-case' +import { titleCase } from 'title-case' import { hideCurrentModal, showModal } from '../globalState' import { parseFormattedMessagePacket } from '../botUtils' import Screen from './Screen' import { useIsModalActive } from './utilsApp' import Button from './Button' import MessageFormattedString from './MessageFormattedString' +import Input, { InputWithLabel } from './Input' const state = proxy({ title: '', @@ -12,6 +16,8 @@ const state = proxy({ showCancel: true, minecraftJsonMessage: null as null | Record, behavior: 'resolve-close' as 'resolve-close' | 'close-resolve', + inputs: {} as Record, + inputsConfirmButton: '' }) let resolve @@ -35,17 +41,63 @@ export const showOptionsModal = async ( title, options, showCancel: cancel, - minecraftJsonMessage: minecraftJsonMessageParsed + minecraftJsonMessage: minecraftJsonMessageParsed, + inputs: {}, + inputsConfirmButton: '' + }) + }) +} + +type InputOption = { + type: 'text' | 'checkbox' + defaultValue?: string | boolean + label?: string +} +export const showInputsModal = async >( + title: string, + inputs: T, + { cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {} +): Promise<{ + [K in keyof T]: T[K] extends { type: 'text' } + ? string + : T[K] extends { type: 'checkbox' } + ? boolean + : never +}> => { + showModal({ reactType: 'general-select' }) + let minecraftJsonMessageParsed + if (minecraftJsonMessage) { + const parseResult = parseFormattedMessagePacket(minecraftJsonMessage) + minecraftJsonMessageParsed = parseResult.formatted + if (parseResult.plain) { + title += ` (${parseResult.plain})` + } + } + return new Promise((_resolve) => { + resolve = _resolve + Object.assign(state, { + title, + inputs, + showCancel: cancel, + minecraftJsonMessage: minecraftJsonMessageParsed, + options: [], + inputsConfirmButton: 'Confirm' }) }) } export default () => { - const { title, options, showCancel, minecraftJsonMessage } = useSnapshot(state) + const { title, options, showCancel, minecraftJsonMessage, inputs, inputsConfirmButton } = useSnapshot(state) const isModalActive = useIsModalActive('general-select') + const inputValues = useRef({}) + + useEffect(() => { + inputValues.current = Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.defaultValue ?? (input.type === 'checkbox' ? false : '')])) + }, [inputs]) + if (!isModalActive) return - const resolveClose = (value: string | undefined) => { + const resolveClose = (value: any) => { if (state.behavior === 'resolve-close') { resolve(value) hideCurrentModal() @@ -59,17 +111,66 @@ export default () => { {minecraftJsonMessage &&
} - {options.map(option => )} - {showCancel && } +
+ {options.length > 0 &&
+ {options.map(option => )} +
} +
+ {Object.entries(inputs).map(([key, input]) => { + const label = input.label ?? titleCase(noCase(key)) + return
+ {input.type === 'text' && ( + { + inputValues.current[key] = e.target.value + }} + /> + )} + {input.type === 'checkbox' && ( + + )} +
+ })} +
+ {inputs && inputsConfirmButton && ( + + )} + {showCancel && ( + + )} +
} diff --git a/src/react/ServersList.tsx b/src/react/ServersList.tsx index 46541af6..3aa02226 100644 --- a/src/react/ServersList.tsx +++ b/src/react/ServersList.tsx @@ -1,64 +1,60 @@ import React from 'react' +import { useSnapshot } from 'valtio' +import { miscUiState } from '../globalState' +import { appQueryParams } from '../appParams' import Singleplayer from './Singleplayer' import Input from './Input' import Button from './Button' import PixelartIcon, { pixelartIcons } from './PixelartIcon' - import Select from './Select' import { BaseServerInfo } from './AddServerOrConnect' import { useIsSmallWidth } from './simpleHooks' +import { appStorage, SavedProxiesData, ServerHistoryEntry } from './appStorageProvider' + +const getInitialProxies = () => { + const proxies = [] as string[] + if (miscUiState.appConfig?.defaultProxy) { + proxies.push(miscUiState.appConfig.defaultProxy) + } + return proxies +} + +export const getCurrentProxy = (): string | undefined => { + return appQueryParams.proxy ?? appStorage.proxiesData?.selected ?? getInitialProxies()[0] +} + +export const getCurrentUsername = () => { + return appQueryParams.username ?? appStorage.username +} interface Props extends React.ComponentProps { joinServer: (info: BaseServerInfo | string, additional: { shouldSave?: boolean index?: number }) => void - initialProxies: SavedProxiesLocalStorage - updateProxies: (proxies: SavedProxiesLocalStorage) => void - username: string - setUsername: (username: string) => void onProfileClick?: () => void setQuickConnectIp?: (ip: string) => void - serverHistory?: Array<{ - ip: string - versionOverride?: string - numConnects: number - }> -} - -export interface SavedProxiesLocalStorage { - proxies: readonly string[] - selected: string -} - -type ProxyStatusResult = { - time: number - ping: number - status: 'success' | 'error' | 'unknown' } export default ({ - initialProxies, - updateProxies: updateProxiesProp, joinServer, - username, - setUsername, onProfileClick, setQuickConnectIp, - serverHistory, ...props }: Props) => { - const [proxies, setProxies] = React.useState(initialProxies) - - const updateProxies = (newData: SavedProxiesLocalStorage) => { - setProxies(newData) - updateProxiesProp(newData) - } - + const snap = useSnapshot(appStorage) const [serverIp, setServerIp] = React.useState('') const [save, setSave] = React.useState(true) const [activeHighlight, setActiveHighlight] = React.useState(undefined as 'quick-connect' | 'server-list' | undefined) + const updateProxies = (newData: SavedProxiesData) => { + appStorage.proxiesData = newData + } + + const setUsername = (username: string) => { + appStorage.username = username + } + const getActiveHighlightStyles = (type: typeof activeHighlight) => { const styles: React.CSSProperties = { transition: 'filter 0.2s', @@ -71,6 +67,8 @@ export default ({ const isSmallWidth = useIsSmallWidth() + const initialProxies = getInitialProxies() + const proxiesData = snap.proxiesData ?? { proxies: initialProxies, selected: initialProxies[0] } return setActiveHighlight('quick-connect')} onMouseLeave={() => setActiveHighlight(undefined)} > - {/* todo history */} - {serverHistory?.map((server) => ( -