Restructure API, condense DOM modules

This commit is contained in:
Zack Rauen 2022-10-02 03:34:34 -04:00
parent 6454b63f6b
commit 87652dab8e
30 changed files with 1441 additions and 1636 deletions

View File

@ -14,7 +14,7 @@
"lint": "eslint --ext .js common/ && pnpm --filter injector lint && pnpm --filter preload lint && pnpm --filter renderer lint-js",
"test": "mocha --require @babel/register --recursive \"./tests/renderer/*.js\"",
"dist": "pnpm run build-prod && node scripts/pack.js",
"api": "jsdoc -X renderer/src/modules/pluginapi.js > jsdoc-ast.json"
"api": "jsdoc -X -r renderer/src/modules/api/ > jsdoc-ast.json"
},
"devDependencies": {
"asar": "^3.2.0",

View File

@ -39,7 +39,7 @@ export default new class CustomCSS extends Builtin {
if (this.isDetached) return;
if (this.nativeOpen) return this.openNative();
else if (this.startDetached) return this.openDetached(this.savedCss);
const settingsView = Utilities.findInRenderTree(thisObject._reactInternals, m => m && m.onSetSection, {walkable: ["child", "memoizedProps", "props", "children"]});
const settingsView = Utilities.findInTree(thisObject._reactInternals, m => m && m.onSetSection, {walkable: ["child", "memoizedProps", "props", "children"]});
if (settingsView && settingsView.onSetSection) settingsView.onSetSection(this.id);
}
});

View File

@ -2,7 +2,6 @@ const fs = require("fs");
const path = require("path");
import Builtin from "../../structs/builtin";
import DataStore from "../../modules/datastore";
import Utilities from "../../modules/utilities";
const timestamp = () => new Date().toISOString().replace("T", " ").replace("Z", "");
@ -18,6 +17,11 @@ const getCircularReplacer = () => {
};
};
const occurrences = (source, substring) => {
const regex = new RegExp(substring, "g");
return (source.match(regex) || []).length;
};
export default new class DebugLogs extends Builtin {
get name() {return "DebugLogs";}
get category() {return "developer";}
@ -45,7 +49,7 @@ export default new class DebugLogs extends Builtin {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (typeof(arg) === "string") {
const styleCount = Utilities.occurrences(arg, "%c");
const styleCount = occurrences(arg, "%c");
sanitized.push(arg.replace(/%c/g, ""));
if (styleCount > 0) i += styleCount;
}

View File

@ -20,7 +20,7 @@ export default new class EmoteMenu extends Builtin {
enabled() {
this.after(EmojiPicker, "type", (_, __, returnValue) => {
const originalChildren = Utilities.getNestedProp(returnValue, "props.children.props.children");
const originalChildren = returnValue?.props?.children?.props?.children;
if (!originalChildren || originalChildren.__patched) return;
const activePicker = useExpressionPickerStore((state) => state.activeView);
@ -30,8 +30,8 @@ export default new class EmoteMenu extends Builtin {
// Attach a try {} catch {} because this might crash the user.
try {
const head = Utilities.findInReactTree(childrenReturn, (e) => e?.role === "tablist")?.children;
const body = Utilities.findInReactTree(childrenReturn, (e) => e?.[0]?.type === "nav");
const head = Utilities.findInTree(childrenReturn, (e) => e?.role === "tablist", {walkable: ["props", "children", "return", "stateNode"]})?.children;
const body = Utilities.findInTree(childrenReturn, (e) => e?.[0]?.type === "nav", {walkable: ["props", "children", "return", "stateNode"]});
if (!head || !body) return childrenReturn;
const isActive = activePicker == "bd-emotes";

View File

@ -1,7 +1,7 @@
import Builtin from "../../structs/builtin";
import {EmoteConfig, Config} from "data";
import {Utilities, WebpackModules, DataStore, DiscordModules, Events, Settings, Strings} from "modules";
import {WebpackModules, DataStore, DiscordModules, Events, Settings, Strings} from "modules";
import BDEmote from "../../ui/emote";
import Modals from "../../ui/modals";
import Toasts from "../../ui/toasts";
@ -24,6 +24,10 @@ const Emotes = {
FrankerFaceZ: {}
};
const escape = (s) => {
return s.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
};
const blocklist = [];
const overrides = ["twitch", "subscriber", "bttv", "ffz"];
const modifiers = ["flip", "spin", "pulse", "spin2", "spin3", "1spin", "2spin", "3spin", "tr", "bl", "br", "shake", "shake2", "shake3", "flap"];
@ -53,7 +57,7 @@ const modifiers = ["flip", "spin", "pulse", "spin2", "spin3", "1spin", "2spin",
getUrl(category, name) {return EmoteURLs[category].format({id: Emotes[category][name]});}
getCategory(category) {return Emotes[category];}
getRemoteFile(category) {return Utilities.repoUrl(`assets/emotes/${category.toLowerCase()}.json`);}
getRemoteFile(category) {return `https://cdn.staticaly.com/gh/BetterDiscord/BetterDiscord/${Config.hash}/assets/emotes/${category.toLowerCase()}.json`;}
initialize() {
super.initialize();
@ -154,7 +158,7 @@ const modifiers = ["flip", "spin", "pulse", "spin2", "spin3", "1spin", "2spin",
}
if (!Emotes[current][emoteName]) continue;
const results = nodes[n].match(new RegExp(`([\\s]|^)${Utilities.escape(emoteModifier ? emoteName + ":" + emoteModifier : emoteName)}([\\s]|$)`));
const results = nodes[n].match(new RegExp(`([\\s]|^)${escape(emoteModifier ? emoteName + ":" + emoteModifier : emoteName)}([\\s]|$)`));
if (!results) continue;
const pre = nodes[n].substring(0, results.index + results[1].length);
const post = nodes[n].substring(results.index + results[0].length - results[2].length);

View File

@ -1,5 +1,5 @@
import Builtin from "../../structs/builtin";
import {DiscordModules, WebpackModules, Strings, DOM, React} from "modules";
import {DiscordModules, WebpackModules, Strings, DOMManager, React} from "modules";
import PublicServersMenu from "../../ui/publicservers/menu";
import Globe from "../../ui/icons/globe";
@ -80,13 +80,13 @@ export default new class PublicServers extends Builtin {
async _appendButton() {
await new Promise(r => setTimeout(r, 1000));
const existing = DOM.query("#bd-pub-li");
const existing = document.querySelector("#bd-pub-li");
if (existing) return;
const guilds = DOM.query(`.${DiscordModules.GuildClasses.guilds} .${DiscordModules.GuildClasses.listItem}`);
const guilds = document.querySelector(`.${DiscordModules.GuildClasses.guilds} .${DiscordModules.GuildClasses.listItem}`);
if (!guilds) return;
DOM.after(guilds, this.button);
guilds.parentNode.insertBefore(this.button, guilds.nextSibling);
}
openPublicServers() {
@ -94,8 +94,8 @@ export default new class PublicServers extends Builtin {
}
get button() {
const btn = DOM.createElement(`<div id="bd-pub-li" class="${DiscordModules.GuildClasses.listItem}">`);
const label = DOM.createElement(`<div id="bd-pub-button" class="${DiscordModules.GuildClasses.wrapper + " " + DiscordModules.GuildClasses.circleIconButton}">${Strings.PublicServers.button}</div>`);
const btn = DOMManager.parseHTML(`<div id="bd-pub-li" class="${DiscordModules.GuildClasses.listItem}">`);
const label = DOMManager.parseHTML(`<div id="bd-pub-button" class="${DiscordModules.GuildClasses.wrapper + " " + DiscordModules.GuildClasses.circleIconButton}">${Strings.PublicServers.button}</div>`);
label.addEventListener("click", () => {this.openPublicServers();});
btn.append(label);
return btn;

View File

@ -2,7 +2,7 @@ import require from "./polyfill"; // eslint-disable-line no-unused-vars
import secure from "./secure";
import LoadingIcon from "./loadingicon";
import BetterDiscord from "./modules/core";
import BdApi from "./modules/pluginapi";
import BdApi from "./modules/api/index";
// Perform some setup
secure();

View File

@ -1,4 +1,3 @@
import Utilities from "./utilities";
import Logger from "common/logger";
import Settings from "./settingsmanager";
import Events from "./emitter";
@ -138,9 +137,14 @@ export default class AddonManager {
parseOldMeta(fileContent, filename) {
const meta = fileContent.split("\n")[0];
const metaData = meta.substring(meta.lastIndexOf("//META") + 6, meta.lastIndexOf("*//"));
const parsed = Utilities.testJSON(metaData);
if (!parsed) throw new AddonError(filename, filename, Strings.Addons.metaError, {message: "", stack: meta}, this.prefix);
if (!parsed.name) throw new AddonError(filename, filename, Strings.Addons.missingNameData, {message: "", stack: meta}, this.prefix);
let parsed = null;
try {
parsed = JSON.parse(metaData);
}
catch (err) {
throw new AddonError(filename, filename, Strings.Addons.metaError, err, this.prefix);
}
if (!parsed || !parsed.name) throw new AddonError(filename, filename, Strings.Addons.missingNameData, {message: "", stack: meta}, this.prefix);
parsed.format = "json";
return parsed;
}

View File

@ -0,0 +1,64 @@
/**
* `AddonAPI` is a utility class for working with plugins and themes. Instances are accessible through the {@link BdApi}.
* @name AddonAPI
*/
class AddonAPI {
#manager;
constructor(manager) {this.#manager = manager;}
/**
* The path to the addon folder.
* @type string
*/
get folder() {return this.#manager.addonFolder;}
/**
* Determines if a particular adon is enabled.
* @param {string} idOrFile Addon id or filename.
* @returns {boolean}
*/
isEnabled(idOrFile) {return this.#manager.isEnabled(idOrFile);}
/**
* Enables the given addon.
* @param {string} idOrFile Addon id or filename.
*/
enable(idOrAddon) {return this.#manager.enableAddon(idOrAddon);}
/**
* Disables the given addon.
* @param {string} idOrFile Addon id or filename.
*/
disable(idOrAddon) {return this.#manager.disableAddon(idOrAddon);}
/**
* Toggles if a particular addon is enabled.
* @param {string} idOrFile Addon id or filename.
*/
toggle(idOrAddon) {return this.#manager.toggleAddon(idOrAddon);}
/**
* Reloads if a particular addon is enabled.
* @param {string} idOrFile Addon id or filename.
*/
reload(idOrFileOrAddon) {return this.#manager.reloadAddon(idOrFileOrAddon);}
/**
* Gets a particular addon.
* @param {string} idOrFile Addon id or filename.
* @returns {object} Addon instance
*/
get(idOrFile) {return this.#manager.getAddon(idOrFile);}
/**
* Gets all addons of this type.
* @returns {Array<object>} Array of all addon instances
*/
getAll() {return this.#manager.addonList.map(a => this.#manager.getAddon(a.id));}
}
Object.freeze(AddonAPI);
Object.freeze(AddonAPI.prototype);
export default AddonAPI;

View File

@ -0,0 +1,55 @@
import DataStore from "../datastore";
/**
* `Data` is a simple utility class for the management of plugin data. An instance is available on {@link BdApi}.
* @type Data
* @summary {@link Data} is a simple utility class for the management of plugin data.
* @name Data
*/
class Data {
constructor(callerName) {
if (!callerName) return;
this.save = this.save.bind(this, callerName);
this.load = this.load.bind(this, callerName);
this.delete = this.delete.bind(this, callerName);
}
/**
* Saves JSON-serializable data.
*
* @param {string} pluginName Name of the plugin saving data
* @param {string} key Which piece of data to store
* @param {any} data The data to be saved
* @returns
*/
save(pluginName, key, data) {
return DataStore.setPluginData(pluginName, key, data);
}
/**
* Loads previously stored data.
*
* @param {string} pluginName Name of the plugin loading data
* @param {string} key Which piece of data to load
* @returns {any} The stored data
*/
load(pluginName, key, data) {
return DataStore.setPluginData(pluginName, key, data);
}
/**
* Deletes a piece of stored data, this is different than saving as null or undefined.
*
* @param {string} pluginName Name of the plugin deleting data
* @param {string} key Which piece of data to delete
*/
delete(pluginName, key, data) {
return DataStore.setPluginData(pluginName, key, data);
}
}
Object.freeze(Data);
export default Data;

View File

@ -0,0 +1,101 @@
import DOMManager from "../dommanager";
/**
* `DOM` is a simple utility class for dom manipulation. An instance is available on {@link BdApi}.
* @type DOM
* @summary {@link DOM} is a simple utility class for dom manipulation.
* @name DOM
*/
class DOM {
/** Document/window width */
get screenWidth() {return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);}
/** Document/window height */
get screenHeight() {return Math.max(document.documentElement.clientHeight, window.innerHeight || 0);}
constructor(callerName) {
if (!callerName) return;
this.addStyle = this.addStyle.bind(this, callerName);
this.removeStyle = this.removeStyle.bind(this, callerName);
}
/**
* Adds a `<style>` to the document with the given ID.
*
* @param {string} id ID to use for style element
* @param {string} css CSS to apply to the document
*/
addStyle(id, css) {
if (arguments.length === 3) {
id = arguments[1];
css = arguments[2];
}
DOMManager.injectStyle(id, css);
}
/**
* Removes a `<style>` from the document corresponding to the given ID.
*
* @param {string} id ID uses for the style element
*/
removeStyle(id) {
if (arguments.length === 2) id = arguments[1];
DOMManager.removeStyle(id);
}
/**
* Adds a listener for when the node is removed from the document body.
*
* @param {HTMLElement} node Node to be observed
* @param {function} callback Function to run when fired
*/
onRemoved(node, callback) {
return DOMManager.onRemoved(node, callback);
}
/**
*
* @param {function} update render function indicating the style should be updates
* @param {number} duration duration in ms to animate for
* @param {object} [options] option to customize the animation
* @returns
*/
animate(update, duration, options = {}) {
return DOMManager.animate({update, duration, timing: options.timing});
}
/**
*
* @param {string} tag HTML tag name to create
* @param {object} [options] options object to customize the element
* @param {string} [options.className] class name to add to the element
* @param {string} [options.id] id to set for the element
* @param {HTMLElement} [options.target] target element to automatically append to
* @param {HTMLElement} [child] child node to add
* @returns HTMLElement
*/
createElement(tag, options = {}, child = null) {
return DOMManager.createElement(tag, options, child);
}
/**
* Parses a string of HTML and returns the results. If the second parameter is true,
* the parsed HTML will be returned as a document fragment {@see https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment}.
* This is extremely useful if you have a list of elements at the top level, they can then be appended all at once to another node.
*
* If the second parameter is false, then the return value will be the list of parsed
* nodes and there were multiple top level nodes, otherwise the single node is returned.
* @param {string} html - HTML to be parsed
* @param {boolean} [fragment=false] - Whether or not the return should be the raw `DocumentFragment`
* @returns {(DocumentFragment|NodeList|HTMLElement)} - The result of HTML parsing
*/
parseHTML(html, fragment = false) {
return DOMManager.parseHTML(html, fragment);
}
}
Object.freeze(DOM);
export default DOM;

View File

@ -0,0 +1,113 @@
import PluginManager from "../pluginmanager";
import ThemeManager from "../thememanager";
import Logger from "common/logger";
import AddonAPI from "./addonapi";
import Data from "./data";
import DOM from "./dom";
import Patcher from "./patcher";
import ReactUtils from "./reactutils";
import UI from "./ui";
import Utils from "./utils";
import Webpack from "./webpack";
import * as Legacy from "./legacy";
const bounded = new Map();
const PluginAPI = new AddonAPI(PluginManager);
const ThemeAPI = new AddonAPI(ThemeManager);
const PatcherAPI = new Patcher();
const DataAPI = new Data();
const DOMAPI = new DOM();
/**
* `BdApi` is a globally (`window.BdApi`) accessible object for use by plugins and developers to make their lives easier.
* @name BdApi
*/
export default class BdApi {
constructor(pluginName) {
if (!pluginName) return BdApi;
if (bounded.has(pluginName)) return bounded.get(pluginName);
if (typeof(pluginName) !== "string") {
Logger.error("BdApi", "Plugin name not a string, returning generic API!");
return BdApi;
}
// Re-add legacy functions
Object.assign(this, Legacy);
// Bind to pluginName
this.Patcher = new Patcher(pluginName);
this.Data = new Data(pluginName);
this.DOM = new DOM(pluginName);
bounded.set(pluginName, this);
}
// Non-bound namespaces
get Plugins() {return PluginAPI;}
get Themes() {return ThemeAPI;}
get Webpack() {return Webpack;}
get Utils() {return Utils;}
get UI() {return UI;}
get ReactUtils() {return ReactUtils;}
}
// Add legacy functions
Object.assign(BdApi, Legacy);
/**
* An instance of {@link AddonAPI} to access plugins.
* @type AddonAPI
*/
BdApi.Plugins = PluginAPI;
/**
* An instance of {@link AddonAPI} to access themes.
* @type AddonAPI
*/
BdApi.Themes = ThemeAPI;
/**
* An instance of {@link Patcher} to monkey patch functions.
* @type Patcher
*/
BdApi.Patcher = PatcherAPI;
/**
* An instance of {@link Webpack} to search for modules.
* @type Webpack
*/
BdApi.Webpack = Webpack;
/**
* An instance of {@link Data} to manage data.
* @type Data
*/
BdApi.Data = DataAPI;
/**
* An instance of {@link UI} to create interfaces.
* @type UI
*/
BdApi.UI = UI;
/**
* An instance of {@link ReactUtils} to work with React.
* @type ReactUtils
*/
BdApi.ReactUtils = ReactUtils;
/**
* An instance of {@link Utils} for general utility functions.
* @type Utils
*/
BdApi.Utils = Utils;
/**
* An instance of {@link DOM} to interact with the DOM.
* @type DOM
*/
BdApi.DOM = DOMAPI;
Object.freeze(BdApi);
Object.freeze(BdApi.prototype);

View File

@ -0,0 +1,438 @@
import {Config} from "data";
import WebpackModules from "../webpackmodules";
import DiscordModules from "../discordmodules";
import DataStore from "../datastore";
import DOMManager from "../dommanager";
import Toasts from "../../ui/toasts";
import Notices from "../../ui/notices";
import Modals from "../../ui/modals";
import Settings from "../settingsmanager";
import Logger from "common/logger";
import Patcher from "../patcher";
import Emotes from "../../builtins/emotes/emotes";
import ipc from "../ipc";
/**
* The React module being used inside Discord.
* @type React
*/
export const React = DiscordModules.React;
/**
* The ReactDOM module being used inside Discord.
* @type ReactDOM
*/
export const ReactDOM = DiscordModules.ReactDOM;
/**
* A reference object to get BD's settings.
* @type object
* @deprecated
*/
export const settings = Settings.collections;
/**
* A reference object for BD's emotes.
* @type object
* @deprecated
*/
export const emotes = new Proxy(Emotes.Emotes, {
get(obj, category) {
if (category === "blocklist") return Emotes.blocklist;
const group = Emotes.Emotes[category];
if (!group) return undefined;
return new Proxy(group, {
get(cat, emote) {return group[emote];},
set() {Logger.warn("BdApi.emotes", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");}
});
},
set() {Logger.warn("BdApi.emotes", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");}
});
/**
* A reference string for BD's version.
* @type string
*/
export const version = Config.version;
/**
* Adds a `<style>` to the document with the given ID.
*
* @param {string} id ID to use for style element
* @param {string} css CSS to apply to the document
*/
export const injectCSS = function (id, css) {
DOMManager.injectStyle(id, css);
};
/**
* Removes a `<style>` from the document corresponding to the given ID.
*
* @param {string} id ID uses for the style element
*/
export const clearCSS = function (id) {
DOMManager.removeStyle(id);
};
/**
* Automatically creates and links a remote JS script.
*
* @deprecated
* @param {string} id ID of the script element
* @param {string} url URL of the remote script
* @returns {Promise} Resolves upon onload event
*/
export const linkJS = function (id, url) {
return DOMManager.injectScript(id, url);
};
/**
* Removes a remotely linked JS script.
*
* @deprecated
* @param {string} id ID of the script element
*/
export const unlinkJS = function (id) {
DOMManager.removeScript(id);
};
/**
* Shows a generic but very customizable modal.
*
* @param {string} title title of the modal
* @param {(string|ReactElement|Array<string|ReactElement>)} content a string of text to display in the modal
*/
export const alert = function (title, content) {
Modals.alert(title, content);
};
/**
* Shows a generic but very customizable confirmation modal with optional confirm and cancel callbacks.
*
* @param {string} title title of the modal
* @param {(string|ReactElement|Array<string|ReactElement>)} children a single or mixed array of react elements and strings. Everything is wrapped in Discord's `TextElement` component so strings will show and render properly.
* @param {object} [options] options to modify the modal
* @param {boolean} [options.danger=false] whether the main button should be red or not
* @param {string} [options.confirmText=Okay] text for the confirmation/submit button
* @param {string} [options.cancelText=Cancel] text for the cancel button
* @param {callable} [options.onConfirm=NOOP] callback to occur when clicking the submit button
* @param {callable} [options.onCancel=NOOP] callback to occur when clicking the cancel button
*/
export const showConfirmationModal = function (title, content, options = {}) {
return Modals.showConfirmationModal(title, content, options);
};
/**
* This shows a toast similar to android towards the bottom of the screen.
*
* @param {string} content The string to show in the toast.
* @param {object} options Options object. Optional parameter.
* @param {string} [options.type=""] Changes the type of the toast stylistically and semantically. Choices: "", "info", "success", "danger"/"error", "warning"/"warn". Default: ""
* @param {boolean} [options.icon=true] Determines whether the icon should show corresponding to the type. A toast without type will always have no icon. Default: true
* @param {number} [options.timeout=3000] Adjusts the time (in ms) the toast should be shown for before disappearing automatically. Default: 3000
* @param {boolean} [options.forceShow=false] Whether to force showing the toast and ignore the bd setting
*/
export const showToast = function(content, options = {}) {
Toasts.show(content, options);
};
/**
* Show a notice above discord's chat layer.
*
* @param {string|Node} content Content of the notice
* @param {object} options Options for the notice.
* @param {string} [options.type="info" | "error" | "warning" | "success"] Type for the notice. Will affect the color.
* @param {Array<{label: string, onClick: function}>} [options.buttons] Buttons that should be added next to the notice text.
* @param {number} [options.timeout=10000] Timeout until the notice is closed. Won't fire if it's set to 0;
* @returns {function}
*/
export const showNotice = function (content, options = {}) {
return Notices.show(content, options);
};
/**
* Finds a webpack module using a filter
*
* @deprecated
* @param {function} filter A filter given the exports, module, and moduleId. Returns true if the module matches.
* @returns {any} Either the matching module or `undefined`
*/
export const findModule = function(filter) {
return WebpackModules.getModule(filter);
};
/**
* Finds multple webpack modules using a filter
*
* @deprecated
* @param {function} filter A filter given the exports, module, and moduleId. Returns true if the module matches.
* @returns {Array} Either an array of matching modules or an empty array
*/
export const findAllModules = function(filter) {
return WebpackModules.getModule(filter, {first: false});
};
/**
* Finds a webpack module by own properties
*
* @deprecated
* @param {...string} props Any desired properties
* @returns {any} Either the matching module or `undefined`
*/
export const findModuleByProps = function(...props) {
return WebpackModules.getByProps(...props);
};
/**
* Finds a webpack module by own prototypes
*
* @deprecated
* @param {...string} protos Any desired prototype properties
* @returns {any} Either the matching module or `undefined`
*/
export const findModuleByPrototypes = function(...protos) {
return WebpackModules.getByPrototypes(...protos);
};
/**
* Finds a webpack module by displayName property
*
* @deprecated
* @param {string} name Desired displayName property
* @returns {any} Either the matching module or `undefined`
*/
export const findModuleByDisplayName = function(name) {
return WebpackModules.getByDisplayName(name);
};
/**
* Get the internal react data of a specified node
*
* @param {HTMLElement} node Node to get the react data from
* @returns {object|undefined} Either the found data or `undefined`
*/
export const getInternalInstance = function(node) {
if (node.__reactInternalInstance$) return node.__reactInternalInstance$;
return node[Object.keys(node).find(k => k.startsWith("__reactInternalInstance") || k.startsWith("__reactFiber"))] || null;
};
/**
* Loads previously stored data.
*
* @param {string} pluginName Name of the plugin loading data
* @param {string} key Which piece of data to load
* @returns {any} The stored data
*/
export const loadData = function(pluginName, key) {
return DataStore.getPluginData(pluginName, key);
};
/** @alias loadData */
export const getData = loadData;
/**
* Saves JSON-serializable data.
*
* @param {string} pluginName Name of the plugin saving data
* @param {string} key Which piece of data to store
* @param {any} data The data to be saved
* @returns
*/
export const saveData = function(pluginName, key, data) {
return DataStore.setPluginData(pluginName, key, data);
};
/** @alias saveData */
export const setData = saveData;
/**
* Deletes a piece of stored data, this is different than saving as null or undefined.
*
* @param {string} pluginName Name of the plugin deleting data
* @param {string} key Which piece of data to delete
*/
export const deleteData = function(pluginName, key) {
DataStore.deletePluginData(pluginName, key);
};
/**
* This function monkey-patches a method on an object. The patching callback may be run before, after or instead of target method.
*
* - Be careful when monkey-patching. Think not only about original functionality of target method and your changes, but also about developers of other plugins, who may also patch this method before or after you. Try to change target method behaviour as little as possible, and avoid changing method signatures.
* - Display name of patched method is changed, so you can see if a function has been patched (and how many times) while debugging or in the stack trace. Also, patched methods have property `__monkeyPatched` set to `true`, in case you want to check something programmatically.
*
* @deprecated
* @param {object} what Object to be patched. You can can also pass class prototypes to patch all class instances.
* @param {string} methodName Name of the function to be patched.
* @param {object} options Options object to configure the patch.
* @param {function} [options.after] Callback that will be called after original target method call. You can modify return value here, so it will be passed to external code which calls target method. Can be combined with `before`.
* @param {function} [options.before] Callback that will be called before original target method call. You can modify arguments here, so it will be passed to original method. Can be combined with `after`.
* @param {function} [options.instead] Callback that will be called instead of original target method call. You can get access to original method using `originalMethod` parameter if you want to call it, but you do not have to. Can't be combined with `before` or `after`.
* @param {boolean} [options.once=false] Set to `true` if you want to automatically unpatch method after first call.
* @param {boolean} [options.silent=false] Set to `true` if you want to suppress log messages about patching and unpatching.
* @returns {function} A function that cancels the monkey patch
*/
export const monkeyPatch = function(what, methodName, options) {
const {before, after, instead, once = false, callerId = "BdApi"} = options;
const patchType = before ? "before" : after ? "after" : instead ? "instead" : "";
if (!patchType) return Logger.err("BdApi", "Must provide one of: after, before, instead");
const originalMethod = what[methodName];
const data = {
originalMethod: originalMethod,
callOriginalMethod: () => data.originalMethod.apply(data.thisObject, data.methodArguments)
};
data.cancelPatch = Patcher[patchType](callerId, what, methodName, (thisObject, args, returnValue) => {
data.thisObject = thisObject;
data.methodArguments = args;
data.returnValue = returnValue;
try {
const patchReturn = Reflect.apply(options[patchType], null, [data]);
if (once) data.cancelPatch();
return patchReturn;
}
catch (err) {
Logger.stacktrace(`${callerId}:monkeyPatch`, `Error in the ${patchType} of ${methodName}`, err);
}
});
return data.cancelPatch;
};
/**
* Adds a listener for when the node is removed from the document body.
*
* @param {HTMLElement} node Node to be observed
* @param {function} callback Function to run when fired
*/
export const onRemoved = function(node, callback) {
return DOMManager.onRemoved(node, callback);
};
/**
* Wraps a given function in a `try..catch` block.
*
* @deprecated
* @param {function} method Function to wrap
* @param {string} message Additional messasge to print when an error occurs
* @returns {function} The new wrapped function
*/
export const suppressErrors = function(method, message) {
return (...params) => {
try {return method(...params);}
catch (e) {Logger.stacktrace("SuppressedError", "Error occurred in " + message, e);}
};
};
/**
* Tests a given object to determine if it is valid JSON.
*
* @deprecated
* @param {object} data Data to be tested
* @returns {boolean} Result of the test
*/
export const testJSON = function(data) {
try {
return JSON.parse(data);
}
catch (err) {
return false;
}
};
/**
* Gets a specific setting's status from BD
*
* @deprecated
* @param {string} [collection="settings"] Collection ID
* @param {string} category Category ID in the collection
* @param {string} id Setting ID in the category
* @returns {boolean} If the setting is enabled
*/
export const isSettingEnabled = function(collection, category, id) {
return Settings.get(collection, category, id);
};
/**
* Enable a BetterDiscord setting by ids.
*
* @deprecated
* @param {string} [collection="settings"] Collection ID
* @param {string} category Category ID in the collection
* @param {string} id Setting ID in the category
*/
export const enableSetting = function(collection, category, id) {
return Settings.set(collection, category, id, true);
};
/**
* Disables a BetterDiscord setting by ids.
*
* @deprecated
* @param {string} [collection="settings"] Collection ID
* @param {string} category Category ID in the collection
* @param {string} id Setting ID in the category
*/
export const disableSetting = function(collection, category, id) {
return Settings.set(collection, category, id, false);
};
/**
* Toggle a BetterDiscord setting by ids.
*
* @deprecated
* @param {string} [collection="settings"] Collection ID
* @param {string} category Category ID in the collection
* @param {string} id Setting ID in the category
*/
export const toggleSetting = function(collection, category, id) {
return Settings.set(collection, category, id, !Settings.get(collection, category, id));
};
/**
* Gets some data in BetterDiscord's misc data.
*
* @deprecated
* @param {string} key Key of the data to load.
* @returns {any} The stored data
*/
export const getBDData = function(key) {
return DataStore.getBDData(key);
};
/**
* Gets some data in BetterDiscord's misc data.
*
* @deprecated
* @param {string} key Key of the data to load.
* @returns {any} The stored data
*/
export const setBDData = function(key, data) {
return DataStore.setBDData(key, data);
};
/**
* Gives access to the [Electron Dialog](https://www.electronjs.org/docs/latest/api/dialog/) api.
* Returns a `Promise` that resolves to an `object` that has a `boolean` cancelled and a `filePath` string for saving and a `filePaths` string array for opening.
*
* @param {object} options Options object to configure the dialog.
* @param {"open"|"save"} [options.mode="open"] Determines whether the dialog should open or save files.
* @param {string} [options.defaultPath=~] Path the dialog should show on launch.
* @param {Array<object<string, string[]>>} [options.filters=[]] An array of [file filters](https://www.electronjs.org/docs/latest/api/structures/file-filter).
* @param {string} [options.title] Title for the titlebar.
* @param {string} [options.message] Message for the dialog.
* @param {boolean} [options.showOverwriteConfirmation=false] Whether the user should be prompted when overwriting a file.
* @param {boolean} [options.showHiddenFiles=false] Whether hidden files should be shown in the dialog.
* @param {boolean} [options.promptToCreate=false] Whether the user should be prompted to create non-existant folders.
* @param {boolean} [options.openDirectory=false] Whether the user should be able to select a directory as a target.
* @param {boolean} [options.openFile=true] Whether the user should be able to select a file as a target.
* @param {boolean} [options.multiSelections=false] Whether the user should be able to select multiple targets.
* @param {boolean} [options.modal=false] Whether the dialog should act as a modal to the main window.
* @returns {Promise<object>} Result of the dialog
*/
export const openDialog = async function (options) {
const data = await ipc.openDialog(options);
if (data.error) throw new Error(data.error);
return data;
};

View File

@ -0,0 +1,83 @@
import Logger from "common/logger";
import {default as MainPatcher} from "../patcher";
/**
* `Patcher` is a utility class for modifying existing functions. Instance is accessible through the {@link BdApi}.
* This is extremely useful for modifying the internals of Discord by adjusting return value or React renders, or arguments of internal functions.
* @type Patcher
* @summary {@link Patcher} is a utility class for modifying existing functions.
* @name Patcher
*/
class Patcher {
constructor(callerName) {
if (!callerName) return;
this.before = this.before.bind(this, callerName);
this.instead = this.instead.bind(this, callerName);
this.after = this.after.bind(this, callerName);
this.getPatchesByCaller = this.getPatchesByCaller.bind(this, callerName);
this.unpatchAll = this.unpatchAll.bind(this, callerName);
}
/**
* This method patches onto another function, allowing your code to run beforehand.
* Using this, you are also able to modify the incoming arguments before the original method is run.
* @param {string} caller Name of the caller of the patch function.
* @param {object} moduleToPatch Object with the function to be patched. Can also be an object's prototype.
* @param {string} functionName Name of the function to be patched.
* @param {function} callback Function to run before the original method. The function is given the `this` context and the `arguments` of the original function.
* @returns {function} Function that cancels the original patch.
*/
before(caller, moduleToPatch, functionName, callback) {
return MainPatcher.pushChildPatch(caller, moduleToPatch, functionName, callback, {type: "before"});
}
/**
* This method patches onto another function, allowing your code to run instead.
* Using this, you are also able to modify the return value, using the return of your code instead.
* @param {string} caller Name of the caller of the patch function.
* @param {object} moduleToPatch Object with the function to be patched. Can also be an object's prototype.
* @param {string} functionName Name of the function to be patched.
* @param {function} callback Function to run before the original method. The function is given the `this` context, `arguments` of the original function, and also the original function.
* @returns {function} Function that cancels the original patch.
*/
instead(caller, moduleToPatch, functionName, callback) {
return MainPatcher.pushChildPatch(caller, moduleToPatch, functionName, callback, {type: "instead"});
}
/**
* This method patches onto another function, allowing your code to run instead.
* Using this, you are also able to modify the return value, using the return of your code instead.
* @param {string} caller Name of the caller of the patch function.
* @param {object} moduleToPatch Object with the function to be patched. Can also be an object's prototype.
* @param {string} functionName Name of the function to be patched.
* @param {function} callback Function to run after the original method. The function is given the `this` context, the `arguments` of the original function, and the `return` value of the original function.
* @returns {function} Function that cancels the original patch.
*/
after(caller, moduleToPatch, functionName, callback) {
return MainPatcher.pushChildPatch(caller, moduleToPatch, functionName, callback, {type: "after"});
}
/**
* Returns all patches by a particular caller. The patches all have an `unpatch()` method.
* @param {string} caller ID of the original patches
* @returns {Array<function>} Array of all the patch objects.
*/
getPatchesByCaller(caller) {
if (typeof(caller) !== "string") return Logger.err("BdApi.Patcher", "Parameter 0 of getPatchesByCaller must be a string representing the caller");
return MainPatcher.getPatchesByCaller(caller);
}
/**
* Automatically cancels all patches created with a specific ID.
* @param {string} caller ID of the original patches
*/
unpatchAll(caller) {
if (typeof(caller) !== "string") return Logger.err("BdApi.Patcher", "Parameter 0 of unpatchAll must be a string representing the caller");
MainPatcher.unpatchAll(caller);
}
}
Object.freeze(Patcher);
export default Patcher;

View File

@ -0,0 +1,82 @@
import Utilities from "../utilities";
import DiscordModules from "../discordmodules";
/**
* `ReactUtils` is a utility class for interacting with React internals. Instance is accessible through the {@link BdApi}.
* This is extremely useful for interacting with the internals of the UI.
* @type ReactUtils
* @summary {@link ReactUtils} is a utility class for interacting with React internals.
* @memberof BdApi
* @name ReactUtils
*/
const ReactUtils = {
get rootInstance() {return document.getElementById("app-mount")?._reactRootContainer?._internalRoot?.current;},
/**
* Get the internal react data of a specified node
*
* @param {HTMLElement} node Node to get the react data from
* @returns {object|undefined} Either the found data or `undefined`
*/
getInternalInstance(node) {
if (node.__reactFiber$) return node.__reactFiber$;
return node[Object.keys(node).find(k => k.startsWith("__reactInternalInstance") || k.startsWith("__reactFiber"))] || null;
},
/**
* Grabs a value from the react internal instance. Allows you to grab
* long depth values safely without accessing no longer valid properties.
* @param {(HTMLElement|jQuery)} node - node to obtain react instance of
* @param {object} options - options for the search
* @param {array} [options.include] - list of items to include from the search
* @param {array} [options.exclude=["Popout", "Tooltip", "Scroller", "BackgroundFlash"]] - list of items to exclude from the search
* @param {callable} [options.filter=_=>_] - filter to check the current instance with (should return a boolean)
* @return {(*|null)} the owner instance or undefined if not found.
*/
getOwnerInstance(node, {include, exclude = ["Popout", "Tooltip", "Scroller", "BackgroundFlash"], filter = _ => _} = {}) {
if (node === undefined) return undefined;
const excluding = include === undefined;
const nameFilter = excluding ? exclude : include;
function getDisplayName(owner) {
const type = owner.type;
if (!type) return null;
return type.displayName || type.name || null;
}
function classFilter(owner) {
const name = getDisplayName(owner);
return (name !== null && !!(nameFilter.includes(name) ^ excluding));
}
let curr = ReactUtils.getReactInstance(node);
for (curr = curr && curr.return; !Utilities.isNil(curr); curr = curr.return) {
if (Utilities.isNil(curr)) continue;
const owner = curr.stateNode;
if (!Utilities.isNil(owner) && !(owner instanceof HTMLElement) && classFilter(curr) && filter(owner)) return owner;
}
return null;
},
/**
* Creates an unrendered react component that wraps dom elements.
* @param {HTMLElement} element - element or array of elements to wrap into a react component
* @returns {object} - unrendered react component
*/
wrapElement(element) {
return class ReactWrapper extends DiscordModules.React.Component {
constructor(props) {
super(props);
this.element = element;
}
componentDidMount() {this.refs.element.appendChild(this.element);}
render() {return DiscordModules.React.createElement("div", {className: "react-wrapper", ref: "element"});}
};
}
};
Object.freeze(ReactUtils);
export default ReactUtils;

View File

@ -0,0 +1,116 @@
import Modals from "../../ui/modals";
import Toasts from "../../ui/toasts";
import Notices from "../../ui/notices";
import Tooltip from "../../ui/tooltip";
import ipc from "../ipc";
/**
* `UI` is a utility class for getting internal webpack modules. Instance is accessible through the {@link BdApi}.
* This is extremely useful for interacting with the internals of Discord.
* @type UI
* @summary {@link UI} is a utility class for getting internal webpack modules.
* @memberof BdApi
* @name UI
*/
const UI = {
/**
* Shows a generic but very customizable modal.
*
* @param {string} title title of the modal
* @param {(string|ReactElement|Array<string|ReactElement>)} content a string of text to display in the modal
*/
alert(title, content) {
Modals.alert(title, content);
},
/**
* Creates a tooltip to automatically show on hover.
*
* @param {HTMLElement} node - DOM node to monitor and show the tooltip on
* @param {string|HTMLElement} content - string to show in the tooltip
* @param {object} options - additional options for the tooltip
* @param {"primary"|"info"|"success"|"warn"|"danger"} [options.style="primary"] - correlates to the discord styling/colors
* @param {"top"|"right"|"bottom"|"left"} [options.side="top"] - can be any of top, right, bottom, left
* @param {boolean} [options.preventFlip=false] - prevents moving the tooltip to the opposite side if it is too big or goes offscreen
* @param {boolean} [options.disabled=false] - whether the tooltip should be disabled from showing on hover
* @returns new Tooltip
*/
createTooltip(node, content, options = {}) {
return Tooltip.create(node, content, options);
},
/**
* Shows a generic but very customizable confirmation modal with optional confirm and cancel callbacks.
*
* @param {string} title title of the modal
* @param {(string|ReactElement|Array<string|ReactElement>)} children a single or mixed array of react elements and strings. Everything is wrapped in Discord's `TextElement` component so strings will show and render properly.
* @param {object} [options] options to modify the modal
* @param {boolean} [options.danger=false] whether the main button should be red or not
* @param {string} [options.confirmText=Okay] text for the confirmation/submit button
* @param {string} [options.cancelText=Cancel] text for the cancel button
* @param {callable} [options.onConfirm=NOOP] callback to occur when clicking the submit button
* @param {callable} [options.onCancel=NOOP] callback to occur when clicking the cancel button
*/
showConfirmationModal(title, content, options = {}) {
return Modals.showConfirmationModal(title, content, options);
},
/**
* This shows a toast similar to android towards the bottom of the screen.
*
* @param {string} content The string to show in the toast.
* @param {object} options Options object. Optional parameter.
* @param {string} [options.type=""] Changes the type of the toast stylistically and semantically. Choices: "", "info", "success", "danger"/"error", "warning"/"warn". Default: ""
* @param {boolean} [options.icon=true] Determines whether the icon should show corresponding to the type. A toast without type will always have no icon. Default: true
* @param {number} [options.timeout=3000] Adjusts the time (in ms) the toast should be shown for before disappearing automatically. Default: 3000
* @param {boolean} [options.forceShow=false] Whether to force showing the toast and ignore the bd setting
*/
showToast(content, options = {}) {
Toasts.show(content, options);
},
/**
* Show a notice above discord's chat layer.
*
* @param {string|Node} content Content of the notice
* @param {object} options Options for the notice.
* @param {string} [options.type="info" | "error" | "warning" | "success"] Type for the notice. Will affect the color.
* @param {Array<{label: string, onClick: function}>} [options.buttons] Buttons that should be added next to the notice text.
* @param {number} [options.timeout=10000] Timeout until the notice is closed. Won't fire if it's set to 0;
* @returns {function}
*/
showNotice(content, options = {}) {
return Notices.show(content, options);
},
/**
* Gives access to the [Electron Dialog](https://www.electronjs.org/docs/latest/api/dialog/) api.
* Returns a `Promise` that resolves to an `object` that has a `boolean` cancelled and a `filePath` string for saving and a `filePaths` string array for opening.
*
* @param {object} options Options object to configure the dialog.
* @param {"open"|"save"} [options.mode="open"] Determines whether the dialog should open or save files.
* @param {string} [options.defaultPath=~] Path the dialog should show on launch.
* @param {Array<object<string, string[]>>} [options.filters=[]] An array of [file filters](https://www.electronjs.org/docs/latest/api/structures/file-filter).
* @param {string} [options.title] Title for the titlebar.
* @param {string} [options.message] Message for the dialog.
* @param {boolean} [options.showOverwriteConfirmation=false] Whether the user should be prompted when overwriting a file.
* @param {boolean} [options.showHiddenFiles=false] Whether hidden files should be shown in the dialog.
* @param {boolean} [options.promptToCreate=false] Whether the user should be prompted to create non-existant folders.
* @param {boolean} [options.openDirectory=false] Whether the user should be able to select a directory as a target.
* @param {boolean} [options.openFile=true] Whether the user should be able to select a file as a target.
* @param {boolean} [options.multiSelections=false] Whether the user should be able to select multiple targets.
* @param {boolean} [options.modal=false] Whether the dialog should act as a modal to the main window.
* @returns {Promise<object>} Result of the dialog
*/
async openDialog(options) {
const data = await ipc.openDialog(options);
if (data.error) throw new Error(data.error);
return data;
}
};
Object.freeze(UI);
export default UI;

View File

@ -0,0 +1,73 @@
import Utilities from "../utilities";
/**
* `Utils` is a utility class for interacting with React internals. Instance is accessible through the {@link BdApi}.
* This is extremely useful for interacting with the internals of the UI.
* @type Utils
* @summary {@link Utils} is a utility class for interacting with React internals.
* @memberof BdApi
* @name Utils
*/
const Utils = {
/**
* Finds a value, subobject, or array from a tree that matches a specific filter. This is a DFS.
* @param {object} tree Tree that should be walked
* @param {callable} searchFilter Filter to check against each object and subobject
* @param {object} options Additional options to customize the search
* @param {Array<string>|null} [options.walkable=null] Array of strings to use as keys that are allowed to be walked on. Null value indicates all keys are walkable
* @param {Array<string>} [options.ignore=[]] Array of strings to use as keys to exclude from the search, most helpful when `walkable = null`.
*/
findInTree(tree, searchFilter, options = {}) {
return Utilities.findInTree(tree, searchFilter, options);
},
/**
* Deep extends an object with a set of other objects. Objects later in the list
* of `extenders` have priority, that is to say if one sets a key to be a primitive,
* it will be overwritten with the next one with the same key. If it is an object,
* and the keys match, the object is extended. This happens recursively.
* @param {object} extendee - Object to be extended
* @param {...object} extenders - Objects to extend with
* @returns {object} - A reference to `extendee`
*/
extend(extendee, ...extenders) {
return Utilities.extend(extendee, ...extenders);
},
/**
* Returns a function, that, as long as it continues to be invoked, will not
* be triggered. The function will be called after it stops being called for
* N milliseconds.
*
* Adapted from the version by David Walsh (https://davidwalsh.name/javascript-debounce-function)
*
* @param {function} executor
* @param {number} delay
*/
debounce(executor, delay) {
return Utilities.debounce(executor, delay);
},
/**
* Takes a string of html and escapes it using the brower's own escaping mechanism.
* @param {String} html - html to be escaped
*/
escapeHTML(html) {
return Utilities.escapeHTML(html);
},
/**
* Builds a classname string from any number of arguments. This includes arrays and objects.
* When given an array all values from the array are added to the list.
* When given an object they keys are added as the classnames if the value is truthy.
* Copyright (c) 2018 Jed Watson https://github.com/JedWatson/classnames MIT License
* @param {...Any} argument - anything that should be used to add classnames.
*/
className() {
return Utilities.className(...arguments);
}
};
Object.freeze(Utils);
export default Utils;

View File

@ -0,0 +1,111 @@
import Logger from "common/logger";
import WebpackModules, {Filters} from "../webpackmodules";
/**
* `Webpack` is a utility class for getting internal webpack modules. Instance is accessible through the {@link BdApi}.
* This is extremely useful for interacting with the internals of Discord.
* @type Webpack
* @summary {@link Webpack} is a utility class for getting internal webpack modules.
* @memberof BdApi
* @name Webpack
*/
const Webpack = {
/**
* Series of {@link Filters} to be used for finding webpack modules.
* @type Filters
* @memberof Webpack
*/
Filters: {
/**
* Generates a function that filters by a set of properties.
* @param {...string} props List of property names
* @returns {function} A filter that checks for a set of properties
*/
byProps(...props) {return Filters.byProps(props);},
/**
* Generates a function that filters by a set of properties on the object's prototype.
* @param {...string} props List of property names
* @returns {function} A filter that checks for a set of properties on the object's prototype.
*/
byPrototypeFields(...props) {return Filters.byPrototypeFields(props);},
/**
* Generates a function that filters by a regex.
* @param {RegExp} search A RegExp to check on the module
* @param {function} filter Additional filter
* @returns {function} A filter that checks for a regex match
*/
byRegex(regex) {return Filters.byRegex(regex);},
/**
* Generates a function that filters by strings.
* @param {...String} strings A list of strings
* @returns {function} A filter that checks for a set of strings
*/
byStrings(...strings) {return Filters.byStrings(...strings);},
/**
* Generates a function that filters by a set of properties.
* @param {string} name Name the module should have
* @returns {function} A filter that checks for a set of properties
*/
byDisplayName(name) {return Filters.byDisplayName(name);},
/**
* Generates a combined function from a list of filters.
* @param {...function} filters A list of filters
* @returns {function} Combinatory filter of all arguments
*/
combine(...filters) {return Filters.combine(...filters);},
},
/**
* Finds a module using a filter function.
* @memberof Webpack
* @param {function} filter A function to use to filter modules. It is given exports, module, and moduleID. Return true to signify match.
* @param {object} [options] Whether to return only the first matching module
* @param {Boolean} [options.first=true] Whether to return only the first matching module
* @param {Boolean} [options.defaultExport=true] Whether to return default export when matching the default export
* @return {any}
*/
getModule(filter, options = {}) {
if (("first" in options) && typeof(options.first) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.first", options.first, "boolean expected.");
if (("defaultExport" in options) && typeof(options.defaultExport) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.defaultExport", options.defaultExport, "boolean expected.");
if (("searchGetters" in options) && typeof(options.searchGetters) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.searchGetters", options.searchGetters, "boolean expected.");
return WebpackModules.getModule(filter, options);
},
/**
* Finds multiple modules using multiple filters.
* @memberof Webpack
* @param {...object} queries Whether to return only the first matching module
* @param {Function} queries.filter A function to use to filter modules
* @param {Boolean} [queries.first=true] Whether to return only the first matching module
* @param {Boolean} [queries.defaultExport=true] Whether to return default export when matching the default export
* @return {any}
*/
getBulk(...queries) {return WebpackModules.getBulk(...queries);},
/**
* Finds a module that lazily loaded.
* @memberof Webpack
* @param {function} filter A function to use to filter modules. It is given exports. Return true to signify match.
* @param {object} [options] Whether to return only the first matching module
* @param {AbortSignal} [options.signal] AbortSignal of an AbortController to cancel the promise
* @param {Boolean} [options.defaultExport=true] Whether to return default export when matching the default export
* @returns {Promise<any>}
*/
waitForModule(filter, options = {}) {
if (("defaultExport" in options) && typeof(options.defaultExport) !== "boolean") return Logger.error("BdApi.Webpack~waitForModule", "Unsupported type used for options.defaultExport", options.defaultExport, "boolean expected.");
if (("signal" in options) && !(options.signal instanceof AbortSignal)) return Logger.error("BdApi.Webpack~waitForModule", "Unsupported type used for options.signal", options.signal, "AbortSignal expected.");
if (("searchGetters" in options) && typeof(options.searchGetters) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.searchGetters", options.searchGetters, "boolean expected.");
return WebpackModules.getLazy(filter, options);
},
};
Object.freeze(Webpack);
Object.freeze(Webpack.Filters);
export default Webpack;

View File

@ -1,5 +1,4 @@
import {Config} from "data";
import Utilities from "./utilities";
import Logger from "common/logger";
const fs = require("fs");
const path = require("path");
@ -130,7 +129,8 @@ export default new class DataStore {
getLocale(locale) {
const file = path.resolve(this.localeFolder, `${locale}.json`);
if (!fs.existsSync(file)) return null;
return Utilities.testJSON(fs.readFileSync(file).toString());
try {return JSON.parse(fs.readFileSync(file).toString());}
catch {return false;}
}
saveLocale(locale, strings) {

View File

@ -1,5 +1,11 @@
export default class DOMManager {
/** Document/window width */
static get screenWidth() {return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);}
/** Document/window height */
static get screenHeight() {return Math.max(document.documentElement.clientHeight, window.innerHeight || 0);}
static get bdHead() {return this.getElement("bd-head");}
static get bdBody() {return this.getElement("bd-body");}
static get bdScripts() {return this.getElement("bd-scripts");}
@ -28,15 +34,35 @@ export default class DOMManager {
return baseElement.querySelector(e);
}
static createElement(tag, options = {}) {
static createElement(tag, options = {}, child = null) {
const {className, id, target} = options;
const element = document.createElement(tag);
if (className) element.className = className;
if (id) element.id = id;
if (child) element.append(child);
if (target) this.getElement(target).append(element);
return element;
}
/**
* Parses a string of HTML and returns the results. If the second parameter is true,
* the parsed HTML will be returned as a document fragment {@see https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment}.
* This is extremely useful if you have a list of elements at the top level, they can then be appended all at once to another node.
*
* If the second parameter is false, then the return value will be the list of parsed
* nodes and there were multiple top level nodes, otherwise the single node is returned.
* @param {string} html - HTML to be parsed
* @param {boolean} [fragment=false] - Whether or not the return should be the raw `DocumentFragment`
* @returns {(DocumentFragment|NodeList|HTMLElement)} - The result of HTML parsing
*/
static parseHTML(html, fragment = false) {
const template = document.createElement("template");
template.innerHTML = html;
const node = template.content.cloneNode(true);
if (fragment) return node;
return node.childNodes.length > 1 ? node.childNodes : node.childNodes[0];
}
static removeStyle(id) {
id = this.escapeID(id);
const exists = this.getElement(`#${id}`, this.bdStyles);
@ -99,6 +125,77 @@ export default class DOMManager {
this.bdScripts.append(script);
});
}
// https://javascript.info/js-animation
static animate({timing = _ => _, update, duration}) {
const start = performance.now();
requestAnimationFrame(function animate(time) {
// timeFraction goes from 0 to 1
let timeFraction = (time - start) / duration;
if (timeFraction > 1) timeFraction = 1;
// calculate the current animation state
const progress = timing(timeFraction);
update(progress); // draw it
if (timeFraction < 1) requestAnimationFrame(animate);
});
}
/**
* Adds a listener for when a node matching a selector is added to the document body.
* The listener is automatically removed upon firing.
* @param {string} selector - node to wait for
* @param {callable} callback - function to be performed on event
*/
static onAdded(selector, callback) {
if (document.body.querySelector(selector)) return callback(document.body.querySelector(selector));
const observer = new MutationObserver((mutations) => {
for (let m = 0; m < mutations.length; m++) {
for (let i = 0; i < mutations[m].addedNodes.length; i++) {
const mutation = mutations[m].addedNodes[i];
if (mutation.nodeType === 3) continue; // ignore text
const directMatch = mutation.matches(selector) && mutation;
const childrenMatch = mutation.querySelector(selector);
if (directMatch || childrenMatch) {
observer.disconnect();
return callback(directMatch ?? childrenMatch);
}
}
}
});
observer.observe(document.body, {subtree: true, childList: true});
return () => {observer.disconnect();};
}
/**
* Adds a listener for when the node is removed from the document body.
* The listener is automatically removed upon firing.
* @param {HTMLElement} node - node to wait for
* @param {callable} callback - function to be performed on event
*/
static onRemoved(node, callback) {
const observer = new MutationObserver((mutations) => {
for (let m = 0; m < mutations.length; m++) {
const mutation = mutations[m];
const nodes = Array.from(mutation.removedNodes);
const directMatch = nodes.indexOf(node) > -1;
const parentMatch = nodes.some(parent => parent.contains(node));
if (directMatch || parentMatch) {
observer.disconnect();
callback();
}
}
});
observer.observe(document.body, {subtree: true, childList: true});
return () => {observer.disconnect();};
}
}
DOMManager.createElement("bd-head", {target: document.head});

View File

@ -1,750 +0,0 @@
/**
* Copyright 2018 Zachary Rauen
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is furnished
* to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
* CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* From: https://github.com/rauenzi/BDPluginLibrary
*/
/**
* @interface
* @name Offset
* @property {number} top - Top offset of the target element.
* @property {number} right - Right offset of the target element.
* @property {number} bottom - Bottom offset of the target element.
* @property {number} left - Left offset of the target element.
* @property {number} height - Outer height of the target element.
* @property {number} width - Outer width of the target element.
*/
/**
* Function that automatically removes added listener.
* @callback module:DOMTools~CancelListener
*/
export default class DOMTools {
/** Document/window width */
static get screenWidth() {return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);}
/** Document/window height */
static get screenHeight() {return Math.max(document.documentElement.clientHeight, window.innerHeight || 0);}
static escapeID(id) {
return id.replace(/^[^a-z]+|[^\w-]+/gi, "-");
}
// https://javascript.info/js-animation
static animate({timing = _ => _, update, duration}) {
const start = performance.now();
requestAnimationFrame(function animate(time) {
// timeFraction goes from 0 to 1
let timeFraction = (time - start) / duration;
if (timeFraction > 1) timeFraction = 1;
// calculate the current animation state
const progress = timing(timeFraction);
update(progress); // draw it
if (timeFraction < 1) requestAnimationFrame(animate);
});
}
/**
* Adds a style to the document.
* @param {string} id - identifier to use as the element id
* @param {string} css - css to add to the document
*/
static addStyle(id, css) {
document.head.append(DOMTools.createElement(`<style id="${id}">${css}</style>`));
}
/**
* Removes a style from the document.
* @param {string} id - original identifier used
*/
static removeStyle(id) {
const element = document.getElementById(id);
if (element) element.remove();
}
/**
* Adds/requires a remote script to be loaded
* @param {string} id - identifier to use for this script
* @param {string} url - url from which to load the script
* @returns {Promise} promise that resolves when the script is loaded
*/
static addScript(id, url) {
return new Promise(resolve => {
const script = document.createElement("script");
script.id = id;
script.src = url;
script.type = "text/javascript";
script.onload = resolve;
document.head.append(script);
});
}
/**
* Removes a remote script from the document.
* @param {string} id - original identifier used
*/
static removeScript(id) {
id = this.escapeID(id);
const element = document.getElementById(id);
if (element) element.remove();
}
/**
* This is my shit version of not having to use `$` from jQuery. Meaning
* that you can pass a selector and it will automatically run {@link module:DOMTools.query}.
* It also means that you can pass a string of html and it will perform and return `parseHTML`.
* @see module:DOMTools.parseHTML
* @see module:DOMTools.query
* @param {string} selector - Selector to query or HTML to parse
* @returns {(DocumentFragment|NodeList|HTMLElement)} - Either the result of `parseHTML` or `query`
*/
static Q(selector) {
const element = this.parseHTML(selector);
const isHTML = element instanceof NodeList ? Array.from(element).some(n => n.nodeType === 1) : element.nodeType === 1;
if (isHTML) return element;
return this.query(selector);
}
/**
* Essentially a shorthand for `document.querySelector`. If the `baseElement` is not provided
* `document` is used by default.
* @param {string} selector - Selector to query
* @param {Element} [baseElement] - Element to base the query from
* @returns {(Element|null)} - The found element or null if not found
*/
static query(selector, baseElement) {
if (!baseElement) baseElement = document;
return baseElement.querySelector(selector);
}
/**
* Essentially a shorthand for `document.querySelectorAll`. If the `baseElement` is not provided
* `document` is used by default.
* @param {string} selector - Selector to query
* @param {Element} [baseElement] - Element to base the query from
* @returns {Array<Element>} - Array of all found elements
*/
static queryAll(selector, baseElement) {
if (!baseElement) baseElement = document;
return baseElement.querySelectorAll(selector);
}
/**
* Parses a string of HTML and returns the results. If the second parameter is true,
* the parsed HTML will be returned as a document fragment {@see https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment}.
* This is extremely useful if you have a list of elements at the top level, they can then be appended all at once to another node.
*
* If the second parameter is false, then the return value will be the list of parsed
* nodes and there were multiple top level nodes, otherwise the single node is returned.
* @param {string} html - HTML to be parsed
* @param {boolean} [fragment=false] - Whether or not the return should be the raw `DocumentFragment`
* @returns {(DocumentFragment|NodeList|HTMLElement)} - The result of HTML parsing
*/
static parseHTML(html, fragment = false) {
const template = document.createElement("template");
template.innerHTML = html;
const node = template.content.cloneNode(true);
if (fragment) return node;
return node.childNodes.length > 1 ? node.childNodes : node.childNodes[0];
}
/** Alternate name for {@link module:DOMTools.parseHTML} */
static createElement(html, fragment = false) {return this.parseHTML(html, fragment);}
/**
* Takes a string of html and escapes it using the brower's own escaping mechanism.
* @param {String} html - html to be escaped
*/
static escapeHTML(html) {
const textNode = document.createTextNode("");
const spanElement = document.createElement("span");
spanElement.append(textNode);
textNode.nodeValue = html;
return spanElement.innerHTML;
}
/**
* Adds a list of classes from the target element.
* @param {Element} element - Element to edit classes of
* @param {...string} classes - Names of classes to add
* @returns {Element} - `element` to allow for chaining
*/
static addClass(element, ...classes) {
classes = classes.flat().filter(c => c);
for (let c = 0; c < classes.length; c++) classes[c] = classes[c].toString().split(" ");
classes = classes.flat().filter(c => c);
element.classList.add(...classes);
return element;
}
/**
* Removes a list of classes from the target element.
* @param {Element} element - Element to edit classes of
* @param {...string} classes - Names of classes to remove
* @returns {Element} - `element` to allow for chaining
*/
static removeClass(element, ...classes) {
for (let c = 0; c < classes.length; c++) classes[c] = classes[c].toString().split(" ");
classes = classes.flat().filter(c => c);
element.classList.remove(...classes);
return element;
}
/**
* When only one argument is present: Toggle class value;
* i.e., if class exists then remove it and return false, if not, then add it and return true.
* When a second argument is present:
* If the second argument evaluates to true, add specified class value, and if it evaluates to false, remove it.
* @param {Element} element - Element to edit classes of
* @param {string} classname - Name of class to toggle
* @param {boolean} [indicator] - Optional indicator for if the class should be toggled
* @returns {Element} - `element` to allow for chaining
*/
static toggleClass(element, classname, indicator) {
classname = classname.toString().split(" ").filter(c => c);
if (typeof(indicator) !== "undefined") classname.forEach(c => element.classList.toggle(c, indicator));
else classname.forEach(c => element.classList.toggle(c));
return element;
}
/**
* Checks if an element has a specific class
* @param {Element} element - Element to edit classes of
* @param {string} classname - Name of class to check
* @returns {boolean} - `true` if the element has the class, `false` otherwise.
*/
static hasClass(element, classname) {
return classname.toString().split(" ").filter(c => c).every(c => element.classList.contains(c));
}
/**
* Replaces one class with another
* @param {Element} element - Element to edit classes of
* @param {string} oldName - Name of class to replace
* @param {string} newName - New name for the class
* @returns {Element} - `element` to allow for chaining
*/
static replaceClass(element, oldName, newName) {
element.classList.replace(oldName, newName);
return element;
}
/**
* Appends `thisNode` to `thatNode`
* @param {Node} thisNode - Node to be appended to another node
* @param {Node} thatNode - Node for `thisNode` to be appended to
* @returns {Node} - `thisNode` to allow for chaining
*/
static appendTo(thisNode, thatNode) {
if (typeof(thatNode) == "string") thatNode = this.query(thatNode);
if (!thatNode) return null;
thatNode.append(thisNode);
return thisNode;
}
/**
* Prepends `thisNode` to `thatNode`
* @param {Node} thisNode - Node to be prepended to another node
* @param {Node} thatNode - Node for `thisNode` to be prepended to
* @returns {Node} - `thisNode` to allow for chaining
*/
static prependTo(thisNode, thatNode) {
if (typeof(thatNode) == "string") thatNode = this.query(thatNode);
if (!thatNode) return null;
thatNode.prepend(thisNode);
return thisNode;
}
/**
* Insert after a specific element, similar to jQuery's `thisElement.insertAfter(otherElement)`.
* @param {Node} thisNode - The node to insert
* @param {Node} targetNode - Node to insert after in the tree
* @returns {Node} - `thisNode` to allow for chaining
*/
static insertAfter(thisNode, targetNode) {
targetNode.parentNode.insertBefore(thisNode, targetNode.nextSibling);
return thisNode;
}
/**
* Insert after a specific element, similar to jQuery's `thisElement.after(newElement)`.
* @param {Node} thisNode - The node to insert
* @param {Node} newNode - Node to insert after in the tree
* @returns {Node} - `thisNode` to allow for chaining
*/
static after(thisNode, newNode) {
thisNode.parentNode.insertBefore(newNode, thisNode.nextSibling);
return thisNode;
}
/**
* Gets the next sibling element that matches the selector.
* @param {Element} element - Element to get the next sibling of
* @param {string} [selector=""] - Optional selector
* @returns {Element} - The sibling element
*/
static next(element, selector = "") {
return selector ? element.querySelector("+ " + selector) : element.nextElementSibling;
}
/**
* Gets all subsequent siblings.
* @param {Element} element - Element to get next siblings of
* @returns {NodeList} - The list of siblings
*/
static nextAll(element) {
return element.querySelectorAll("~ *");
}
/**
* Gets the subsequent siblings until an element matches the selector.
* @param {Element} element - Element to get the following siblings of
* @param {string} selector - Selector to stop at
* @returns {Array<Element>} - The list of siblings
*/
static nextUntil(element, selector) {
const next = [];
while (element.nextElementSibling && !element.nextElementSibling.matches(selector)) next.push(element = element.nextElementSibling);
return next;
}
/**
* Gets the previous sibling element that matches the selector.
* @param {Element} element - Element to get the previous sibling of
* @param {string} [selector=""] - Optional selector
* @returns {Element} - The sibling element
*/
static previous(element, selector = "") {
const previous = element.previousElementSibling;
if (selector) return previous && previous.matches(selector) ? previous : null;
return previous;
}
/**
* Gets all preceeding siblings.
* @param {Element} element - Element to get preceeding siblings of
* @returns {NodeList} - The list of siblings
*/
static previousAll(element) {
const previous = [];
while (element.previousElementSibling) previous.push(element = element.previousElementSibling);
return previous;
}
/**
* Gets the preceeding siblings until an element matches the selector.
* @param {Element} element - Element to get the preceeding siblings of
* @param {string} selector - Selector to stop at
* @returns {Array<Element>} - The list of siblings
*/
static previousUntil(element, selector) {
const previous = [];
while (element.previousElementSibling && !element.previousElementSibling.matches(selector)) previous.push(element = element.previousElementSibling);
return previous;
}
/**
* Find which index in children a certain node is. Similar to jQuery's `$.index()`
* @param {HTMLElement} node - The node to find its index in parent
* @returns {number} Index of the node
*/
static indexInParent(node) {
const children = node.parentNode.childNodes;
let num = 0;
for (let i = 0; i < children.length; i++) {
if (children[i] == node) return num;
if (children[i].nodeType == 1) num++;
}
return -1;
}
/** Shorthand for {@link module:DOMTools.indexInParent} */
static index(node) {return this.indexInParent(node);}
/**
* Gets the parent of the element if it matches the selector,
* otherwise returns null.
* @param {Element} element - Element to get parent of
* @param {string} [selector=""] - Selector to match parent
* @returns {(Element|null)} - The sibling element or null
*/
static parent(element, selector = "") {
return !selector || element.parentElement.matches(selector) ? element.parentElement : null;
}
/**
* Gets all children of Element that match the selector if provided.
* @param {Element} element - Element to get all children of
* @param {string} selector - Selector to match the children to
* @returns {Array<Element>} - The list of children
*/
static findChild(element, selector) {
return element.querySelector(":scope > " + selector);
}
/**
* Gets all children of Element that match the selector if provided.
* @param {Element} element - Element to get all children of
* @param {string} selector - Selector to match the children to
* @returns {Array<Element>} - The list of children
*/
static findChildren(element, selector) {
return element.querySelectorAll(":scope > " + selector);
}
/**
* Gets all ancestors of Element that match the selector if provided.
* @param {Element} element - Element to get all parents of
* @param {string} [selector=""] - Selector to match the parents to
* @returns {Array<Element>} - The list of parents
*/
static parents(element, selector = "") {
const parents = [];
if (selector) while (element.parentElement && element.parentElement.closest(selector)) parents.push(element = element.parentElement.closest(selector));
else while (element.parentElement) parents.push(element = element.parentElement);
return parents;
}
/**
* Gets the ancestors until an element matches the selector.
* @param {Element} element - Element to get the ancestors of
* @param {string} selector - Selector to stop at
* @returns {Array<Element>} - The list of parents
*/
static parentsUntil(element, selector) {
const parents = [];
while (element.parentElement && !element.parentElement.matches(selector)) parents.push(element = element.parentElement);
return parents;
}
/**
* Gets all siblings of the element that match the selector.
* @param {Element} element - Element to get all siblings of
* @param {string} [selector="*"] - Selector to match the siblings to
* @returns {Array<Element>} - The list of siblings
*/
static siblings(element, selector = "*") {
return Array.from(element.parentElement.children).filter(e => e != element && e.matches(selector));
}
/**
* Sets or gets css styles for a specific element. If `value` is provided
* then it sets the style and returns the element to allow for chaining,
* otherwise returns the style.
* @param {Element} element - Element to set the CSS of
* @param {string} attribute - Attribute to get or set
* @param {string} [value] - Value to set for attribute
* @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned.
*/
static css(element, attribute, value) {
if (typeof(value) == "undefined") return global.getComputedStyle(element)[attribute];
element.style[attribute] = value;
return element;
}
/**
* Sets or gets the width for a specific element. If `value` is provided
* then it sets the width and returns the element to allow for chaining,
* otherwise returns the width.
* @param {Element} element - Element to set the CSS of
* @param {string} [value] - Width to set
* @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned.
*/
static width(element, value) {
if (typeof(value) == "undefined") return parseInt(getComputedStyle(element).width);
element.style.width = value;
return element;
}
/**
* Sets or gets the height for a specific element. If `value` is provided
* then it sets the height and returns the element to allow for chaining,
* otherwise returns the height.
* @param {Element} element - Element to set the CSS of
* @param {string} [value] - Height to set
* @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned.
*/
static height(element, value) {
if (typeof(value) == "undefined") return parseInt(getComputedStyle(element).height);
element.style.height = value;
return element;
}
/**
* Sets the inner text of an element if given a value, otherwise returns it.
* @param {Element} element - Element to set the text of
* @param {string} [text] - Content to set
* @returns {string} - Either the string set by this call or the current text content of the node.
*/
static text(element, text) {
if (typeof(text) == "undefined") return element.textContent;
return element.textContent = text;
}
/**
* Returns the innerWidth of the element.
* @param {Element} element - Element to retrieve inner width of
* @return {number} - The inner width of the element.
*/
static innerWidth(element) {
return element.clientWidth;
}
/**
* Returns the innerHeight of the element.
* @param {Element} element - Element to retrieve inner height of
* @return {number} - The inner height of the element.
*/
static innerHeight(element) {
return element.clientHeight;
}
/**
* Returns the outerWidth of the element.
* @param {Element} element - Element to retrieve outer width of
* @return {number} - The outer width of the element.
*/
static outerWidth(element) {
return element.offsetWidth;
}
/**
* Returns the outerHeight of the element.
* @param {Element} element - Element to retrieve outer height of
* @return {number} - The outer height of the element.
*/
static outerHeight(element) {
return element.offsetHeight;
}
/**
* Gets the offset of the element in the page.
* @param {Element} element - Element to get offset of
* @return {Offset} - The offset of the element
*/
static offset(element) {
return element.getBoundingClientRect();
}
static get listeners() {return this._listeners || (this._listeners = {});}
/**
* This is similar to jQuery's `on` function and can *hopefully* be used in the same way.
*
* Rather than attempt to explain, I'll show some example usages.
*
* The following will add a click listener (in the `myPlugin` namespace) to `element`.
* `DOMTools.on(element, "click.myPlugin", () => {console.log("clicked!");});`
*
* The following will add a click listener (in the `myPlugin` namespace) to `element` that only fires when the target is a `.block` element.
* `DOMTools.on(element, "click.myPlugin", ".block", () => {console.log("clicked!");});`
*
* The following will add a click listener (without namespace) to `element`.
* `DOMTools.on(element, "click", () => {console.log("clicked!");});`
*
* The following will add a click listener (without namespace) to `element` that only fires once.
* `const cancel = DOMTools.on(element, "click", () => {console.log("fired!"); cancel();});`
*
* @param {Element} element - Element to add listener to
* @param {string} event - Event to listen to with option namespace (e.g. "event.namespace")
* @param {(string|callable)} delegate - Selector to run on element to listen to
* @param {callable} [callback] - Function to fire on event
* @returns {module:DOMTools~CancelListener} - A function that will undo the listener
*/
static on(element, event, delegate, callback) {
const [type, namespace] = event.split(".");
const hasDelegate = delegate && callback;
if (!callback) callback = delegate;
const eventFunc = !hasDelegate ? callback : function(ev) {
if (ev.target.matches(delegate)) {
callback(ev);
}
};
element.addEventListener(type, eventFunc);
const cancel = () => {
element.removeEventListener(type, eventFunc);
};
if (namespace) {
if (!this.listeners[namespace]) this.listeners[namespace] = [];
const newCancel = () => {
cancel();
this.listeners[namespace].splice(this.listeners[namespace].findIndex(l => l.event == type && l.element == element), 1);
};
this.listeners[namespace].push({
event: type,
element: element,
cancel: newCancel
});
return newCancel;
}
return cancel;
}
/**
* Functionality for this method matches {@link module:DOMTools.on} but automatically cancels itself
* and removes the listener upon the first firing of the desired event.
*
* @param {Element} element - Element to add listener to
* @param {string} event - Event to listen to with option namespace (e.g. "event.namespace")
* @param {(string|callable)} delegate - Selector to run on element to listen to
* @param {callable} [callback] - Function to fire on event
* @returns {module:DOMTools~CancelListener} - A function that will undo the listener
*/
static once(element, event, delegate, callback) {
const [type, namespace] = event.split(".");
const hasDelegate = delegate && callback;
if (!callback) callback = delegate;
const eventFunc = !hasDelegate ? function(ev) {
callback(ev);
element.removeEventListener(type, eventFunc);
} : function(ev) {
if (!ev.target.matches(delegate)) return;
callback(ev);
element.removeEventListener(type, eventFunc);
};
element.addEventListener(type, eventFunc);
const cancel = () => {
element.removeEventListener(type, eventFunc);
};
if (namespace) {
if (!this.listeners[namespace]) this.listeners[namespace] = [];
const newCancel = () => {
cancel();
this.listeners[namespace].splice(this.listeners[namespace].findIndex(l => l.event == type && l.element == element), 1);
};
this.listeners[namespace].push({
event: type,
element: element,
cancel: newCancel
});
return newCancel;
}
return cancel;
}
static __offAll(event, element) {
const [type, namespace] = event.split(".");
let matchFilter = listener => listener.event == type, defaultFilter = _ => _;
if (element) {
matchFilter = l => l.event == type && l.element == element;
defaultFilter = l => l.element == element;
}
const listeners = this.listeners[namespace] || [];
const list = type ? listeners.filter(matchFilter) : listeners.filter(defaultFilter);
for (let c = 0; c < list.length; c++) list[c].cancel();
}
/**
* This is similar to jQuery's `off` function and can *hopefully* be used in the same way.
*
* Rather than attempt to explain, I'll show some example usages.
*
* The following will remove a click listener called `onClick` (in the `myPlugin` namespace) from `element`.
* `DOMTools.off(element, "click.myPlugin", onClick);`
*
* The following will remove a click listener called `onClick` (in the `myPlugin` namespace) from `element` that only fired when the target is a `.block` element.
* `DOMTools.off(element, "click.myPlugin", ".block", onClick);`
*
* The following will remove a click listener (without namespace) from `element`.
* `DOMTools.off(element, "click", onClick);`
*
* The following will remove all listeners in namespace `myPlugin` from `element`.
* `DOMTools.off(element, ".myPlugin");`
*
* The following will remove all click listeners in namespace `myPlugin` from *all elements*.
* `DOMTools.off("click.myPlugin");`
*
* The following will remove all listeners in namespace `myPlugin` from *all elements*.
* `DOMTools.off(".myPlugin");`
*
* @param {(Element|string)} element - Element to remove listener from
* @param {string} [event] - Event to listen to with option namespace (e.g. "event.namespace")
* @param {(string|callable)} [delegate] - Selector to run on element to listen to
* @param {callable} [callback] - Function to fire on event
* @returns {Element} - The original element to allow for chaining
*/
static off(element, event, delegate, callback) {
if (typeof(element) == "string") return this.__offAll(element);
const [type, namespace] = event.split(".");
if (namespace) return this.__offAll(event, element);
const hasDelegate = delegate && callback;
if (!callback) callback = delegate;
const eventFunc = !hasDelegate ? callback : function(ev) {
if (ev.target.matches(delegate)) {
callback(ev);
}
};
element.removeEventListener(type, eventFunc);
return element;
}
/**
* Adds a listener for when the node is added/removed from the document body.
* The listener is automatically removed upon firing.
* @param {HTMLElement} node - node to wait for
* @param {callable} callback - function to be performed on event
* @param {boolean} onMount - determines if it should fire on Mount or on Unmount
*/
static onMountChange(node, callback, onMount = true) {
const wrappedCallback = () => {
this.observer.unsubscribe(wrappedCallback);
callback();
};
this.observer.subscribe(wrappedCallback, mutation => {
const nodes = Array.from(onMount ? mutation.addedNodes : mutation.removedNodes);
const directMatch = nodes.indexOf(node) > -1;
const parentMatch = nodes.some(parent => parent.contains(node));
return directMatch || parentMatch;
});
return node;
}
/** Shorthand for {@link module:DOMTools.onMountChange} with third parameter `true` */
static onMount(node, callback) {return this.onMountChange(node, callback);}
/** Shorthand for {@link module:DOMTools.onMountChange} with third parameter `false` */
static onUnmount(node, callback) {return this.onMountChange(node, callback, false);}
/** Alias for {@link module:DOMTools.onMount} */
static onAdded(node, callback) {return this.onMount(node, callback);}
/** Alias for {@link module:DOMTools.onUnmount} */
static onRemoved(node, callback) {return this.onUnmount(node, callback, false);}
/**
* Helper function which combines multiple elements into one parent element
* @param {Array<HTMLElement>} elements - array of elements to put into a single parent
*/
static wrap(elements) {
const domWrapper = this.parseHTML(`<div class="dom-wrapper"></div>`);
for (let e = 0; e < elements.length; e++) domWrapper.appendChild(elements[e]);
return domWrapper;
}
}

View File

@ -10,7 +10,6 @@ export {default as DataStore} from "./datastore";
export {default as Events} from "./emitter";
export {default as Settings} from "./settingsmanager";
export {default as DOMManager} from "./dommanager";
export {default as DOM} from "./domtools";
export {default as Patcher} from "./patcher";
export {default as LocaleManager} from "./localemanager";
export {default as Strings} from "./strings";

View File

@ -1,708 +0,0 @@
import {Config} from "data";
import Utilities from "./utilities";
import WebpackModules, {Filters} from "./webpackmodules";
import DiscordModules from "./discordmodules";
import DataStore from "./datastore";
import DOMManager from "./dommanager";
import Toasts from "../ui/toasts";
import Notices from "../ui/notices";
import Modals from "../ui/modals";
import PluginManager from "./pluginmanager";
import ThemeManager from "./thememanager";
import Settings from "./settingsmanager";
import Logger from "common/logger";
import Patcher from "./patcher";
import Emotes from "../builtins/emotes/emotes";
import ipc from "./ipc";
import Tooltip from "../ui/tooltip";
/**
* `BdApi` is a globally (`window.BdApi`) accessible object for use by plugins and developers to make their lives easier.
* @name BdApi
*/
const BdApi = {
/**
* The React module being used inside Discord.
* @type React
* */
get React() {return DiscordModules.React;},
/**
* The ReactDOM module being used inside Discord.
* @type ReactDOM
*/
get ReactDOM() {return DiscordModules.ReactDOM;},
/**
* A reference object to get BD's settings.
* @type object
* @deprecated
*/
get settings() {return Settings.collections;},
/**
* A reference object for BD's emotes.
* @type object
* @deprecated
*/
get emotes() {
return new Proxy(Emotes.Emotes, {
get(obj, category) {
if (category === "blocklist") return Emotes.blocklist;
const group = Emotes.Emotes[category];
if (!group) return undefined;
return new Proxy(group, {
get(cat, emote) {return group[emote];},
set() {Logger.warn("BdApi.emotes", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");}
});
},
set() {Logger.warn("BdApi.emotes", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");}
});
},
/**
* A reference string for BD's version.
* @type string
*/
get version() {return Config.version;}
};
/**
* Adds a `<style>` to the document with the given ID.
*
* @param {string} id ID to use for style element
* @param {string} css CSS to apply to the document
*/
BdApi.injectCSS = function (id, css) {
DOMManager.injectStyle(id, css);
};
/**
* Removes a `<style>` from the document corresponding to the given ID.
*
* @param {string} id ID uses for the style element
*/
BdApi.clearCSS = function (id) {
DOMManager.removeStyle(id);
};
/**
* Automatically creates and links a remote JS script.
*
* @deprecated
* @param {string} id ID of the script element
* @param {string} url URL of the remote script
* @returns {Promise} Resolves upon onload event
*/
BdApi.linkJS = function (id, url) {
return DOMManager.injectScript(id, url);
};
/**
* Removes a remotely linked JS script.
*
* @deprecated
* @param {string} id ID of the script element
*/
BdApi.unlinkJS = function (id) {
DOMManager.removeScript(id);
};
/**
* Shows a generic but very customizable modal.
*
* @param {string} title title of the modal
* @param {(string|ReactElement|Array<string|ReactElement>)} content a string of text to display in the modal
*/
BdApi.alert = function (title, content) {
Modals.alert(title, content);
};
/**
* Shows a generic but very customizable confirmation modal with optional confirm and cancel callbacks.
*
* @param {string} title title of the modal
* @param {(string|ReactElement|Array<string|ReactElement>)} children a single or mixed array of react elements and strings. Everything is wrapped in Discord's `TextElement` component so strings will show and render properly.
* @param {object} [options] options to modify the modal
* @param {boolean} [options.danger=false] whether the main button should be red or not
* @param {string} [options.confirmText=Okay] text for the confirmation/submit button
* @param {string} [options.cancelText=Cancel] text for the cancel button
* @param {callable} [options.onConfirm=NOOP] callback to occur when clicking the submit button
* @param {callable} [options.onCancel=NOOP] callback to occur when clicking the cancel button
*/
BdApi.showConfirmationModal = function (title, content, options = {}) {
return Modals.showConfirmationModal(title, content, options);
};
/**
* Shows a toast similar to android towards the bottom of the screen.
*
* @param {string} content The string to show in the toast.
* @param {object} options Options object. Optional parameter.
* @param {string} [options.type=""] Changes the type of the toast stylistically and semantically. Choices: "", "info", "success", "danger"/"error", "warning"/"warn". Default: ""
* @param {boolean} [options.icon=true] Determines whether the icon should show corresponding to the type. A toast without type will always have no icon. Default: `true`
* @param {number} [options.timeout=3000] Adjusts the time (in ms) the toast should be shown for before disappearing automatically. Default: `3000`
* @param {boolean} [options.forceShow=false] Whether to force showing the toast and ignore the bd setting
*/
BdApi.showToast = function(content, options = {}) {
Toasts.show(content, options);
};
/**
* Shows a notice above Discord's chat layer.
*
* @param {string|Node} content Content of the notice
* @param {object} options Options for the notice.
* @param {string} [options.type="info" | "error" | "warning" | "success"] Type for the notice. Will affect the color.
* @param {Array<{label: string, onClick: function}>} [options.buttons] Buttons that should be added next to the notice text.
* @param {number} [options.timeout=0] Timeout until the notice is closed. Won't fire if it's set to 0;
* @returns {function} A callback for closing the notice. Passing `true` as first parameter closes immediately without transitioning out.
*/
BdApi.showNotice = function (content, options = {}) {
return Notices.show(content, options);
};
/**
* Creates a tooltip to automatically show on hover.
*
* @param {HTMLElement} node - DOM node to monitor and show the tooltip on
* @param {string|HTMLElement} content - string to show in the tooltip
* @param {object} options - additional options for the tooltip
* @param {"primary"|"info"|"success"|"warn"|"danger"} [options.style="primary"] - correlates to the discord styling/colors
* @param {"top"|"right"|"bottom"|"left"} [options.side="top"] - can be any of top, right, bottom, left
* @param {boolean} [options.preventFlip=false] - prevents moving the tooltip to the opposite side if it is too big or goes offscreen
* @param {boolean} [options.disabled=false] - whether the tooltip should be disabled from showing on hover
* @returns new Tooltip
*/
BdApi.createTooltip = function(node, content, options = {}) {
return Tooltip.create(node, content, options);
};
/**
* Finds a webpack module using a filter.
*
* @deprecated
* @param {function} filter A filter given the exports, module, and moduleId. Returns `true` if the module matches.
* @returns {any} Either the matching module or `undefined`
*/
BdApi.findModule = function(filter) {
return WebpackModules.getModule(filter);
};
/**
* Finds multiple webpack modules using a filter.
*
* @deprecated
* @param {function} filter A filter given the exports, module, and moduleId. Returns `true` if the module matches.
* @returns {Array} Either an array of matching modules or an empty array
*/
BdApi.findAllModules = function(filter) {
return WebpackModules.getModule(filter, {first: false});
};
/**
* Finds a webpack module by own properties.
*
* @deprecated
* @param {...string} props Any desired properties
* @returns {any} Either the matching module or `undefined`
*/
BdApi.findModuleByProps = function(...props) {
return WebpackModules.getByProps(...props);
};
/**
* Finds a webpack module by own prototypes.
*
* @deprecated
* @param {...string} protos Any desired prototype properties
* @returns {any} Either the matching module or `undefined`
*/
BdApi.findModuleByPrototypes = function(...protos) {
return WebpackModules.getByPrototypes(...protos);
};
/**
* Finds a webpack module by `displayName` property.
*
* @deprecated
* @param {string} name Desired `displayName` property
* @returns {any} Either the matching module or `undefined`
*/
BdApi.findModuleByDisplayName = function(name) {
return WebpackModules.getByDisplayName(name);
};
/**
* Gets the internal react data of a specified node.
*
* @param {HTMLElement} node Node to get the react data from
* @returns {object|undefined} Either the found data or `undefined`
*/
BdApi.getInternalInstance = function(node) {
return Utilities.getReactInstance(node);
};
/**
* Loads previously stored data.
*
* @param {string} pluginName Name of the plugin loading data
* @param {string} key Which piece of data to load
* @returns {any} The stored data
*/
BdApi.loadData = function(pluginName, key) {
return DataStore.getPluginData(pluginName, key);
};
/** @alias loadData */
BdApi.getData = BdApi.loadData;
/**
* Saves JSON-serializable data.
*
* @param {string} pluginName Name of the plugin saving data
* @param {string} key Which piece of data to store
* @param {any} data The data to be saved
* @returns
*/
BdApi.saveData = function(pluginName, key, data) {
return DataStore.setPluginData(pluginName, key, data);
};
/** @alias saveData */
BdApi.setData = BdApi.saveData;
/**
* Deletes a piece of stored data, this is different than saving as null or undefined.
*
* @param {string} pluginName Name of the plugin deleting data
* @param {string} key Which piece of data to delete
*/
BdApi.deleteData = function(pluginName, key) {
DataStore.deletePluginData(pluginName, key);
};
/**
* Monkey-patches a method on an object. The patching callback may be run before, after or instead of target method.
*
* - Be careful when monkey-patching. Think not only about original functionality of target method and your changes, but also about developers of other plugins, who may also patch this method before or after you. Try to change target method behaviour as little as possible, and avoid changing method signatures.
* - Display name of patched method is changed, so you can see if a function has been patched (and how many times) while debugging or in the stack trace. Also, patched methods have property `__monkeyPatched` set to `true`, in case you want to check something programmatically.
*
* @deprecated
* @param {object} what Object to be patched. You can can also pass class prototypes to patch all class instances.
* @param {string} methodName Name of the function to be patched.
* @param {object} options Options object to configure the patch.
* @param {function} [options.after] Callback that will be called after original target method call. You can modify return value here, so it will be passed to external code which calls target method. Can be combined with `before`.
* @param {function} [options.before] Callback that will be called before original target method call. You can modify arguments here, so it will be passed to original method. Can be combined with `after`.
* @param {function} [options.instead] Callback that will be called instead of original target method call. You can get access to original method using `originalMethod` parameter if you want to call it, but you do not have to. Can't be combined with `before` or `after`.
* @param {boolean} [options.once=false] Set to `true` if you want to automatically unpatch method after first call.
* @param {boolean} [options.silent=false] Set to `true` if you want to suppress log messages about patching and unpatching.
* @returns {function} A function that cancels the monkey patch
*/
BdApi.monkeyPatch = function(what, methodName, options) {
const {before, after, instead, once = false, callerId = "BdApi"} = options;
const patchType = before ? "before" : after ? "after" : instead ? "instead" : "";
if (!patchType) return Logger.err("BdApi", "Must provide one of: after, before, instead");
const originalMethod = what[methodName];
const data = {
originalMethod: originalMethod,
callOriginalMethod: () => data.originalMethod.apply(data.thisObject, data.methodArguments)
};
data.cancelPatch = Patcher[patchType](callerId, what, methodName, (thisObject, args, returnValue) => {
data.thisObject = thisObject;
data.methodArguments = args;
data.returnValue = returnValue;
try {
const patchReturn = Reflect.apply(options[patchType], null, [data]);
if (once) data.cancelPatch();
return patchReturn;
}
catch (err) {
Logger.stacktrace(`${callerId}:monkeyPatch`, `Error in the ${patchType} of ${methodName}`, err);
}
});
return data.cancelPatch;
};
/**
* Adds a listener for when the node is removed from the document body.
*
* @param {HTMLElement} node Node to be observed
* @param {function} callback Function to run when fired
*/
BdApi.onRemoved = function(node, callback) {
Utilities.onRemoved(node, callback);
};
/**
* Wraps a given function in a `try..catch` block.
*
* @deprecated
* @param {function} method Function to wrap
* @param {string} message Additional messasge to print when an error occurs
* @returns {function} The new wrapped function
*/
BdApi.suppressErrors = function(method, message) {
return Utilities.suppressErrors(method, message);
};
/**
* Tests a given object to determine if it is valid JSON.
*
* @deprecated
* @param {object} data Data to be tested
* @returns {boolean} Result of the test
*/
BdApi.testJSON = function(data) {
return Utilities.testJSON(data);
};
/**
* Gets a specific setting's status from BD.
*
* @deprecated
* @param {string} [collection="settings"] Collection ID
* @param {string} category Category ID in the collection
* @param {string} id Setting ID in the category
* @returns {boolean} If the setting is enabled
*/
BdApi.isSettingEnabled = function(collection, category, id) {
return Settings.get(collection, category, id);
};
/**
* Enables a BetterDiscord setting by ids.
*
* @deprecated
* @param {string} [collection="settings"] Collection ID
* @param {string} category Category ID in the collection
* @param {string} id Setting ID in the category
*/
BdApi.enableSetting = function(collection, category, id) {
return Settings.set(collection, category, id, true);
};
/**
* Disables a BetterDiscord setting by ids.
*
* @deprecated
* @param {string} [collection="settings"] Collection ID
* @param {string} category Category ID in the collection
* @param {string} id Setting ID in the category
*/
BdApi.disableSetting = function(collection, category, id) {
return Settings.set(collection, category, id, false);
};
/**
* Toggles a BetterDiscord setting by ids.
*
* @deprecated
* @param {string} [collection="settings"] Collection ID
* @param {string} category Category ID in the collection
* @param {string} id Setting ID in the category
*/
BdApi.toggleSetting = function(collection, category, id) {
return Settings.set(collection, category, id, !Settings.get(collection, category, id));
};
/**
* Gets some data in BetterDiscord's misc data.
*
* @deprecated
* @param {string} key Key of the data to load
* @returns {any} The stored data
*/
BdApi.getBDData = function(key) {
return DataStore.getBDData(key);
};
/**
* Sets some data in BetterDiscord's misc data.
*
* @deprecated
* @param {string} key Key of the data to store
* @returns {any} The stored data
*/
BdApi.setBDData = function(key, data) {
return DataStore.setBDData(key, data);
};
/**
* Gives access to the [Electron Dialog](https://www.electronjs.org/docs/latest/api/dialog/) api.
* Returns a `Promise` that resolves to an `object` that has a `boolean` cancelled and a `filePath` string for saving and a `filePaths` string array for opening.
*
* @param {object} options Options object to configure the dialog.
* @param {"open"|"save"} [options.mode="open"] Determines whether the dialog should open or save files.
* @param {string} [options.defaultPath=~] Path the dialog should show on launch.
* @param {Array<object<string, string[]>>} [options.filters=[]] An array of [file filters](https://www.electronjs.org/docs/latest/api/structures/file-filter).
* @param {string} [options.title] Title for the titlebar.
* @param {string} [options.message] Message for the dialog.
* @param {boolean} [options.showOverwriteConfirmation=false] Whether the user should be prompted when overwriting a file.
* @param {boolean} [options.showHiddenFiles=false] Whether hidden files should be shown in the dialog.
* @param {boolean} [options.promptToCreate=false] Whether the user should be prompted to create non-existant folders.
* @param {boolean} [options.openDirectory=false] Whether the user should be able to select a directory as a target.
* @param {boolean} [options.openFile=true] Whether the user should be able to select a file as a target.
* @param {boolean} [options.multiSelections=false] Whether the user should be able to select multiple targets.
* @param {boolean} [options.modal=false] Whether the dialog should act as a modal to the main window.
* @returns {Promise<object>} Result of the dialog
*/
BdApi.openDialog = async function (options) {
const data = await ipc.openDialog(options);
if (data.error) throw new Error(data.error);
return data;
};
/**
* `AddonAPI` is a utility class for working with plugins and themes. Instances are accessible through the {@link BdApi}.
*/
class AddonAPI {
#manager;
constructor(manager) {this.#manager = manager;}
/**
* The path to the addon folder.
* @type string
*/
get folder() {return this.#manager.addonFolder;}
/**
* Determines if a particular adon is enabled.
* @param {string} idOrFile Addon id or filename.
* @returns {boolean}
*/
isEnabled(idOrFile) {return this.#manager.isEnabled(idOrFile);}
/**
* Enables the given addon.
* @param {string} idOrFile Addon id or filename.
*/
enable(idOrAddon) {return this.#manager.enableAddon(idOrAddon);}
/**
* Disables the given addon.
* @param {string} idOrFile Addon id or filename.
*/
disable(idOrAddon) {return this.#manager.disableAddon(idOrAddon);}
/**
* Toggles if a particular addon is enabled.
* @param {string} idOrFile Addon id or filename.
*/
toggle(idOrAddon) {return this.#manager.toggleAddon(idOrAddon);}
/**
* Reloads if a particular addon is enabled.
* @param {string} idOrFile Addon id or filename.
*/
reload(idOrFileOrAddon) {return this.#manager.reloadAddon(idOrFileOrAddon);}
/**
* Gets a particular addon.
* @param {string} idOrFile Addon id or filename.
* @returns {object} Addon instance
*/
get(idOrFile) {return this.#manager.getAddon(idOrFile);}
/**
* Gets all addons of this type.
* @returns {Array<object>} Array of all addon instances
*/
getAll() {return this.#manager.addonList.map(a => this.#manager.getAddon(a.id));}
}
/**
* An instance of {@link AddonAPI} to access plugins.
* @type AddonAPI
*/
BdApi.Plugins = new AddonAPI(PluginManager);
/**
* An instance of {@link AddonAPI} to access themes.
* @type AddonAPI
*/
BdApi.Themes = new AddonAPI(ThemeManager);
/**
* `Patcher` is a utility class for modifying existing functions. Instance is accessible through the {@link BdApi}.
* This is extremely useful for modifying the internals of Discord by adjusting return value or React renders, or arguments of internal functions.
* @type Patcher
* @summary {@link Patcher} is a utility class for modifying existing functions.
*/
BdApi.Patcher = {
/**
* This method patches onto another function, allowing your code to run beforehand.
* Using this, you are able to modify the incoming arguments before the original method is run.
* @param {string} caller Name of the caller of the patch function.
* @param {object} moduleToPatch Object with the function to be patched. Can also be an object's prototype.
* @param {string} functionName Name of the function to be patched.
* @param {function} callback Function to run before the original method. The function is given the `this` context and the `arguments` of the original function.
* @returns {function} Function that cancels the original patch.
*/
before(caller, moduleToPatch, functionName, callback) {
return Patcher.pushChildPatch(caller, moduleToPatch, functionName, callback, {type: "before"});
},
/**
* This method patches onto another function, allowing your code to run instead.
* Using this, you are able to replace the original completely. You can still call the original manually if needed.
* @param {string} caller Name of the caller of the patch function.
* @param {object} moduleToPatch Object with the function to be patched. Can also be an object's prototype.
* @param {string} functionName Name of the function to be patched.
* @param {function} callback Function to run before the original method. The function is given the `this` context, `arguments` of the original function, and also the original function.
* @returns {function} Function that cancels the original patch.
*/
instead(caller, moduleToPatch, functionName, callback) {
return Patcher.pushChildPatch(caller, moduleToPatch, functionName, callback, {type: "instead"});
},
/**
* This method patches onto another function, allowing your code to run afterwards.
* Using this, you are able to modify the return value after the original method is run.
* @param {string} caller Name of the caller of the patch function.
* @param {object} moduleToPatch Object with the function to be patched. Can also be an object's prototype.
* @param {string} functionName Name of the function to be patched.
* @param {function} callback Function to run after the original method. The function is given the `this` context, the `arguments` of the original function, and the `return` value of the original function.
* @returns {function} Function that cancels the original patch.
*/
after(caller, moduleToPatch, functionName, callback) {
return Patcher.pushChildPatch(caller, moduleToPatch, functionName, callback, {type: "after"});
},
/**
* Returns all patches by a particular caller. The patches all have an `unpatch()` method.
* @param {string} caller ID of the original patches
* @returns {Array<function>} Array of all the patch objects.
*/
getPatchesByCaller(caller) {
if (typeof(caller) !== "string") return Logger.err("BdApi.Patcher", "Parameter 0 of getPatchesByCaller must be a string representing the caller");
return Patcher.getPatchesByCaller(caller);
},
/**
* Automatically cancels all patches created with a specific ID.
* @param {string} caller ID of the original patches
*/
unpatchAll(caller) {
if (typeof(caller) !== "string") return Logger.err("BdApi.Patcher", "Parameter 0 of unpatchAll must be a string representing the caller");
Patcher.unpatchAll(caller);
}
};
/**
* `Webpack` is a utility class for getting internal webpack modules. Instance is accessible through the {@link BdApi}.
* This is extremely useful for interacting with the internals of Discord.
* @type Webpack
* @summary {@link Webpack} is a utility class for getting internal webpack modules.
*/
BdApi.Webpack = {
/**
* Series of {@link Filters} to be used for finding webpack modules.
* @type Filters
*/
Filters: {
/**
* Generates a function that filters by a set of properties.
* @param {...string} props List of property names
* @returns {function} A filter that checks for a set of properties
*/
byProps(...props) {return Filters.byProps(props);},
/**
* Generates a function that filters by a set of properties on the object's prototype.
* @param {...string} props List of property names
* @returns {function} A filter that checks for a set of properties on the object's prototype.
*/
byPrototypeFields(...props) {return Filters.byPrototypeFields(props);},
/**
* Generates a function that filters by a regex.
* @param {RegExp} search A RegExp to check on the module
* @param {function} filter Additional filter
* @returns {function} A filter that checks for a regex match
*/
byRegex(regex) {return Filters.byRegex(regex);},
/**
* Generates a function that filters by strings.
* @param {...String} strings A list of strings
* @returns {function} A filter that checks for a set of strings
*/
byStrings(...strings) {return Filters.byStrings(...strings);},
/**
* Generates a function that filters by the `displayName` property.
* @param {string} name Name the module should have
* @returns {function} A filter that checks for a `displayName` match
*/
byDisplayName(name) {return Filters.byDisplayName(name);},
/**
* Generates a combined function from a list of filters.
* @param {...function} filters A list of filters
* @returns {function} Combinatory filter of all arguments
*/
combine(...filters) {return Filters.combine(...filters);},
},
/**
* Finds a module using a filter function.
* @param {function} filter A function to use to filter modules. It is given exports, module, and moduleID. Return `true` to signify match.
* @param {object} [options] Options object to configure the search
* @param {Boolean} [options.first=true] Whether to return only the first matching module
* @param {Boolean} [options.defaultExport=true] Whether to return default export when matching the default export
* @param {Boolean} [options.searchGetters=true] Whether to execute the filter on webpack export getters.
* @return {any}
*/
getModule(filter, options = {}) {
if (("first" in options) && typeof(options.first) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.first", options.first, "boolean expected.");
if (("defaultExport" in options) && typeof(options.defaultExport) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.defaultExport", options.defaultExport, "boolean expected.");
if (("searchGetters" in options) && typeof(options.searchGetters) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.searchGetters", options.searchGetters, "boolean expected.");
return WebpackModules.getModule(filter, options);
},
/**
* Finds multiple modules using multiple filters.
*
* @param {...object} queries Object representing the query
* @param {Function} queries.filter A function to use to filter modules
* @param {Boolean} [queries.first=true] Whether to return only the first matching module
* @param {Boolean} [queries.defaultExport=true] Whether to return default export when matching the default export
* @param {Boolean} [queries.searchGetters=true] Whether to execute the filter on webpack export getters.
* @return {any}
*/
getBulk(...queries) {return WebpackModules.getBulk(...queries);},
/**
* Finds a module that is lazily loaded.
* @param {function} filter A function to use to filter modules. It is given exports. Return `true` to signify match.
* @param {object} [options] Options object to configure the listener
* @param {AbortSignal} [options.signal] AbortSignal of an AbortController to cancel the promise
* @param {Boolean} [options.defaultExport=true] Whether to return default export when matching the default export
* @param {Boolean} [options.searchGetters=true] Whether to execute the filter on webpack export getters.
* @returns {Promise<any>}
*/
waitForModule(filter, options = {}) {
if (("defaultExport" in options) && typeof(options.defaultExport) !== "boolean") return Logger.error("BdApi.Webpack~waitForModule", "Unsupported type used for options.defaultExport", options.defaultExport, "boolean expected.");
if (("signal" in options) && !(options.signal instanceof AbortSignal)) return Logger.error("BdApi.Webpack~waitForModule", "Unsupported type used for options.signal", options.signal, "AbortSignal expected.");
if (("searchGetters" in options) && typeof(options.searchGetters) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.searchGetters", options.searchGetters, "boolean expected.");
return WebpackModules.getLazy(filter, options);
},
};
Object.freeze(BdApi);
Object.freeze(BdApi.Plugins);
Object.freeze(BdApi.Themes);
Object.freeze(BdApi.Patcher);
Object.freeze(BdApi.Webpack);
Object.freeze(BdApi.Webpack.Filters);
export default BdApi;

View File

@ -1,91 +1,6 @@
import {Config} from "data";
import Logger from "common/logger";
export default class Utilities {
static repoUrl(path) {
return `https://cdn.staticaly.com/gh/BetterDiscord/BetterDiscord/${Config.hash}/${path}`;
}
static escape(s) {
return s.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
}
static testJSON(data) {
try {
return JSON.parse(data);
}
catch (err) {
return false;
}
}
static suppressErrors(method, message) {
return (...params) => {
try {return method(...params);}
catch (e) {Logger.stacktrace("SuppressedError", "Error occurred in " + message, e);}
};
}
static occurrences(source, substring) {
const regex = new RegExp(substring, "g");
return (source.match(regex) || []).length;
}
static onRemoved(node, callback) {
const observer = new MutationObserver((mutations) => {
for (let m = 0; m < mutations.length; m++) {
const mutation = mutations[m];
const nodes = Array.from(mutation.removedNodes);
const directMatch = nodes.indexOf(node) > -1;
const parentMatch = nodes.some(parent => parent.contains(node));
if (directMatch || parentMatch) {
observer.disconnect();
callback();
}
}
});
observer.observe(document.body, {subtree: true, childList: true});
}
static onAdded(selector, callback) {
if (document.body.querySelector(selector)) return callback(document.body.querySelector(selector));
const observer = new MutationObserver((mutations) => {
for (let m = 0; m < mutations.length; m++) {
for (let i = 0; i < mutations[m].addedNodes.length; i++) {
const mutation = mutations[m].addedNodes[i];
if (mutation.nodeType === 3) continue; // ignore text
const directMatch = mutation.matches(selector) && mutation;
const childrenMatch = mutation.querySelector(selector);
if (directMatch || childrenMatch) {
observer.disconnect();
return callback(directMatch ?? childrenMatch);
}
}
}
});
observer.observe(document.body, {subtree: true, childList: true});
return () => {observer.disconnect();};
}
static isEmpty(obj) {
if (obj === null || typeof(undefined) === "undefined" || obj === "") return true;
if (typeof(obj) !== "object") return false;
if (Array.isArray(obj)) return obj.length == 0;
for (const key in obj) {
if (obj.hasOwnProperty(key)) return false;
}
return true;
}
static isClass(obj) {
return typeof(obj) === "function" && /^\s*class\s+/.test(obj.toString());
}
/**
* Generates an automatically memoizing version of an object.
* @author Zerebos
@ -194,7 +109,7 @@ export default class Utilities {
}
/**
* Finds a value, subobject, or array from a tree that matches a specific filter.
* Finds a value, subobject, or array from a tree that matches a specific filter. This is a DFS.
* @param {object} tree Tree that should be walked
* @param {callable} searchFilter Filter to check against each object and subobject
* @param {object} options Additional options to customize the search
@ -230,70 +145,74 @@ export default class Utilities {
}
/**
* Gets a nested property (if it exists) safely. Path should be something like `prop.prop2.prop3`.
* Numbers can be used for arrays as well like `prop.prop2.array.0.id`.
* @param {Object} obj - object to get nested property of
* @param {string} path - representation of the property to obtain
* Returns a function, that, as long as it continues to be invoked, will not
* be triggered. The function will be called after it stops being called for
* N milliseconds.
*
* Adapted from the version by David Walsh (https://davidwalsh.name/javascript-debounce-function)
*
* @param {function} executor
* @param {number} delay
*/
static getNestedProp(obj, path) {
return path.split(/\s?\.\s?/).reduce(function(currentObj, prop) {
return currentObj && currentObj[prop];
}, obj);
static debounce(executor, delay) {
let timeout;
return function(...args) {
const callback = () => {
timeout = null;
Reflect.apply(executor, null, args);
};
clearTimeout(timeout);
timeout = setTimeout(callback, delay);
};
}
/**
* Finds a value, subobject, or array from a tree that matches a specific filter. Great for patching render functions.
* @param {object} tree React tree to look through. Can be a rendered object or an internal instance.
* @param {callable} searchFilter Filter function to check subobjects against.
* Takes a string of html and escapes it using the brower's own escaping mechanism.
* @param {String} html - html to be escaped
*/
static findInRenderTree(tree, searchFilter, {walkable = ["props", "children", "child", "sibling"], ignore = []} = {}) {
return this.findInTree(tree, searchFilter, {walkable, ignore});
static escapeHTML(html) {
const textNode = document.createTextNode("");
const spanElement = document.createElement("span");
spanElement.append(textNode);
textNode.nodeValue = html;
return spanElement.innerHTML;
}
/**
* Finds a value, subobject, or array from a tree that matches a specific filter. Great for patching render functions.
* @param {object} tree React tree to look through. Can be a rendered object or an internal instance.
* @param {callable} searchFilter Filter function to check subobjects against.
* Builds a classname string from any number of arguments. This includes arrays and objects.
* When given an array all values from the array are added to the list.
* When given an object they keys are added as the classnames if the value is truthy.
* Copyright (c) 2018 Jed Watson https://github.com/JedWatson/classnames MIT License
* @param {...Any} argument - anything that should be used to add classnames.
*/
static findInReactTree(tree, searchFilter) {
return this.findInTree(tree, searchFilter, {walkable: ["props", "children", "return", "stateNode"]});
}
static className() {
const classes = [];
const hasOwn = {}.hasOwnProperty;
static getReactInstance(node) {
if (node.__reactInternalInstance$) return node.__reactInternalInstance$;
return node[Object.keys(node).find(k => k.startsWith("__reactInternalInstance") || k.startsWith("__reactFiber"))] || null;
}
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
if (!arg) continue;
/**
* Grabs a value from the react internal instance. Allows you to grab
* long depth values safely without accessing no longer valid properties.
* @param {HTMLElement} node - node to obtain react instance of
* @param {object} options - options for the search
* @param {array} [options.include] - list of items to include from the search
* @param {array} [options.exclude=["Popout", "Tooltip", "Scroller", "BackgroundFlash"]] - list of items to exclude from the search
* @param {callable} [options.filter=_=>_] - filter to check the current instance with (should return a boolean)
* @return {(*|null)} the owner instance or undefined if not found.
*/
static getOwnerInstance(node, {include, exclude = ["Popout", "Tooltip", "Scroller", "BackgroundFlash"], filter = _ => _} = {}) {
if (node === undefined) return undefined;
const excluding = include === undefined;
const nameFilter = excluding ? exclude : include;
function getDisplayName(owner) {
const type = owner.type;
if (!type) return null;
return type.displayName || type.name || null;
}
function classFilter(owner) {
const name = getDisplayName(owner);
return (name !== null && !!(nameFilter.includes(name) ^ excluding));
const argType = typeof arg;
if (argType === "string" || argType === "number") {
classes.push(arg);
}
else if (Array.isArray(arg) && arg.length) {
const inner = this.classNames.apply(null, arg);
if (inner) {
classes.push(inner);
}
}
else if (argType === "object") {
for (const key in arg) {
if (hasOwn.call(arg, key) && arg[key]) {
classes.push(key);
}
}
}
}
let curr = this.getReactInstance(node);
for (curr = curr && curr.return; curr !== null; curr = curr.return) {
const owner = curr.stateNode;
if (!(owner instanceof HTMLElement) && classFilter(curr) && filter(owner)) return owner;
}
return null;
return classes.join(" ");
}
}

View File

@ -1,4 +1,4 @@
import {React, DOM} from "modules";
import {React} from "modules";
import FloatingWindow from "./window";
@ -10,7 +10,7 @@ class FloatingWindowContainer extends React.Component {
}
get minY() {
const appContainer = DOM.query(`#app-mount > div[class*="app-"]`);
const appContainer = document.querySelector(`#app-mount > div[class*="app-"]`);
if (appContainer) return appContainer.offsetTop;
return 0;
}

View File

@ -1,4 +1,4 @@
import {WebpackModules, React, ReactDOM, DOM, DOMManager} from "modules";
import {WebpackModules, React, ReactDOM, DOMManager} from "modules";
import FloatingWindowContainer from "./floating/container";
/* eslint-disable new-cap */
@ -12,7 +12,7 @@ export default class FloatingWindows {
const wrapped = AppLayerProvider
? React.createElement(AppLayerProvider().props.layerContext.Provider, {value: [document.querySelector("#app-mount > .layerContainer-2v_Sit")]}, container) // eslint-disable-line new-cap
: container;
const div = DOM.createElement(`<div id="floating-windows-layer">`);
const div = DOMManager.parseHTML(`<div id="floating-windows-layer">`);
DOMManager.bdBody.append(div);
ReactDOM.render(wrapped, div);
this.ref = containerRef;

View File

@ -1,6 +1,6 @@
import {Config} from "data";
import Logger from "common/logger";
import {WebpackModules, React, Settings, Strings, DOM, DiscordModules} from "modules";
import {WebpackModules, React, Settings, Strings, DOMManager, DiscordModules} from "modules";
import FormattableString from "../structs/string";
import AddonErrorModal from "./addonerrormodal";
import ErrorBoundary from "./errorboundary";
@ -28,7 +28,7 @@ export default class Modals {
static get Buttons() {return WebpackModules.getByProps("BorderColors");}
static default(title, content) {
const modal = DOM.createElement(`<div class="bd-modal-wrapper theme-dark">
const modal = DOMManager.parseHTML(`<div class="bd-modal-wrapper theme-dark">
<div class="bd-backdrop backdrop-1wrmKB"></div>
<div class="bd-modal modal-1UGdnR">
<div class="bd-modal-inner inner-1JeGVc">

View File

@ -1,4 +1,4 @@
import {Utilities, WebpackModules} from "modules";
import {WebpackModules, DOMManager} from "modules";
export default class Notices {
static get baseClass() {return this.__baseClass ??= WebpackModules.getByProps("container", "base")?.base;}
@ -84,10 +84,10 @@ export default class Notices {
});
container.prepend(noticeContainer);
Utilities.onRemoved(container, async () => {
DOMManager.onRemoved(container, async () => {
if (!this.errorPageClass) return;
const element = await new Promise(res => Utilities.onAdded(`.${this.errorPageClass}`, res));
const element = await new Promise(res => DOMManager.onAdded(`.${this.errorPageClass}`, res));
element.prepend(noticeContainer);
});

View File

@ -96,7 +96,7 @@ export default new class SettingsRenderer {
const viewClass = WebpackModules.getByProps("standardSidebarView")?.standardSidebarView.split(" ")[0];
const node = document.querySelector(`.${viewClass}`);
if (!node) return;
const stateNode = Utilities.findInReactTree(Utilities.getReactInstance(node), m => m && m.getPredicateSections, {walkable: ["return", "stateNode"]});
const stateNode = Utilities.findInTree(node?.__reactFiber$, m => m && m.getPredicateSections, {walkable: ["return", "stateNode"]});
if (stateNode) stateNode.forceUpdate();
}
};

View File

@ -1,5 +1,5 @@
import Logger from "common/logger";
import {DOM} from "modules";
import {DOMManager} from "modules";
const toPx = function(value) {
@ -34,8 +34,8 @@ export default class Tooltip {
if (!sides.includes(this.side)) return Logger.err("Tooltip", `Side ${this.side} does not exist.`);
if (!styles.includes(this.style)) return Logger.err("Tooltip", `Style ${this.style} does not exist.`);
this.element = DOM.createElement(`<div class="bd-layer">`);
this.tooltipElement = DOM.createElement(`<div class="bd-tooltip"><div class="bd-tooltip-pointer"></div><div class="bd-tooltip-content"></div></div>`);
this.element = DOMManager.parseHTML(`<div class="bd-layer">`);
this.tooltipElement = DOMManager.parseHTML(`<div class="bd-tooltip"><div class="bd-tooltip-pointer"></div><div class="bd-tooltip-content"></div></div>`);
this.tooltipElement.classList.add(`bd-tooltip-${this.style}`);
this.labelElement = this.tooltipElement.childNodes[1];
@ -62,11 +62,11 @@ export default class Tooltip {
/** Boolean representing if the tooltip will fit on screen above the element */
get canShowAbove() {return this.node.getBoundingClientRect().top - this.element.offsetHeight >= 0;}
/** Boolean representing if the tooltip will fit on screen below the element */
get canShowBelow() {return this.node.getBoundingClientRect().top + this.node.offsetHeight + this.element.offsetHeight <= DOM.screenHeight;}
get canShowBelow() {return this.node.getBoundingClientRect().top + this.node.offsetHeight + this.element.offsetHeight <= DOMManager.screenHeight;}
/** Boolean representing if the tooltip will fit on screen to the left of the element */
get canShowLeft() {return this.node.getBoundingClientRect().left - this.element.offsetWidth >= 0;}
/** Boolean representing if the tooltip will fit on screen to the right of the element */
get canShowRight() {return this.node.getBoundingClientRect().left + this.node.offsetWidth + this.element.offsetWidth <= DOM.screenWidth;}
get canShowRight() {return this.node.getBoundingClientRect().left + this.node.offsetWidth + this.element.offsetWidth <= DOMManager.screenWidth;}
/** Hides the tooltip. Automatically called on mouseleave. */
hide() {