From 0d5e2d0696da494aee2126b4cadbca7e07066b89 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Sat, 6 May 2023 01:36:00 +0200 Subject: [skip ci] Refactor utils --- src/api/Commands/index.ts | 2 +- src/api/ContextMenu.ts | 2 +- src/api/MessageEvents.ts | 2 +- src/api/MessagePopover.ts | 2 +- src/api/Notifications/NotificationComponent.tsx | 2 +- src/api/Notifications/Notifications.tsx | 2 +- src/api/Notifications/notificationLog.tsx | 4 +- src/api/ServerList.ts | 2 +- src/api/Settings.ts | 283 ++++++++++++++++++++++++ src/api/SettingsStore.ts | 6 +- src/api/settings.ts | 283 ------------------------ 11 files changed, 295 insertions(+), 295 deletions(-) create mode 100644 src/api/Settings.ts delete mode 100644 src/api/settings.ts (limited to 'src/api') diff --git a/src/api/Commands/index.ts b/src/api/Commands/index.ts index 3f639a1..ef4db17 100644 --- a/src/api/Commands/index.ts +++ b/src/api/Commands/index.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { makeCodeblock } from "@utils/misc"; +import { makeCodeblock } from "@utils/text"; import { sendBotMessage } from "./commandHelpers"; import { ApplicationCommandInputType, ApplicationCommandOptionType, ApplicationCommandType, Argument, Command, CommandContext, Option } from "./types"; diff --git a/src/api/ContextMenu.ts b/src/api/ContextMenu.ts index 4d1d577..f1ebfdb 100644 --- a/src/api/ContextMenu.ts +++ b/src/api/ContextMenu.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import Logger from "@utils/Logger"; +import { Logger } from "@utils/Logger"; import type { ReactElement } from "react"; type ContextMenuPatchCallbackReturn = (() => void) | void; diff --git a/src/api/MessageEvents.ts b/src/api/MessageEvents.ts index 50d8b26..b597fea 100644 --- a/src/api/MessageEvents.ts +++ b/src/api/MessageEvents.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import Logger from "@utils/Logger"; +import { Logger } from "@utils/Logger"; import { MessageStore } from "@webpack/common"; import type { Channel, Message } from "discord-types/general"; import type { Promisable } from "type-fest"; diff --git a/src/api/MessagePopover.ts b/src/api/MessagePopover.ts index 85dff9c..3391cfb 100644 --- a/src/api/MessagePopover.ts +++ b/src/api/MessagePopover.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import Logger from "@utils/Logger"; +import { Logger } from "@utils/Logger"; import { Channel, Message } from "discord-types/general"; import type { MouseEventHandler } from "react"; diff --git a/src/api/Notifications/NotificationComponent.tsx b/src/api/Notifications/NotificationComponent.tsx index 542c29b..caa4b64 100644 --- a/src/api/Notifications/NotificationComponent.tsx +++ b/src/api/Notifications/NotificationComponent.tsx @@ -18,7 +18,7 @@ import "./styles.css"; -import { useSettings } from "@api/settings"; +import { useSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { classes } from "@utils/misc"; import { React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common"; diff --git a/src/api/Notifications/Notifications.tsx b/src/api/Notifications/Notifications.tsx index 600ea63..6025646 100644 --- a/src/api/Notifications/Notifications.tsx +++ b/src/api/Notifications/Notifications.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { Settings } from "@api/settings"; +import { Settings } from "@api/Settings"; import { Queue } from "@utils/Queue"; import { ReactDOM } from "@webpack/common"; import type { ReactNode } from "react"; diff --git a/src/api/Notifications/notificationLog.tsx b/src/api/Notifications/notificationLog.tsx index 72f09ac..9535fb6 100644 --- a/src/api/Notifications/notificationLog.tsx +++ b/src/api/Notifications/notificationLog.tsx @@ -17,10 +17,10 @@ */ import * as DataStore from "@api/DataStore"; -import { Settings } from "@api/settings"; +import { Settings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; -import { useAwaiter } from "@utils/misc"; import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { useAwaiter } from "@utils/react"; import { Alerts, Button, Forms, moment, React, Text, Timestamp, useEffect, useReducer, useState } from "@webpack/common"; import { nanoid } from "nanoid"; import type { DispatchWithoutAction } from "react"; diff --git a/src/api/ServerList.ts b/src/api/ServerList.ts index c98b174..4804413 100644 --- a/src/api/ServerList.ts +++ b/src/api/ServerList.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import Logger from "@utils/Logger"; +import { Logger } from "@utils/Logger"; const logger = new Logger("ServerListAPI"); diff --git a/src/api/Settings.ts b/src/api/Settings.ts new file mode 100644 index 0000000..e481e48 --- /dev/null +++ b/src/api/Settings.ts @@ -0,0 +1,283 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { debounce } from "@utils/debounce"; +import { localStorage } from "@utils/localStorage"; +import { Logger } from "@utils/Logger"; +import { mergeDefaults } from "@utils/misc"; +import { putCloudSettings } from "@utils/settingsSync"; +import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types"; +import { React } from "@webpack/common"; + +import plugins from "~plugins"; + +const logger = new Logger("Settings"); +export interface Settings { + notifyAboutUpdates: boolean; + autoUpdate: boolean; + autoUpdateNotification: boolean, + useQuickCss: boolean; + enableReactDevtools: boolean; + themeLinks: string[]; + frameless: boolean; + transparent: boolean; + winCtrlQ: boolean; + macosTranslucency: boolean; + disableMinSize: boolean; + winNativeTitleBar: boolean; + plugins: { + [plugin: string]: { + enabled: boolean; + [setting: string]: any; + }; + }; + + notifications: { + timeout: number; + position: "top-right" | "bottom-right"; + useNative: "always" | "never" | "not-focused"; + logLimit: number; + }; + + cloud: { + authenticated: boolean; + url: string; + settingsSync: boolean; + settingsSyncVersion: number; + }; +} + +const DefaultSettings: Settings = { + notifyAboutUpdates: true, + autoUpdate: false, + autoUpdateNotification: true, + useQuickCss: true, + themeLinks: [], + enableReactDevtools: false, + frameless: false, + transparent: false, + winCtrlQ: false, + macosTranslucency: false, + disableMinSize: false, + winNativeTitleBar: false, + plugins: {}, + + notifications: { + timeout: 5000, + position: "bottom-right", + useNative: "not-focused", + logLimit: 50 + }, + + cloud: { + authenticated: false, + url: "https://api.vencord.dev/", + settingsSync: false, + settingsSyncVersion: 0 + } +}; + +try { + var settings = JSON.parse(VencordNative.settings.get()) as Settings; + mergeDefaults(settings, DefaultSettings); +} catch (err) { + var settings = mergeDefaults({} as Settings, DefaultSettings); + logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err); +} + +const saveSettingsOnFrequentAction = debounce(async () => { + if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { + await putCloudSettings(); + delete localStorage.Vencord_settingsDirty; + } +}, 60_000); + +type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; }; +const subscriptions = new Set(); + +const proxyCache = {} as Record; + +// Wraps the passed settings object in a Proxy to nicely handle change listeners and default values +function makeProxy(settings: any, root = settings, path = ""): Settings { + return proxyCache[path] ??= new Proxy(settings, { + get(target, p: string) { + const v = target[p]; + + // using "in" is important in the following cases to properly handle falsy or nullish values + if (!(p in target)) { + // Return empty for plugins with no settings + if (path === "plugins" && p in plugins) + return target[p] = makeProxy({ + enabled: plugins[p].required ?? plugins[p].enabledByDefault ?? false + }, root, `plugins.${p}`); + + // Since the property is not set, check if this is a plugin's setting and if so, try to resolve + // the default value. + if (path.startsWith("plugins.")) { + const plugin = path.slice("plugins.".length); + if (plugin in plugins) { + const setting = plugins[plugin].options?.[p]; + if (!setting) return v; + if ("default" in setting) + // normal setting with a default value + return (target[p] = setting.default); + if (setting.type === OptionType.SELECT) { + const def = setting.options.find(o => o.default); + if (def) + target[p] = def.value; + return def?.value; + } + } + } + return v; + } + + // Recursively proxy Objects with the updated property path + if (typeof v === "object" && !Array.isArray(v) && v !== null) + return makeProxy(v, root, `${path}${path && "."}${p}`); + + // primitive or similar, no need to proxy further + return v; + }, + + set(target, p: string, v) { + // avoid unnecessary updates to React Components and other listeners + if (target[p] === v) return true; + + target[p] = v; + // Call any listeners that are listening to a setting of this path + const setPath = `${path}${path && "."}${p}`; + delete proxyCache[setPath]; + for (const subscription of subscriptions) { + if (!subscription._path || subscription._path === setPath) { + subscription(v, setPath); + } + } + // And don't forget to persist the settings! + PlainSettings.cloud.settingsSyncVersion = Date.now(); + localStorage.Vencord_settingsDirty = true; + saveSettingsOnFrequentAction(); + VencordNative.settings.set(JSON.stringify(root, null, 4)); + return true; + } + }); +} + +/** + * Same as {@link Settings} but unproxied. You should treat this as readonly, + * as modifying properties on this will not save to disk or call settings + * listeners. + * WARNING: default values specified in plugin.options will not be ensured here. In other words, + * settings for which you specified a default value may be uninitialised. If you need proper + * handling for default values, use {@link Settings} + */ +export const PlainSettings = settings; +/** + * A smart settings object. Altering props automagically saves + * the updated settings to disk. + * This recursively proxies objects. If you need the object non proxied, use {@link PlainSettings} + */ +export const Settings = makeProxy(settings); + +/** + * Settings hook for React components. Returns a smart settings + * object that automagically triggers a rerender if any properties + * are altered + * @param paths An optional list of paths to whitelist for rerenders + * @returns Settings + */ +// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later +export function useSettings(paths?: UseSettings[]) { + const [, forceUpdate] = React.useReducer(() => ({}), {}); + + const onUpdate: SubscriptionCallback = paths + ? (value, path) => paths.includes(path as UseSettings) && forceUpdate() + : forceUpdate; + + React.useEffect(() => { + subscriptions.add(onUpdate); + return () => void subscriptions.delete(onUpdate); + }, []); + + return Settings; +} + +// Resolves a possibly nested prop in the form of "some.nested.prop" to type of T.some.nested.prop +type ResolvePropDeep = P extends "" ? T : + P extends `${infer Pre}.${infer Suf}` ? + Pre extends keyof T ? ResolvePropDeep : never : P extends keyof T ? T[P] : never; + +/** + * Add a settings listener that will be invoked whenever the desired setting is updated + * @param path Path to the setting that you want to watch, for example "plugins.Unindent.enabled" will fire your callback + * whenever Unindent is toggled. Pass an empty string to get notified for all changes + * @param onUpdate Callback function whenever a setting matching path is updated. It gets passed the new value and the path + * to the updated setting. This path will be the same as your path argument, unless it was an empty string. + * + * @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`)) + * addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled")) + */ +export function addSettingsListener(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void; +export function addSettingsListener(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep, path: Path extends "" ? string : Path) => void): void; +export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) { + (onUpdate as SubscriptionCallback)._path = path; + subscriptions.add(onUpdate); +} + +export function migratePluginSettings(name: string, ...oldNames: string[]) { + const { plugins } = settings; + if (name in plugins) return; + + for (const oldName of oldNames) { + if (oldName in plugins) { + logger.info(`Migrating settings from old name ${oldName} to ${name}`); + plugins[name] = plugins[oldName]; + delete plugins[oldName]; + VencordNative.settings.set(JSON.stringify(settings, null, 4)); + break; + } + } +} + +export function definePluginSettings>(def: D, checks?: C) { + const definedSettings: DefinedSettings = { + get store() { + if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized"); + return Settings.plugins[definedSettings.pluginName] as any; + }, + use: settings => useSettings( + settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`) as UseSettings[] + ).plugins[definedSettings.pluginName] as any, + def, + checks: checks ?? {}, + pluginName: "", + }; + return definedSettings; +} + +type UseSettings = ResolveUseSettings[keyof T]; + +type ResolveUseSettings = { + [Key in keyof T]: + Key extends string + ? T[Key] extends Record + // @ts-ignore "Type instantiation is excessively deep and possibly infinite" + ? UseSettings extends string ? `${Key}.${UseSettings}` : never + : Key + : never; +}; diff --git a/src/api/SettingsStore.ts b/src/api/SettingsStore.ts index a5a0402..d9369a9 100644 --- a/src/api/SettingsStore.ts +++ b/src/api/SettingsStore.ts @@ -16,11 +16,11 @@ * along with this program. If not, see . */ -import Logger from "@utils/Logger"; -import { proxyLazy } from "@utils/proxyLazy"; +import { proxyLazy } from "@utils/lazy"; +import { Logger } from "@utils/Logger"; import { findModuleId, wreq } from "@webpack"; -import { Settings } from "./settings"; +import { Settings } from "./Settings"; interface Setting { /** diff --git a/src/api/settings.ts b/src/api/settings.ts deleted file mode 100644 index 2329f94..0000000 --- a/src/api/settings.ts +++ /dev/null @@ -1,283 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2022 Vendicated and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ - -import { debounce } from "@utils/debounce"; -import { localStorage } from "@utils/localStorage"; -import Logger from "@utils/Logger"; -import { mergeDefaults } from "@utils/misc"; -import { putCloudSettings } from "@utils/settingsSync"; -import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types"; -import { React } from "@webpack/common"; - -import plugins from "~plugins"; - -const logger = new Logger("Settings"); -export interface Settings { - notifyAboutUpdates: boolean; - autoUpdate: boolean; - autoUpdateNotification: boolean, - useQuickCss: boolean; - enableReactDevtools: boolean; - themeLinks: string[]; - frameless: boolean; - transparent: boolean; - winCtrlQ: boolean; - macosTranslucency: boolean; - disableMinSize: boolean; - winNativeTitleBar: boolean; - plugins: { - [plugin: string]: { - enabled: boolean; - [setting: string]: any; - }; - }; - - notifications: { - timeout: number; - position: "top-right" | "bottom-right"; - useNative: "always" | "never" | "not-focused"; - logLimit: number; - }; - - cloud: { - authenticated: boolean; - url: string; - settingsSync: boolean; - settingsSyncVersion: number; - }; -} - -const DefaultSettings: Settings = { - notifyAboutUpdates: true, - autoUpdate: false, - autoUpdateNotification: true, - useQuickCss: true, - themeLinks: [], - enableReactDevtools: false, - frameless: false, - transparent: false, - winCtrlQ: false, - macosTranslucency: false, - disableMinSize: false, - winNativeTitleBar: false, - plugins: {}, - - notifications: { - timeout: 5000, - position: "bottom-right", - useNative: "not-focused", - logLimit: 50 - }, - - cloud: { - authenticated: false, - url: "https://api.vencord.dev/", - settingsSync: false, - settingsSyncVersion: 0 - } -}; - -try { - var settings = JSON.parse(VencordNative.settings.get()) as Settings; - mergeDefaults(settings, DefaultSettings); -} catch (err) { - var settings = mergeDefaults({} as Settings, DefaultSettings); - logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err); -} - -const saveSettingsOnFrequentAction = debounce(async () => { - if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { - await putCloudSettings(); - delete localStorage.Vencord_settingsDirty; - } -}, 60_000); - -type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; }; -const subscriptions = new Set(); - -const proxyCache = {} as Record; - -// Wraps the passed settings object in a Proxy to nicely handle change listeners and default values -function makeProxy(settings: any, root = settings, path = ""): Settings { - return proxyCache[path] ??= new Proxy(settings, { - get(target, p: string) { - const v = target[p]; - - // using "in" is important in the following cases to properly handle falsy or nullish values - if (!(p in target)) { - // Return empty for plugins with no settings - if (path === "plugins" && p in plugins) - return target[p] = makeProxy({ - enabled: plugins[p].required ?? plugins[p].enabledByDefault ?? false - }, root, `plugins.${p}`); - - // Since the property is not set, check if this is a plugin's setting and if so, try to resolve - // the default value. - if (path.startsWith("plugins.")) { - const plugin = path.slice("plugins.".length); - if (plugin in plugins) { - const setting = plugins[plugin].options?.[p]; - if (!setting) return v; - if ("default" in setting) - // normal setting with a default value - return (target[p] = setting.default); - if (setting.type === OptionType.SELECT) { - const def = setting.options.find(o => o.default); - if (def) - target[p] = def.value; - return def?.value; - } - } - } - return v; - } - - // Recursively proxy Objects with the updated property path - if (typeof v === "object" && !Array.isArray(v) && v !== null) - return makeProxy(v, root, `${path}${path && "."}${p}`); - - // primitive or similar, no need to proxy further - return v; - }, - - set(target, p: string, v) { - // avoid unnecessary updates to React Components and other listeners - if (target[p] === v) return true; - - target[p] = v; - // Call any listeners that are listening to a setting of this path - const setPath = `${path}${path && "."}${p}`; - delete proxyCache[setPath]; - for (const subscription of subscriptions) { - if (!subscription._path || subscription._path === setPath) { - subscription(v, setPath); - } - } - // And don't forget to persist the settings! - PlainSettings.cloud.settingsSyncVersion = Date.now(); - localStorage.Vencord_settingsDirty = true; - saveSettingsOnFrequentAction(); - VencordNative.settings.set(JSON.stringify(root, null, 4)); - return true; - } - }); -} - -/** - * Same as {@link Settings} but unproxied. You should treat this as readonly, - * as modifying properties on this will not save to disk or call settings - * listeners. - * WARNING: default values specified in plugin.options will not be ensured here. In other words, - * settings for which you specified a default value may be uninitialised. If you need proper - * handling for default values, use {@link Settings} - */ -export const PlainSettings = settings; -/** - * A smart settings object. Altering props automagically saves - * the updated settings to disk. - * This recursively proxies objects. If you need the object non proxied, use {@link PlainSettings} - */ -export const Settings = makeProxy(settings); - -/** - * Settings hook for React components. Returns a smart settings - * object that automagically triggers a rerender if any properties - * are altered - * @param paths An optional list of paths to whitelist for rerenders - * @returns Settings - */ -// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later -export function useSettings(paths?: UseSettings[]) { - const [, forceUpdate] = React.useReducer(() => ({}), {}); - - const onUpdate: SubscriptionCallback = paths - ? (value, path) => paths.includes(path as UseSettings) && forceUpdate() - : forceUpdate; - - React.useEffect(() => { - subscriptions.add(onUpdate); - return () => void subscriptions.delete(onUpdate); - }, []); - - return Settings; -} - -// Resolves a possibly nested prop in the form of "some.nested.prop" to type of T.some.nested.prop -type ResolvePropDeep = P extends "" ? T : - P extends `${infer Pre}.${infer Suf}` ? - Pre extends keyof T ? ResolvePropDeep : never : P extends keyof T ? T[P] : never; - -/** - * Add a settings listener that will be invoked whenever the desired setting is updated - * @param path Path to the setting that you want to watch, for example "plugins.Unindent.enabled" will fire your callback - * whenever Unindent is toggled. Pass an empty string to get notified for all changes - * @param onUpdate Callback function whenever a setting matching path is updated. It gets passed the new value and the path - * to the updated setting. This path will be the same as your path argument, unless it was an empty string. - * - * @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`)) - * addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled")) - */ -export function addSettingsListener(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void; -export function addSettingsListener(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep, path: Path extends "" ? string : Path) => void): void; -export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) { - (onUpdate as SubscriptionCallback)._path = path; - subscriptions.add(onUpdate); -} - -export function migratePluginSettings(name: string, ...oldNames: string[]) { - const { plugins } = settings; - if (name in plugins) return; - - for (const oldName of oldNames) { - if (oldName in plugins) { - logger.info(`Migrating settings from old name ${oldName} to ${name}`); - plugins[name] = plugins[oldName]; - delete plugins[oldName]; - VencordNative.settings.set(JSON.stringify(settings, null, 4)); - break; - } - } -} - -export function definePluginSettings>(def: D, checks?: C) { - const definedSettings: DefinedSettings = { - get store() { - if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized"); - return Settings.plugins[definedSettings.pluginName] as any; - }, - use: settings => useSettings( - settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`) as UseSettings[] - ).plugins[definedSettings.pluginName] as any, - def, - checks: checks ?? {}, - pluginName: "", - }; - return definedSettings; -} - -type UseSettings = ResolveUseSettings[keyof T]; - -type ResolveUseSettings = { - [Key in keyof T]: - Key extends string - ? T[Key] extends Record - // @ts-ignore "Type instantiation is excessively deep and possibly infinite" - ? UseSettings extends string ? `${Key}.${UseSettings}` : never - : Key - : never; -}; -- cgit