/** * @name BetterFolders * @version 3.6.2 * @author Zerthox * @authorLink https://github.com/Zerthox * @description Adds new functionality to server folders. Custom Folder Icons. Close other folders on open. * @website https://github.com/Zerthox/BetterDiscord-Plugins * @source https://github.com/Zerthox/BetterDiscord-Plugins/tree/master/src/BetterFolders **/ /*@cc_on @if (@_jscript) var pluginName = WScript.ScriptName.split(".")[0]; var shell = WScript.CreateObject("WScript.Shell"); shell.Popup( "Do NOT run scripts from the internet with the Windows Script Host!\nMove this file to your BetterDiscord plugins folder.", 0, pluginName + ": Warning!", 0x1030 ); var fso = new ActiveXObject("Scripting.FileSystemObject"); var pluginsPath = shell.expandEnvironmentStrings("%appdata%\\BetterDiscord\\plugins"); if (!fso.FolderExists(pluginsPath)) { var popup = shell.Popup( "Unable to find BetterDiscord on your computer.\nOpen the download page of BetterDiscord?", 0, pluginName + ": BetterDiscord not found", 0x34 ); if (popup === 6) { shell.Exec("explorer \"https://betterdiscord.app\""); } } else if (WScript.ScriptFullName === pluginsPath + "\\" + WScript.ScriptName) { shell.Popup( "This plugin is already in the correct folder.\nNavigate to the \"Plugins\" settings tab in Discord and enable it there.", 0, pluginName, 0x40 ); } else { var popup = shell.Popup( "Open the BetterDiscord plugins folder?", 0, pluginName, 0x34 ); if (popup === 6) { shell.Exec("explorer " + pluginsPath); } } WScript.Quit(); @else @*/ 'use strict'; let meta = null; const getMeta = () => { if (meta) { return meta; } else { throw Error("Accessing meta before initialization"); } }; const setMeta = (newMeta) => { meta = newMeta; }; const load = (key) => BdApi.Data.load(getMeta().name, key); const save = (key, value) => BdApi.Data.save(getMeta().name, key, value); const checkObjectValues = (target) => target !== window && target instanceof Object && target.constructor?.prototype !== target; const byEntry = (filter, every = false) => { return ((target, ...args) => { if (checkObjectValues(target)) { const values = Object.values(target); return values.length > 0 && values[every ? "every" : "some"]((value) => filter(value, ...args)); } else { return false; } }); }; const byName$1 = (name) => { return (target) => (target?.displayName ?? target?.constructor?.displayName) === name; }; const byKeys$1 = (...keys) => { return (target) => target instanceof Object && keys.every((key) => key in target); }; const byProtos = (...protos) => { return (target) => target instanceof Object && target.prototype instanceof Object && protos.every((proto) => proto in target.prototype); }; const bySource$1 = (...fragments) => { return (target) => { while (target instanceof Object && "$$typeof" in target) { target = target.render ?? target.type; } if (target instanceof Function) { const source = target.toString(); const renderSource = target.prototype?.render?.toString(); return fragments.every((fragment) => typeof fragment === "string" ? (source.includes(fragment) || renderSource?.includes(fragment)) : (fragment(source) || renderSource && fragment(renderSource))); } else { return false; } }; }; const confirm = (title, content, options = {}) => BdApi.UI.showConfirmationModal(title, content, options); const mappedProxy = (target, mapping) => { const map = new Map(Object.entries(mapping)); return new Proxy(target, { get(target, prop) { return target[map.get(prop) ?? prop]; }, set(target, prop, value) { target[map.get(prop) ?? prop] = value; return true; }, deleteProperty(target, prop) { delete target[map.get(prop) ?? prop]; map.delete(prop); return true; }, has(target, prop) { return map.has(prop) || prop in target; }, ownKeys() { return [...map.keys(), ...Object.keys(target)]; }, getOwnPropertyDescriptor(target, prop) { return Object.getOwnPropertyDescriptor(target, map.get(prop) ?? prop); }, defineProperty(target, prop, attributes) { Object.defineProperty(target, map.get(prop) ?? prop, attributes); return true; } }); }; const find = (filter, { resolve = true, entries = false } = {}) => BdApi.Webpack.getModule(filter, { defaultExport: resolve, searchExports: entries }); const byName = (name, options) => find(byName$1(name), options); const byKeys = (keys, options) => find(byKeys$1(...keys), options); const bySource = (contents, options) => find(bySource$1(...contents), options); const resolveKey = (target, filter) => [target, Object.entries(target ?? {}).find(([, value]) => filter(value))?.[0]]; const findWithKey = (filter) => resolveKey(find(byEntry(filter)), filter); const demangle = (mapping, required, proxy = false) => { const req = required ?? Object.keys(mapping); const found = find((target) => (checkObjectValues(target) && req.every((req) => Object.values(target).some((value) => mapping[req](value))))); return proxy ? mappedProxy(found, Object.fromEntries(Object.entries(mapping).map(([key, filter]) => [ key, Object.entries(found ?? {}).find(([, value]) => filter(value))?.[0] ]))) : Object.fromEntries(Object.entries(mapping).map(([key, filter]) => [ key, Object.values(found ?? {}).find((value) => filter(value)) ])); }; let controller = new AbortController(); const waitFor = (filter, { resolve = true, entries = false } = {}) => BdApi.Webpack.waitForModule(filter, { signal: controller.signal, defaultExport: resolve, searchExports: entries }); const abort = () => { controller.abort(); controller = new AbortController(); }; const COLOR = "#3a71c1"; const print = (output, ...data) => output(`%c[${getMeta().name}] %c${getMeta().version ? `(v${getMeta().version})` : ""}`, `color: ${COLOR}; font-weight: 700;`, "color: #666; font-size: .8em;", ...data); const log = (...data) => print(console.log, ...data); const warn = (...data) => print(console.warn, ...data); const error = (...data) => print(console.error, ...data); const patch = (type, object, method, callback, options) => { const original = object?.[method]; if (!(original instanceof Function)) { throw TypeError(`patch target ${original} is not a function`); } const cancel = BdApi.Patcher[type](getMeta().name, object, method, options.once ? (...args) => { const result = callback(cancel, original, ...args); cancel(); return result; } : (...args) => callback(cancel, original, ...args)); if (!options.silent) { log(`Patched ${options.name ?? String(method)}`); } return cancel; }; const instead = (object, method, callback, options = {}) => patch("instead", object, method, (cancel, original, context, args) => callback({ cancel, original, context, args }), options); const after = (object, method, callback, options = {}) => patch("after", object, method, (cancel, original, context, args, result) => callback({ cancel, original, context, args, result }), options); let menuPatches = []; const unpatchAll = () => { if (menuPatches.length + BdApi.Patcher.getPatchesByCaller(getMeta().name).length > 0) { for (const cancel of menuPatches) { cancel(); } menuPatches = []; BdApi.Patcher.unpatchAll(getMeta().name); log("Unpatched all"); } }; const inject = (styles) => { if (typeof styles === "string") { BdApi.DOM.addStyle(getMeta().name, styles); } }; const clear = () => BdApi.DOM.removeStyle(getMeta().name); const ClientActions = /* @__PURE__ */ byKeys(["toggleGuildFolderExpand"]); const { useStateFromStores } = /* @__PURE__ */ demangle({ default: byKeys$1("Store", "connectStores"), Dispatcher: byProtos("dispatch"), Store: byProtos("emitChange"), BatchedStoreListener: byProtos("attach", "detach"), useStateFromStores: bySource$1("useStateFromStores") }, ["Store", "Dispatcher", "useStateFromStores"]); const SortedGuildStore = /* @__PURE__ */ byName("SortedGuildStore"); const ExpandedGuildFolderStore = /* @__PURE__ */ byName("ExpandedGuildFolderStore"); const { React } = BdApi; const classNames = /* @__PURE__ */ find((exports) => exports instanceof Object && exports.default === exports && Object.keys(exports).length === 1); const Button = /* @__PURE__ */ byKeys(["Colors", "Link"], { entries: true }); const Flex = /* @__PURE__ */ byKeys(["Child", "Justify", "Align"], { entries: true }); const { FormSection, FormItem, FormText, FormDivider, FormSwitch} = /* @__PURE__ */ demangle({ FormSection: bySource$1("titleClassName:", ".sectionTitle"), FormItem: bySource$1("titleClassName:", "required:"), FormTitle: bySource$1("faded:", "required:"), FormText: (target) => target.Types?.INPUT_PLACEHOLDER, FormDivider: bySource$1(".divider", "style:"), FormSwitch: bySource$1("tooltipNote:"), FormNotice: bySource$1("imageData:", ".formNotice") }, ["FormSection", "FormItem", "FormDivider"]); const margins = /* @__PURE__ */ byKeys(["marginBottom40", "marginTop4"]); const RadioGroup = /* @__PURE__ */ bySource(["radioPosition:", "radioItemClassName:", "options:"], { entries: true }); const ImageInput = /* @__PURE__ */ find((target) => typeof target.defaultProps?.multiple === "boolean" && typeof target.defaultProps?.maxFileSizeBytes === "number"); const replaceElement = (target, replace) => { target.type = replace.type; target.key = replace.key ?? target.key; target.props = replace.props; }; const queryTree = (node, predicate) => { const worklist = [node].flat(); while (worklist.length !== 0) { const node = worklist.shift(); if (React.isValidElement(node)) { if (predicate(node)) { return node; } const children = node?.props?.children; if (children) { worklist.push(...[children].flat()); } } } return null; }; const getFiber = (node) => { const key = Object.keys(node).find((key) => key.startsWith("__reactFiber")); return node?.[key]; }; const queryFiber = (fiber, predicate, direction = "up" , depth = 30) => { if (depth < 0) { return null; } if (predicate(fiber)) { return fiber; } if (direction === "up" || direction === "both" ) { let count = 0; let parent = fiber.return; while (parent && count < depth) { if (predicate(parent)) { return parent; } count++; parent = parent.return; } } if (direction === "down" || direction === "both" ) { let child = fiber.child; while (child) { const result = queryFiber(child, predicate, "down" , depth - 1); if (result) { return result; } child = child.sibling; } } return null; }; const findOwner = (fiber, depth = 50) => { return queryFiber(fiber, (node) => node?.stateNode instanceof React.Component, "up" , depth); }; const forceFullRerender = (fiber) => new Promise((resolve) => { const owner = findOwner(fiber); if (owner) { const { stateNode } = owner; instead(stateNode, "render", () => null, { once: true, silent: true }); stateNode.forceUpdate(() => stateNode.forceUpdate(() => resolve(true))); } else { resolve(false); } }); const SettingsContainer = ({ name, children, onReset }) => (React.createElement(FormSection, null, children, onReset ? (React.createElement(React.Fragment, null, React.createElement(FormDivider, { className: classNames(margins.marginTop20, margins.marginBottom20) }), React.createElement(Flex, { justify: Flex.Justify.END }, React.createElement(Button, { size: Button.Sizes.SMALL, onClick: () => confirm(name, "Reset all settings?", { onConfirm: () => onReset() }) }, "Reset")))) : null)); class SettingsStore { constructor(defaults, onLoad) { this.listeners = new Set(); this.update = (settings) => { Object.assign(this.current, typeof settings === "function" ? settings(this.current) : settings); this._dispatch(true); }; this.addReactChangeListener = this.addListener; this.removeReactChangeListener = this.removeListener; this.defaults = defaults; this.onLoad = onLoad; } load() { this.current = { ...this.defaults, ...load("settings") }; this.onLoad?.(); this._dispatch(false); } _dispatch(save$1) { for (const listener of this.listeners) { listener(this.current); } if (save$1) { save("settings", this.current); } } reset() { this.current = { ...this.defaults }; this._dispatch(true); } delete(...keys) { for (const key of keys) { delete this.current[key]; } this._dispatch(true); } useCurrent() { return useStateFromStores([this], () => this.current, undefined, () => false); } useSelector(selector, deps, compare) { return useStateFromStores([this], () => selector(this.current), deps, compare); } useState() { return useStateFromStores([this], () => [ this.current, this.update ]); } useStateWithDefaults() { return useStateFromStores([this], () => [ this.current, this.defaults, this.update ]); } useListener(listener, deps) { React.useEffect(() => { this.addListener(listener); return () => this.removeListener(listener); }, deps ?? [listener]); } addListener(listener) { this.listeners.add(listener); return listener; } removeListener(listener) { this.listeners.delete(listener); } removeAllListeners() { this.listeners.clear(); } } const createSettings = (defaults, onLoad) => new SettingsStore(defaults, onLoad); const createPlugin = (plugin) => (meta) => { setMeta(meta); const { start, stop, styles, Settings, SettingsPanel } = (plugin instanceof Function ? plugin(meta) : plugin); Settings?.load(); return { start() { log("Enabled"); inject(styles); start?.(); }, stop() { abort(); unpatchAll(); clear(); stop?.(); log("Disabled"); }, getSettingsPanel: SettingsPanel ? () => (React.createElement(SettingsContainer, { name: meta.name, onReset: Settings ? () => Settings.reset() : null }, React.createElement(SettingsPanel, null))) : null }; }; const Settings = createSettings({ closeOnOpen: false, folders: {} }); const css = ".customIcon-BetterFolders {\n box-sizing: border-box;\n border-radius: var(--radius-lg);\n width: var(--guildbar-folder-size);\n height: var(--guildbar-folder-size);\n padding: var(--custom-folder-preview-padding);\n background-size: contain;\n background-position: center;\n background-repeat: no-repeat;\n}"; const styles = { customIcon: "customIcon-BetterFolders" }; const folderStyles = byKeys(["folderIcon", "folderIconWrapper", "folderPreviewWrapper"]); const renderIcon = (data) => (React.createElement("div", { className: styles.customIcon, style: { backgroundImage: data?.icon ? `url(${data.icon})` : null } })); const BetterFolderIcon = ({ data, childProps, FolderIcon }) => { if (FolderIcon) { const result = FolderIcon(childProps); if (data?.icon) { const replace = renderIcon(data); const iconWrapper = queryTree(result, (node) => node?.props?.className === folderStyles.folderIconWrapper); if (iconWrapper) { replaceElement(iconWrapper, replace); } else { error("Failed to find folderIconWrapper element"); } if (data.always) { const previewWrapper = queryTree(result, (node) => node?.props?.className === folderStyles.folderPreviewWrapper); if (previewWrapper) { replaceElement(previewWrapper, replace); } else { error("Failed to find folderPreviewWrapper element"); } } } return result; } else { return null; } }; const compareFolderData = (a, b) => a?.icon === b?.icon && a?.always === b?.always; const ConnectedBetterFolderIcon = ({ folderId, ...props }) => { const data = Settings.useSelector((current) => current.folders[folderId], [folderId], compareFolderData); return React.createElement(BetterFolderIcon, { data: data, ...props }); }; const BetterFolderUploader = ({ icon, always, onChange }) => (React.createElement(React.Fragment, null, React.createElement(Flex, { align: Flex.Align.CENTER }, React.createElement(Button, { color: Button.Colors.WHITE, look: Button.Looks.OUTLINED }, "Upload Image", React.createElement(ImageInput, { onChange: (img) => onChange({ icon: img, always }) })), React.createElement(FormText, { type: "description", style: { margin: "0 10px 0 40px" } }, "Preview:"), renderIcon({ icon})), React.createElement(FormSwitch, { hideBorder: true, className: margins.marginTop8, value: always, onChange: (checked) => onChange({ icon, always: checked }) }, "Always display icon"))); const folderModalPatch = ({ context, result }) => { const { folderId } = context.props; const { state } = context; const form = queryTree(result, (node) => node?.type === "form"); if (!form) { warn("Unable to find form"); return; } if (!state.iconType) { const { icon = null, always = false } = Settings.current.folders[folderId] ?? {}; Object.assign(state, { iconType: icon ? "custom" : "default" , icon, always }); } const { children } = form.props; const { className } = children[0].props; children.push(React.createElement(FormItem, { title: "Icon", className: className }, React.createElement(RadioGroup, { value: state.iconType, options: [ { value: "default" , name: "Default Icon" }, { value: "custom" , name: "Custom Icon" } ], onChange: ({ value }) => context.setState({ iconType: value }) }))); if (state.iconType === "custom" ) { const tree = SortedGuildStore.getGuildsTree(); children.push(React.createElement(FormItem, { title: "Custom Icon", className: className }, React.createElement(BetterFolderUploader, { icon: state.icon, always: state.always, folderNode: tree.nodes[folderId], onChange: ({ icon, always }) => context.setState({ icon, always }) }))); } const button = queryTree(result, (node) => node?.props?.type === "submit"); const original = button.props.onClick; button.props.onClick = (...args) => { original(...args); const { folders } = Settings.current; if (state.iconType === "custom" && state.icon) { folders[folderId] = { icon: state.icon, always: state.always }; Settings.update({ folders }); } else if ((state.iconType === "default" || !state.icon) && folders[folderId]) { delete folders[folderId]; Settings.update({ folders }); } }; }; const guildStyles = byKeys(["guilds", "base"]); const getGuildsOwner = () => findOwner(getFiber(document.getElementsByClassName(guildStyles.guilds)?.[0])); const triggerRerender = async (guildsFiber) => { if (await forceFullRerender(guildsFiber)) { log("Rerendered guilds"); } else { warn("Unable to rerender guilds"); } }; const index = createPlugin({ start() { let FolderIcon = null; const guildsOwner = getGuildsOwner(); const FolderIconWrapper = findWithKey(bySource$1("folderIconWrapper")); after(...FolderIconWrapper, ({ args: [props], result }) => { const icon = queryTree(result, (node) => node?.props?.folderNode); if (!icon) { return error("Unable to find FolderIcon component"); } if (!FolderIcon) { log("Found FolderIcon component"); FolderIcon = icon.type; } const replace = React.createElement(ConnectedBetterFolderIcon, { folderId: props.folderNode.id, childProps: icon.props, FolderIcon: FolderIcon }); replaceElement(icon, replace); }, { name: "FolderIconWrapper" }); triggerRerender(guildsOwner); after(ClientActions, "toggleGuildFolderExpand", ({ original, args: [folderId] }) => { if (Settings.current.closeOnOpen) { for (const id of ExpandedGuildFolderStore.getExpandedFolders()) { if (id !== folderId) { original(id); } } } }); waitFor(bySource$1(".folderName", ".onClose"), { entries: true }).then((FolderSettingsModal) => { if (FolderSettingsModal) { after(FolderSettingsModal.prototype, "render", folderModalPatch, { name: "GuildFolderSettingsModal" }); } }); }, stop() { triggerRerender(getGuildsOwner()); }, styles: css, Settings, SettingsPanel: () => { const [{ closeOnOpen }, setSettings] = Settings.useState(); return (React.createElement(FormSwitch, { note: "Close other folders when opening a new folder", hideBorder: true, value: closeOnOpen, onChange: (checked) => { if (checked) { for (const id of Array.from(ExpandedGuildFolderStore.getExpandedFolders()).slice(1)) { ClientActions.toggleGuildFolderExpand(id); } } setSettings({ closeOnOpen: checked }); } }, "Close on open")); } }); module.exports = index; /*@end @*/