diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a3e9809a9..90fb6ac06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,11 +68,12 @@ jobs: rm release/*.map - name: Upload Equicord Stable + if: ${{ !vars.ACT }} run: | gh release upload latest --clobber dist/release/* - name: Upload Plugin JSONs to Equibored repo - if: github.repository == 'Equicord/Equicord' + if: github.repository == 'Equicord/Equicord' && !vars.ACT run: | git config --global user.name "GitHub-Actions" git config --global user.email actions@github.com diff --git a/.github/workflows/buildBranches.yml b/.github/workflows/buildBranches.yml index f4e98b2ac..f321da1f0 100644 --- a/.github/workflows/buildBranches.yml +++ b/.github/workflows/buildBranches.yml @@ -4,6 +4,7 @@ on: push: branches: - "**" + - "!pr-**" workflow_dispatch: inputs: branch: @@ -90,6 +91,7 @@ jobs: bun run generateDevsList dist/devs.json - name: Upload to Equibuilds repo + if: ${{ !vars.ACT }} run: | BRANCH="${{ github.event.inputs.branch || github.ref_name }}" COMMIT_SHA=$(git rev-parse --short HEAD) diff --git a/.github/workflows/nixosBuild.yml b/.github/workflows/nixosBuild.yml index bd2c85c8a..cc32abb10 100644 --- a/.github/workflows/nixosBuild.yml +++ b/.github/workflows/nixosBuild.yml @@ -69,6 +69,7 @@ jobs: run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - name: Upload Equicord Stable + if: ${{ !vars.ACT }} run: | gh release create ${{ steps.date.outputs.date }} --latest=false gh release upload ${{ steps.date.outputs.date }} --clobber dist/release/* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 694368312..4e8ea71b3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -79,12 +79,16 @@ jobs: - name: Create sources zip if: steps.check.outputs.release == 'true' - run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" + run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x + ".git/*" - name: Publish extension - if: steps.check.outputs.release == 'true' - run: pnpx @equicord/publish-browser-extension@latest --chrome-zip dist/extension-chrome.zip --firefox-zip dist/extension-firefox.zip --firefox-sources-zip dist/extension-sources.zip --edge-zip dist/extension-chrome.zip - + if: steps.check.outputs.release == 'true' && !vars.ACT + run: pnpx @equicord/publish-browser-extension@latest --chrome-zip + dist/extension-chrome.zip --firefox-zip + dist/extension-firefox.zip --firefox-sources-zip + dist/extension-sources.zip --edge-zip + dist/extension-chrome.zip env: CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }} diff --git a/.gitignore b/.gitignore index 34faacbfa..26295f97b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ dist node_modules .VSCodeCounter +.vars +.secrets +.input *.exe vencord_installer diff --git a/eslint.config.mjs b/eslint.config.mjs index a42f71ee6..8db3a18eb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -137,6 +137,8 @@ export default defineConfig( "use-isnan": "error", "prefer-const": ["error", { destructuring: "all" }], "prefer-spread": "error", + // These are old deprecated browser globals which may be used by mistake, e.g. `addEventListener(e => console.log(event))` + "no-restricted-globals": ["error", "event", "name"], // Plugin Rules "simple-import-sort/imports": "error", diff --git a/package.json b/package.json index 24c7c7a29..472d56a5b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "equicord", "private": "true", - "version": "1.14.11.0", + "version": "1.14.12.0", "description": "The other cutest Discord client mod", "homepage": "https://github.com/Equicord/Equicord#readme", "bugs": { diff --git a/packages/discord-types/src/components.d.ts b/packages/discord-types/src/components.d.ts index 5f341fd92..c7d01e481 100644 --- a/packages/discord-types/src/components.d.ts +++ b/packages/discord-types/src/components.d.ts @@ -331,6 +331,15 @@ declare enum PopoutAnimation { type PopoutPosition = "top" | "bottom" | "left" | "right" | "center" | "window_center"; +export interface PopoutProps { + position: PopoutPosition; + nudge: number; + isPositioned: boolean; + setPopoutRef(ref: any): void; + closePopout(): void; + updatePosition(): void; +} + export type Popout = ComponentType<{ children( thing: { @@ -347,14 +356,8 @@ export type Popout = ComponentType<{ ): ReactNode; shouldShow?: boolean; targetElementRef: RefObject; - renderPopout(args: { - closePopout(): void; - isPositioned: boolean; - nudge: number; - position: PopoutPosition; - setPopoutRef(ref: any): void; - updatePosition(): void; - }): ReactNode; + renderPopout(props: PopoutProps): ReactNode; + preload?(): Promise; onRequestOpen?(): void; onRequestClose?(): void; diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index 159404783..545e14add 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -147,7 +147,7 @@ export const globPlugins = kind => ({ }); build.onLoad({ filter, namespace: "import-plugins" }, async () => { - const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "equicordplugins", "equicordplugins/_api", "userplugins"]; + const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "equicordplugins/_api", "equicordplugins/_core", "equicordplugins", "userplugins"]; let code = ""; let pluginsCode = "\n"; let metaCode = "\n"; diff --git a/scripts/generatePluginList.ts b/scripts/generatePluginList.ts index 76a03bd60..d4cb347e9 100644 --- a/scripts/generatePluginList.ts +++ b/scripts/generatePluginList.ts @@ -31,11 +31,11 @@ import { getEntryPoint, isPluginFile, parseDevs, parseEquicordDevs, parseFile, P let dirs: string[]; if (equicordFlag) { - dirs = ["src/equicordplugins"]; + dirs = ["src/equicordplugins/_core", "src/equicordplugins"]; } else if (vencordFlag) { dirs = ["src/plugins", "src/plugins/_core"]; } else { - dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins"]; + dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins/_core", "src/equicordplugins"]; } const outputPath = args.find(a => !a.startsWith("--")) ?? null; diff --git a/src/Vencord.ts b/src/Vencord.ts index 679915a60..1ce426d0f 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -42,8 +42,7 @@ import { popNotice, showNotice } from "./api/Notices"; import { NotificationData, showNotification } from "./api/Notifications"; import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager"; import { PlainSettings, Settings, SettingsStore } from "./api/Settings"; -import { getCloudSettings, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync"; -import { localStorage } from "./utils/localStorage"; +import { areLocalSettingsDirty, getCloudSettings, getCloudSyncDirection, markLocalSettingsDirty, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync"; import { relaunch } from "./utils/native"; import { checkForUpdates, isOutdated as getIsOutdated, update, UpdateLogger } from "./utils/updater"; import { onceReady } from "./webpack"; @@ -54,11 +53,6 @@ if (IS_REPORTER) { } async function syncSettings() { - // Check if cloud auth exists for current user before attempting sync - if (localStorage.Vencord_cloudSyncDirection === undefined) { - // by default, sync bi-directionally - localStorage.Vencord_cloudSyncDirection = "both"; - } const hasCloudAuth = await dsGet("Vencord_cloudSecret"); if (!hasCloudAuth) { if (Settings.cloud.authenticated) { @@ -93,9 +87,9 @@ async function syncSettings() { if ( Settings.cloud.settingsSync && // if it's enabled Settings.cloud.authenticated && // if cloud integrations are enabled - localStorage.Vencord_cloudSyncDirection !== "manual" // if we're not in manual mode + getCloudSyncDirection() !== "manual" // if we're not in manual mode ) { - if (localStorage.Vencord_settingsDirty && shouldCloudSync("push")) { + if (areLocalSettingsDirty() && shouldCloudSync("push")) { await putCloudSettings(); } else if (shouldCloudSync("pull") && await getCloudSettings(false)) { // if we synchronized something (false means no sync) // we show a notification here instead of allowing getCloudSettings() to show one to declutter the amount of @@ -118,7 +112,7 @@ async function syncSettings() { }, 60_000); SettingsStore.addGlobalChangeListener(() => { - localStorage.Vencord_settingsDirty = true; + markLocalSettingsDirty(); saveSettingsOnFrequentAction(); }); } diff --git a/src/api/ChatButtons.tsx b/src/api/ChatButtons.tsx index 4288c6f8b..a04725386 100644 --- a/src/api/ChatButtons.tsx +++ b/src/api/ChatButtons.tsx @@ -190,3 +190,22 @@ addContextMenuPatch("textarea-context", (children, args) => { ); }); + +// Wrapper stuff is down here to avoid conflicts with above +export type ChatBarButtonWrapper = (buttons: ReactNode) => ReactNode; + +/** + * Registry for plugins that need to wrap the entire chat bar button container + */ +export const ChatBarButtonWrappers = new Map(); + +export const addChatBarButtonWrapper = (id: string, wrapper: ChatBarButtonWrapper) => ChatBarButtonWrappers.set(id, wrapper); +export const removeChatBarButtonWrapper = (id: string) => ChatBarButtonWrappers.delete(id); + +export function _wrapButtons(buttons: ReactNode) { + let wrapped = buttons; + for (const wrapper of ChatBarButtonWrappers.values()) { + wrapped = wrapper(wrapped); + } + return wrapped; +} diff --git a/src/api/MessageAccessories.tsx b/src/api/MessageAccessories.tsx index 07eaec055..9c90be1ba 100644 --- a/src/api/MessageAccessories.tsx +++ b/src/api/MessageAccessories.tsx @@ -17,6 +17,7 @@ */ import ErrorBoundary from "@components/ErrorBoundary"; +import { Logger } from "@utils/Logger"; import { t } from "@utils/translation"; import { JSX, ReactNode } from "react"; @@ -47,23 +48,28 @@ export function _modifyAccessories( elements: JSX.Element[], props: Record ) { - for (const [key, accessory] of accessories.entries()) { - const res = ( - - - - ); + try { + for (const [key, accessory] of accessories.entries()) { + const res = ( + + + + ); - elements.splice( - accessory.position != null - ? accessory.position < 0 - ? elements.length + accessory.position - : accessory.position - : elements.length, - 0, - res - ); + elements.splice( + accessory.position != null + ? accessory.position < 0 + ? elements.length + accessory.position + : accessory.position + : elements.length, + 0, + res + ); + } + + return elements; + } catch (e) { + new Logger("MessageAccessories").error("Failed to modify message accessories", e); + return elements; } - - return elements; } diff --git a/src/api/Notifications/styles.css b/src/api/Notifications/styles.css index 400fb7d4a..e15e01a7d 100644 --- a/src/api/Notifications/styles.css +++ b/src/api/Notifications/styles.css @@ -15,7 +15,7 @@ position: absolute; z-index: 2147483647; right: 1rem; - width: 25vw; + width: min(25vw, 400px); min-height: 10vh; } diff --git a/src/api/PluginManager.ts b/src/api/PluginManager.ts index 5b4f60248..76a14c988 100644 --- a/src/api/PluginManager.ts +++ b/src/api/PluginManager.ts @@ -17,7 +17,7 @@ */ import { addProfileBadge, removeProfileBadge } from "@api/Badges"; -import { addChatBarButton, removeChatBarButton } from "@api/ChatButtons"; +import { addChatBarButton, addChatBarButtonWrapper, removeChatBarButton, removeChatBarButtonWrapper } from "@api/ChatButtons"; import { registerCommand, unregisterCommand } from "@api/Commands"; import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu"; import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators"; @@ -32,7 +32,7 @@ import { traceFunction } from "@debug/Tracer"; import { Logger } from "@utils/Logger"; import { onlyOnce } from "@utils/onlyOnce"; import { canonicalizeFind, canonicalizeReplacement } from "@utils/patches"; -import { Patch, Plugin, PluginDef, ReporterTestable, StartAt } from "@utils/types"; +import { DefinedSettings, Patch, Plugin, PluginDef, PluginSettingDef, ReporterTestable, StartAt } from "@utils/types"; import { FluxEvents } from "@vencord/discord-types"; import { FluxDispatcher } from "@webpack/common"; import { patches } from "@webpack/patcher"; @@ -43,6 +43,7 @@ export { Plugins as plugins }; import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer"; import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar"; import { addProfileCollection, removeProfileCollection } from "./ProfileCollections"; +import { addProfileSection, removeProfileSection } from "./ProfileSections"; import { addUserAreaButton, removeUserAreaButton } from "./UserArea"; const logger = new Logger("PluginManager", "#a6d189"); @@ -60,13 +61,32 @@ export function isPluginEnabled(p: string) { Settings.plugins[p]?.enabled ) ?? false; } - export function isPluginRequired(p: string) { return ( Plugins[p]?.required ) ?? false; } +export function isSettingHidden(settings: DefinedSettings, setting: PluginSettingDef) { + if (!("hidden" in setting)) return false; + + return typeof setting.hidden === "function" + ? setting.hidden.call(settings) + : Boolean(setting.hidden); +} + +export function isSettingDisabled(settings: DefinedSettings, setting: PluginSettingDef) { + if (!("disabled" in setting)) return false; + + return typeof setting.disabled === "function" + ? setting.disabled.call(settings) + : Boolean(setting.disabled); +} + +export function hasAnyVisibleSettings({ settings }: Plugin) { + return !!settings && Object.values(settings.def).some(s => !isSettingHidden(settings, s)); +} + export function addPatch(newPatch: Omit, pluginName: string, pluginPath = `Vencord.Plugins.plugins[${JSON.stringify(pluginName)}]`) { // TODO: this causes crashes if (pluginName === "Vesktop" && newPatch.find === ".STREAMING_AUTO_STREAMER_MODE,") return; @@ -200,7 +220,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton, // Custom - renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection + renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper, + renderProfileSection } = p; if (p.start) { @@ -268,6 +289,8 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: if (audioProcessor) addAudioProcessor(name, audioProcessor); if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority); if (renderProfileCollection) addProfileCollection(name, renderProfileCollection); + if (chatBarButtonWrapper) addChatBarButtonWrapper(name, chatBarButtonWrapper); + if (renderProfileSection) addProfileSection(name, renderProfileSection); return true; }, p => `startPlugin ${p.name}`); @@ -278,7 +301,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, messagePopoverButton, // Custom - renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection + renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollection, chatBarButtonWrapper, + renderProfileSection } = p; if (p.stop) { @@ -344,6 +368,8 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu if (audioProcessor) removeAudioProcessor(name); if (userAreaButton) removeUserAreaButton(name); if (renderProfileCollection) removeProfileCollection(name); + if (chatBarButtonWrapper) removeChatBarButtonWrapper(name); + if (renderProfileSection) removeProfileSection(name); return true; }, p => `stopPlugin ${p.name}`); @@ -399,6 +425,8 @@ export const initPluginManager = onlyOnce(function init() { if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI"); if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI"); if (p.renderProfileCollection) neededApiPlugins.add("ProfileCollectionsAPI"); + if (p.chatBarButtonWrapper) neededApiPlugins.add("ChatInputButtonAPI"); + if (p.renderProfileSection) neededApiPlugins.add("ProfileSectionsAPI"); for (const key of pluginKeysToBind) { p[key] &&= p[key].bind(p) as any; @@ -412,22 +440,11 @@ export const initPluginManager = onlyOnce(function init() { for (const p of pluginsValues) { if (p.settings) { - p.options ??= {}; - p.settings.pluginName = p.name; - for (const name in p.settings.def) { - const def = p.settings.def[name]; - const checks = p.settings.checks?.[name]; - p.options[name] = { ...def, ...checks }; - } - } - if (p.options) { - for (const name in p.options) { - const opt = p.options[name]; - if (opt.onChange != null) { - SettingsStore.addChangeListener(`plugins.${p.name}.${name}`, opt.onChange); - } + for (const [key, def] of Object.entries(p.settings.def)) { + if (def.onChange) + SettingsStore.addChangeListener(`plugins.${p.name}.${key}`, def.onChange); } } diff --git a/src/api/ProfileCollections.tsx b/src/api/ProfileCollections.tsx index 2a8a16783..07afef0cb 100644 --- a/src/api/ProfileCollections.tsx +++ b/src/api/ProfileCollections.tsx @@ -6,12 +6,13 @@ import ErrorBoundary from "@components/ErrorBoundary"; import { Logger } from "@utils/Logger"; +import { User } from "@vencord/discord-types"; import { useEffect, useState } from "@webpack/common"; import type { ReactNode } from "react"; const logger = new Logger("ProfileCollectionAPI"); -export type ProfileCollectionFactory = (props: any) => ReactNode | null; +export type ProfileCollectionFactory = (props: { user: User; isSideBar: boolean; }) => ReactNode | Promise | null; interface CollectionEntry { render: ProfileCollectionFactory; diff --git a/src/api/ProfileSections.tsx b/src/api/ProfileSections.tsx new file mode 100644 index 000000000..ecf52ae9c --- /dev/null +++ b/src/api/ProfileSections.tsx @@ -0,0 +1,73 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import ErrorBoundary from "@components/ErrorBoundary"; +import { Logger } from "@utils/Logger"; +import { useEffect, useState } from "@webpack/common"; +import type { ReactNode } from "react"; + +const logger = new Logger("ProfileSectionsAPI"); + +export type ProfileSectionFactory = (props: { userId: string; isSideBar: boolean; }) => ReactNode | Promise | null; + +interface SectionEntry { + render: ProfileSectionFactory; + priority: number; +} + +const profileSections = new Map(); +const profileSectionListeners = new Set<() => void>(); + +/** + * Adds a section to the user profile panel near the 'Member Since' area. + * + * @param id - Unique identifier for the section (e.g., "my-plugin-profile-section") + * @param render - Function that returns the section JSX. + * Receives `{ userId, isSideBar }` so you can style accordingly. + * @param priority - Higher values appear first. Default: 0 + * + * @example + * addProfileSection("my-section", ({ userId, isSideBar }) => ( + *
Custom content for {userId}
+ * )); + */ +export function addProfileSection(id: string, render: ProfileSectionFactory, priority = 0) { + profileSections.set(id, { render, priority }); + profileSectionListeners.forEach(listener => listener()); +} + +/** + * Removes a section from the user profile panel. + * + * @param id - The identifier used when adding the section + */ +export function removeProfileSection(id: string) { + profileSections.delete(id); + profileSectionListeners.forEach(listener => listener()); +} + +function ProfileSections({ userId, isSideBar }: { userId: string; isSideBar: boolean; }) { + const [, forceUpdate] = useState(0); + + useEffect(() => { + const listener = () => forceUpdate(n => n + 1); + profileSectionListeners.add(listener); + return () => { profileSectionListeners.delete(listener); }; + }, []); + + return Array.from(profileSections) + .sort(([, a], [, b]) => b.priority - a.priority) + .map(([id, { render: Section }]) => ( + logger.error(`Failed to render profile section: ${id}`, e.error)}> +
+ + )); +} + +/** @internal Injected by ProfileSectionsAPI patch (do NOT call directly) */ +export function renderProfileSections(props: { userId: string; isSideBar: boolean; }) { + return ; +} diff --git a/src/api/ServerList.tsx b/src/api/ServerList.tsx index 39d8b5da4..0bf5f6155 100644 --- a/src/api/ServerList.tsx +++ b/src/api/ServerList.tsx @@ -25,11 +25,11 @@ export const enum ServerListRenderPosition { Below, } -const componentsIn = new Set(); -const componentsAbove = new Set(); -const componentsBelow = new Set(); +const componentsIn = new Map(); +const componentsAbove = new Map(); +const componentsBelow = new Map(); -function getRenderFunctions(position: ServerListRenderPosition) { +function getRenderMap(position: ServerListRenderPosition) { switch (position) { case ServerListRenderPosition.Above: return componentsAbove; @@ -40,21 +40,20 @@ function getRenderFunctions(position: ServerListRenderPosition) { } } -export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) { - getRenderFunctions(position).add(renderFunction); +export function addServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType, priority = 0) { + getRenderMap(position).set(renderFunction, priority); } export function removeServerListElement(position: ServerListRenderPosition, renderFunction: ComponentType) { - getRenderFunctions(position).delete(renderFunction); + getRenderMap(position).delete(renderFunction); } export const renderAll = (position: ServerListRenderPosition) => { - return Array.from( - getRenderFunctions(position), - (Component, i) => ( + return Array.from(getRenderMap(position).entries()) + .sort((a, b) => b[1] - a[1]) + .map(([Component], i) => ( - ) - ); + )); }; diff --git a/src/api/Settings.ts b/src/api/Settings.ts index c5f6e480c..9e674c326 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -178,7 +178,7 @@ export const SettingsStore = new SettingsStoreClass(settings, { if (path.startsWith("plugins.")) { const plugin = path.slice("plugins.".length); if (plugin in plugins) { - const setting = plugins[plugin].options?.[key]; + const setting = plugins[plugin].settings?.def[key]; if (!setting) return v; if ("default" in setting) @@ -208,7 +208,7 @@ if (!IS_REPORTER) { * 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, + * WARNING: default values specified in plugin.settings 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} */ @@ -353,7 +353,13 @@ export function definePluginSettings< Checks extends SettingsChecks, PrivateSettings extends object = {} >(def: Def, checks?: Checks) { - const definedSettings: DefinedSettings = { + if (checks) { + for (const [name, check] of Object.entries(checks)) { + Object.assign(def[name], check); + } + } + + 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; @@ -368,11 +374,10 @@ export function definePluginSettings< : [`plugins.${definedSettings.pluginName}.*`] ) as UseSettings[]).plugins[definedSettings.pluginName] as any, def, - checks: checks ?? {} as any, pluginName: "", withPrivateSettings() { - return this as DefinedSettings; + return this as DefinedSettings; } }; diff --git a/src/api/SettingsSync/cloudSync.ts b/src/api/SettingsSync/cloudSync.ts index 3b65b11e0..5489ef06c 100644 --- a/src/api/SettingsSync/cloudSync.ts +++ b/src/api/SettingsSync/cloudSync.ts @@ -25,6 +25,14 @@ const API_VERSION_STORE_KEY = "Vencord_cloudApiVersions"; type ApiVersion = "v2" | "v1"; +const SYNC_DIRECTION_KEY = "Vencord_cloudSyncDirection"; +const SETTINGS_DIRTY_KEY = "Vencord_settingsDirty"; +export const getCloudSyncDirection = () => localStorage.getItem(SYNC_DIRECTION_KEY) || "both"; +export const setCloudSyncDirection = (direction: "push" | "pull" | "both" | "manual") => localStorage.setItem(SYNC_DIRECTION_KEY, direction); +export const areLocalSettingsDirty = () => localStorage.getItem(SETTINGS_DIRTY_KEY) === "true"; +export const markLocalSettingsDirty = () => localStorage.setItem(SETTINGS_DIRTY_KEY, "true"); +export const markLocalSettingsClean = () => localStorage.removeItem(SETTINGS_DIRTY_KEY); + async function loadApiVersionMap(): Promise> { return await DataStore.get>(API_VERSION_STORE_KEY) ?? {}; } diff --git a/src/api/index.ts b/src/api/index.ts index eb1d32335..585cb3dbb 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -35,6 +35,7 @@ import * as $Notifications from "./Notifications"; import * as $UserArea from "./UserArea"; export * as PluginManager from "./PluginManager"; import * as $ProfileCollections from "./ProfileCollections"; +import * as $ProfileSections from "./ProfileSections"; import * as $ServerList from "./ServerList"; import * as $Settings from "./Settings"; import * as $Styles from "./Styles"; @@ -176,3 +177,8 @@ export const isEquicord = true; * An API allowing you to add other collections where discords game collection is. */ export const ProfileCollections = $ProfileCollections; + +/** + * An API allowing you to add sections near the 'Member Since' area of user profile panels. + */ +export const ProfileSections = $ProfileSections; diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index c0ff49a33..afee7b18a 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -730,6 +730,34 @@ export function ClockIcon(props: IconProps) { ); } +export function ChevronSmallDownIcon(props: IconProps) { + return ( + + + + ); +} + +export function ChevronSmallUpIcon(props: IconProps) { + return ( + + + + ); +} + export function DownArrow(props: IconProps) { return ( openPluginModal(plugin, onRestartNeeded)} className={cl("info-button")} > - {plugin.settings?.def && Object.values(plugin.settings.def).some(s => s.type !== OptionType.CUSTOM && !s.hidden) + {hasAnyVisibleSettings(plugin) ? : } diff --git a/src/components/settings/tabs/plugins/PluginModal.tsx b/src/components/settings/tabs/plugins/PluginModal.tsx index 177fce95d..186f2be1f 100644 --- a/src/components/settings/tabs/plugins/PluginModal.tsx +++ b/src/components/settings/tabs/plugins/PluginModal.tsx @@ -19,6 +19,7 @@ import "./PluginModal.css"; import { generateId } from "@api/Commands"; +import { hasAnyVisibleSettings, isSettingHidden } from "@api/PluginManager"; import { useSettings } from "@api/Settings"; import { BaseText } from "@components/BaseText"; import { Button } from "@components/Button"; @@ -86,7 +87,7 @@ function PluginTags({ tags }: { tags: PluginTag[]; }) { export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) { const pluginSettings = useSettings([`plugins.${plugin.name}.*`]).plugins[plugin.name]; - const hasSettings = Boolean(pluginSettings && plugin.options && !isObjectEmpty(plugin.options)); + const hasSettings = hasAnyVisibleSettings(plugin); // avoid layout shift by showing dummy users while loading users const fallbackAuthors = useMemo(() => [makeDummyUser({ username: "Loading...", id: "-1465912127305809920" })], []); @@ -114,14 +115,17 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti } function renderSettings() { - if (!hasSettings || !plugin.options) + const { settings } = plugin; + if (!hasSettings || !settings) return There are no settings for this plugin.; - const options = Object.entries(plugin.options).map(([key, setting]) => { - if (setting.type === OptionType.CUSTOM || setting.hidden) return null; + const options = Object.entries(settings.def).map(([key, setting]) => { + if (setting.type === OptionType.CUSTOM) return null; + + if (isSettingHidden(settings, setting)) return null; function onChange(newValue: any) { - const option = plugin.options?.[key]; + const option = plugin.settings!.def[key]; if (!option || option.type === OptionType.CUSTOM) return; pluginSettings[key] = newValue; @@ -134,10 +138,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti ); diff --git a/src/components/settings/tabs/plugins/components/BooleanSetting.tsx b/src/components/settings/tabs/plugins/components/BooleanSetting.tsx index 51cef49d3..22596c3ba 100644 --- a/src/components/settings/tabs/plugins/components/BooleanSetting.tsx +++ b/src/components/settings/tabs/plugins/components/BooleanSetting.tsx @@ -16,20 +16,21 @@ * along with this program. If not, see . */ +import { isSettingDisabled } from "@api/PluginManager"; import { Switch } from "@components/Switch"; -import { PluginOptionBoolean } from "@utils/types"; +import { PluginSettingBooleanDef } from "@utils/types"; import { React, useState } from "@webpack/common"; import { resolveError, SettingProps, SettingsSection } from "./Common"; -export function BooleanSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps) { - const def = pluginSettings[id] ?? option.default; +export function BooleanSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps) { + const def = pluginSettings[id] ?? setting.default; const [state, setState] = useState(def ?? false); const [error, setError] = useState(null); function handleChange(newValue: boolean): void { - const isValid = option.isValid?.call(definedSettings, newValue) ?? true; + const isValid = setting.isValid?.call(definedSettings, newValue) ?? true; setState(newValue); setError(resolveError(isValid)); @@ -40,11 +41,11 @@ export function BooleanSetting({ option, pluginSettings, definedSettings, id, on } return ( - + ); diff --git a/src/components/settings/tabs/plugins/components/Common.tsx b/src/components/settings/tabs/plugins/components/Common.tsx index 6b8ead13f..803f1d395 100644 --- a/src/components/settings/tabs/plugins/components/Common.tsx +++ b/src/components/settings/tabs/plugins/components/Common.tsx @@ -8,24 +8,24 @@ import { BaseText } from "@components/BaseText"; import { classNameFactory } from "@utils/css"; import { classes } from "@utils/misc"; import { wordsFromCamel, wordsToTitle } from "@utils/text"; -import { DefinedSettings, PluginOptionBase } from "@utils/types"; +import { DefinedSettings, PluginSettingDefCommon } from "@utils/types"; import { PropsWithChildren } from "react"; export const cl = classNameFactory("vc-plugins-setting-"); interface SettingBaseProps { - option: T; + setting: T; onChange(newValue: any): void; pluginSettings: { [setting: string]: any; enabled: boolean; }; id: string; - definedSettings?: DefinedSettings; + definedSettings: DefinedSettings; } -export type SettingProps = SettingBaseProps; -export type ComponentSettingProps> = SettingBaseProps; +export type SettingProps = SettingBaseProps; +export type ComponentSettingProps> = SettingBaseProps; export function resolveError(isValidResult: boolean | string) { if (typeof isValidResult === "string") return isValidResult; diff --git a/src/components/settings/tabs/plugins/components/ComponentSetting.tsx b/src/components/settings/tabs/plugins/components/ComponentSetting.tsx index c43b72768..e1521f02a 100644 --- a/src/components/settings/tabs/plugins/components/ComponentSetting.tsx +++ b/src/components/settings/tabs/plugins/components/ComponentSetting.tsx @@ -16,10 +16,10 @@ * along with this program. If not, see . */ -import { PluginOptionComponent } from "@utils/types"; +import { PluginSettingComponentDef } from "@utils/types"; import { ComponentSettingProps } from "./Common"; -export function ComponentSetting({ option, onChange }: ComponentSettingProps) { - return option.component({ setValue: onChange, option }); +export function ComponentSetting({ setting, onChange }: ComponentSettingProps) { + return setting.component({ setValue: onChange, option: setting }); } diff --git a/src/components/settings/tabs/plugins/components/NumberSetting.tsx b/src/components/settings/tabs/plugins/components/NumberSetting.tsx index 108ddc3e4..fa348a108 100644 --- a/src/components/settings/tabs/plugins/components/NumberSetting.tsx +++ b/src/components/settings/tabs/plugins/components/NumberSetting.tsx @@ -16,24 +16,25 @@ * along with this program. If not, see . */ -import { OptionType, PluginOptionNumber } from "@utils/types"; +import { isSettingDisabled } from "@api/PluginManager"; +import { OptionType, PluginSettingBigIntDef, PluginSettingNumberDef } from "@utils/types"; import { React, TextInput, useState } from "@webpack/common"; import { resolveError, SettingProps, SettingsSection } from "./Common"; const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER); -export function NumberSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps) { +export function NumberSetting({ setting, pluginSettings, definedSettings, id, onChange }: SettingProps) { function serialize(value: any) { - if (option.type === OptionType.BIGINT) return BigInt(value); + if (setting.type === OptionType.BIGINT) return BigInt(value); return Number(value); } - const [state, setState] = useState(`${pluginSettings[id] ?? option.default ?? 0}`); + const [state, setState] = useState(`${pluginSettings[id] ?? setting.default ?? 0}`); const [error, setError] = useState(null); function handleChange(newValue: any) { - const isValid = option.isValid?.call(definedSettings, newValue) ?? true; + const isValid = setting.isValid?.call(definedSettings, newValue) ?? true; setError(resolveError(isValid)); @@ -41,7 +42,7 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC onChange(serialize(newValue)); } - if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) { + if (setting.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) { setState(`${Number.MAX_SAFE_INTEGER}`); } else { setState(newValue); @@ -49,15 +50,15 @@ export function NumberSetting({ option, pluginSettings, definedSettings, id, onC } return ( - + ); diff --git a/src/components/settings/tabs/plugins/components/SelectSetting.tsx b/src/components/settings/tabs/plugins/components/SelectSetting.tsx index 4c912c515..89f652e7a 100644 --- a/src/components/settings/tabs/plugins/components/SelectSetting.tsx +++ b/src/components/settings/tabs/plugins/components/SelectSetting.tsx @@ -16,19 +16,20 @@ * along with this program. If not, see . */ -import { PluginOptionSelect } from "@utils/types"; +import { isSettingDisabled } from "@api/PluginManager"; +import { PluginSettingSelectDef } from "@utils/types"; import { React, Select, useState } from "@webpack/common"; import { resolveError, SettingProps, SettingsSection } from "./Common"; -export function SelectSetting({ option, pluginSettings, definedSettings, onChange, id }: SettingProps) { - const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value; +export function SelectSetting({ setting, pluginSettings, definedSettings, onChange, id }: SettingProps) { + const def = pluginSettings[id] ?? setting.options?.find(o => o.default)?.value; const [state, setState] = useState(def ?? null); const [error, setError] = useState(null); function handleChange(newValue: any) { - const isValid = option.isValid?.call(definedSettings, newValue) ?? true; + const isValid = setting.isValid?.call(definedSettings, newValue) ?? true; setState(newValue); setError(resolveError(isValid)); @@ -39,17 +40,17 @@ export function SelectSetting({ option, pluginSettings, definedSettings, onChang } return ( - +