Merge pull request #1433 from BetterDiscord/development

Merge development changes
This commit is contained in:
Zerebos 2022-10-10 15:13:58 -04:00 committed by GitHub
commit 9e0f274b50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 3958 additions and 2373 deletions

View File

@ -1,31 +0,0 @@
language: node_js
node_js:
- "node"
git:
autocrlf: true
branches:
only:
- development
install:
- npm ci
script:
- npm run lint-prod
- npm run test-prod
- npm run deploy
before_deploy: "echo 'node_modules' > .gitignore"
deploy:
provider: pages
skip_cleanup: true
github_token: $TRAVIS_ACCESS
keep_history: true
local_dir: .
name: BetterDiscord Deployment
target_branch: gh-pages-development
on:
branch: development

View File

@ -2,6 +2,46 @@
This changelog starts with the restructured 1.0.0 release that happened after context isolation changes. The changelogs here should more-or-less mirror the ones that get shown in the client but probably with less formatting and pizzazz.
## 1.8.0
### Added
- Proper updater system with UI.
- Tooltip component for plugins.
- Highly expanded plugin API.
### Removed
### Changed
- Reverted how internal webpack module searches are performed.
- New options for webpack searches.
### Fixed
- Fixed many issues regarding memory leaks and out-of-memory errors!
- Fixed a major issue where webpack searches would iterate by default.
- Fixed an issue with `byStrings` and `combine` filters in the API.
- Fixed an issue where searching for multiple modules could yield the same module multiple times.
- Fixed an issue where misnamed addon files could prevent startup.
- Fixed an issue where the `request` module would not follow redirects.
- Fixed an issue where certain modals could crash the client.
- Fixed an issue where toasts would not show on the crash screen.
## 1.7.0
### Added
- Polyfill for certain node modules.
### Removed
- Proxy protection for certain modules.
### Changed
- Changed how internal webpack module searches are performed.
- New location for public servers button.
- Switch to pnpm with workspaces.
- Improved startup errors.
### Fixed
- Fixed several issues for Discord's internal changes.
## 1.6.3
### Added

View File

@ -1,6 +1,6 @@
{
"name": "betterdiscord",
"version": "1.7.0",
"version": "1.8.0",
"description": "Enhances Discord by adding functionality and themes.",
"main": "src/index.js",
"scripts": {
@ -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

@ -44,6 +44,14 @@ export function renameSync(oldPath, newPath) {
return fs.renameSync(oldPath, newPath);
}
export function rm(pathToFile) {
return fs.rmSync(pathToFile);
}
export function rmSync(pathToFile) {
return fs.rmSync(pathToFile);
}
export function unlinkSync(fileToDelete) {
return fs.unlinkSync(fileToDelete);
}

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, ReactDOM} from "modules";
import PublicServersMenu from "../../ui/publicservers/menu";
import Globe from "../../ui/icons/globe";
@ -44,49 +44,87 @@ export default new class PublicServers extends Builtin {
get id() {return "publicServers";}
enabled() {
// let target = null;
// WebpackModules.getModule((_, m) => {
// if (m.exports?.toString().includes("privateChannelIds")) {
// target = m.exports;
// }
// });
// if (!target || !target.Z) return;
// const PrivateChannelListComponents = WebpackModules.getByProps("LinkButton");
// this.after(target, "Z", (_, __, returnValue) => {
// const destination = returnValue?.props?.children?.props?.children;
// if (!destination || !Array.isArray(destination)) return;
// if (destination.find(b => b?.props?.children?.props?.id === "public-server-button")) return;
const PrivateChannelList = WebpackModules.getModule(m => m?.toString().includes("privateChannelIds"), {defaultExport: false});
if (!PrivateChannelList || !PrivateChannelList.Z) return this.warn("Could not find PrivateChannelList", PrivateChannelList);
const PrivateChannelButton = WebpackModules.getModule(m => m?.prototype?.render?.toString().includes("linkButton"), {searchExports: true});
if (!PrivateChannelButton) return this.warn("Could not find PrivateChannelButton", PrivateChannelButton);
this.after(PrivateChannelList, "Z", (_, __, returnValue) => {
const destination = returnValue?.props?.children?.props?.children;
if (!destination || !Array.isArray(destination)) return;
if (destination.find(b => b?.props?.children?.props?.id === "public-servers-button")) return; // If it exists, don't try to add again
// destination.push(
// React.createElement(ErrorBoundary, null,
// React.createElement(PrivateChannelListComponents.LinkButton,
// {
// id: "public-server-button",
// onClick: () => this.openPublicServers(),
// text: "Public Servers",
// icon: () => React.createElement(Globe, {color: "currentColor"})
// }
// )
// )
// );
// });
destination.push(
React.createElement(ErrorBoundary, null,
React.createElement(PrivateChannelButton,
{
id: "public-servers-button",
onClick: () => this.openPublicServers(),
text: "Public Servers",
icon: () => React.createElement(Globe, {color: "currentColor"})
}
)
)
);
});
/**
* On being first enabled, we have no way of forceUpdating the list,
* so clone and modify an existing button and add it to the end
* of the button list.
*/
const header = document.querySelector(`[class*="privateChannelsHeaderContainer-"]`);
if (!header) return; // No known element
const oldButton = header.previousElementSibling;
if (!oldButton.className.includes("channel-")) return; // Not what we expected to be there
// Clone existing button and set click handler
const newButton = oldButton.cloneNode(true);
newButton.addEventListener("click", (event) => {
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
this.openPublicServers();
});
// Remove existing route and id
const aSlot = newButton.querySelector("a");
aSlot.href = "";
aSlot.dataset.listItemId = "public-servers";
// Remove any badges
const badge = newButton.querySelector(`[class*="premiumTrial"]`);
badge?.remove?.();
// Render our icon in the avatar slot
const avatarSlot = newButton.querySelector(`[class*="avatar-"]`);
avatarSlot.replaceChildren();
ReactDOM.render(React.createElement(Globe, {color: "currentColor"}), avatarSlot);
DOMManager.onRemoved(avatarSlot, () => ReactDOM.unmountComponentAtNode(avatarSlot));
// Replace the existing name
const nameSlot = newButton.querySelector(`[class*="name-"]`);
nameSlot.textContent = "Public Servers";
// Insert before the header, end of the list
header.parentNode.insertBefore(newButton, header);
}
disabled() {
// this.unpatchAll();
this.unpatchAll();
// DOM.query("#bd-pub-li").remove();
}
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 +132,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

@ -1,23 +1,31 @@
// fixed, improved, added, progress
export default {
description: "BetterDiscord is alive! At least... _sorta_.",
description: "Big improvements have been made!",
changes: [
{
title: "Known Issues",
title: "What's New?",
type: "improved",
items: [
"**Many many plugins are either completely broken or missing functionality.** Please refer to the respective developers for ETAs.",
"The Twitch Emote system is completely broken, and there is no ETA on being fixed.",
"The Public Servers module is also broken with no ETA for a fix.",
"BetterDiscord now has a built-in update system to help update broken plugins and themes.",
"New API options for plugin developers.",
"`Patcher` now works with the configurable getters.",
"A new tooltip component for use in plugins.",
"The plugin API now includes context menu capabilties.",
"Public servers button has found a new home on your Discord homepage above the DM list."
]
},
{
title: "Important News!",
title: "Bug Fixes",
type: "fixed",
items: [
"Due to recent and upcoming changes, BetterDiscord is going to go through a rewrite.",
"There is no ETA or timeline for this rewrite.",
"We will continue to try and __maintain__ this version of BetterDiscord without adding new features."
"Fixed many issues regarding memory leaks and out-of-memory errors!",
"Fixed a major issue where webpack searches would iterate by default.",
"Fixed an issue with `byStrings` and `combine` filters in the API.",
"Fixed an issue where searching for multiple modules could yield the same module multiple times.",
"Fixed an issue where misnamed addon files could prevent startup.",
"Fixed an issue where the `request` module would not follow redirects.",
"Fixed an issue where certain modals could crash the client.",
"Fixed an issue where toasts would not show on the crash screen."
]
}
]

View File

@ -70,5 +70,31 @@ export default [
{type: "switch", id: "inspectElement", value: false, enableWith: "devTools"},
{type: "switch", id: "devToolsWarning", value: false, enableWith: "devTools"},
]
}
},
// {
// type: "category",
// id: "debug",
// name: "Debug",
// collapsible: true,
// shown: true,
// settings: [
// {name: "Text test", note: "Just testing it", type: "text", id: "texttest", value: ""},
// {name: "Slider test", note: "Just testing it", type: "slider", id: "slidertest", value: 30, min: 20, max: 50, step: 10},
// {
// name: "Radio test",
// note: "Just testing it",
// type: "radio",
// id: "radiotest",
// value: "test",
// options: [
// {name: "First", value: 30, description: "little hint"},
// {name: "IDK", value: "test", description: "who cares"},
// {name: "Something", value: 666, description: "something else"},
// {name: "Last", value: "last", description: "nothing more to add"}
// ]
// },
// {name: "Keybind test", note: "Just testing it", type: "keybind", id: "keybindtest", value: ["Control", "H"]},
// {name: "Color test", note: "Just testing it", type: "color", id: "colortest", value: "#ff0000", defaultValue: "#ffffff"},
// ]
// }
];

View File

@ -2,11 +2,15 @@ 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();
window.BdApi = BdApi;
Object.defineProperty(window, "BdApi", {
value: BdApi,
writable: false,
configurable: false
});
window.global = window;
// Add loading icon at the bottom right

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;
}
@ -202,6 +206,7 @@ export default class AddonManager {
if (partialAddon) {
partialAddon.partial = true;
this.state[partialAddon.id] = false;
this.emit("loaded", partialAddon);
}
return e;
}
@ -211,11 +216,12 @@ export default class AddonManager {
if (error) {
this.state[addon.id] = false;
addon.partial = true;
this.emit("loaded", addon);
return error;
}
if (shouldToast) Toasts.success(`${addon.name} v${addon.version} was loaded.`);
this.emit("loaded", addon.id);
this.emit("loaded", addon);
if (!this.state[addon.id]) return this.state[addon.id] = false;
return this.startAddon(addon);
@ -228,7 +234,7 @@ export default class AddonManager {
if (this.state[addon.id]) isReload ? this.stopAddon(addon) : this.disableAddon(addon);
this.addonList.splice(this.addonList.indexOf(addon), 1);
this.emit("unloaded", addon.id);
this.emit("unloaded", addon);
if (shouldToast) Toasts.success(`${addon.name} was unloaded.`);
return true;
}

View File

@ -1,96 +0,0 @@
import request from "request";
import fileSystem from "fs";
import {Config} from "data";
import path from "path";
import PluginManager from "./pluginmanager";
import ThemeManager from "./thememanager";
import Toasts from "../ui/toasts";
import Notices from "../ui/notices";
import Logger from "common/logger";
const base = "https://api.betterdiscord.app/v2/store/";
const route = r => `${base}${r}`;
const redirect = addonId => `https://betterdiscord.app/gh-redirect?id=${addonId}`;
const getJSON = url => {
return new Promise(resolve => {
request(url, (error, _, body) => {
if (error) return resolve([]);
resolve(JSON.parse(body));
});
});
};
const reducer = (acc, addon) => {
if (addon.version === "Unknown") return acc;
acc[addon.file_name] = {name: addon.name, version: addon.version, id: addon.id, type: addon.type};
return acc;
};
export default class AddonUpdater {
static async initialize() {
this.cache = {};
this.shown = false;
this.pending = [];
const pluginData = await getJSON(route("plugins"));
const themeData = await getJSON(route("themes"));
pluginData.reduce(reducer, this.cache);
themeData.reduce(reducer, this.cache);
for (const addon of PluginManager.addonList) this.checkForUpdate(addon.filename, addon.version);
for (const addon of ThemeManager.addonList) this.checkForUpdate(addon.filename, addon.version);
this.showUpdateNotice();
}
static clearPending() {
this.pending.splice(0, this.pending.length);
}
static async checkForUpdate(filename, currentVersion) {
const info = this.cache[path.basename(filename)];
if (!info) return;
const hasUpdate = info.version > currentVersion;
if (!hasUpdate) return;
this.pending.push(filename);
}
static async updatePlugin(filename) {
const info = this.cache[filename];
request(redirect(info.id), (error, _, body) => {
if (error) {
Logger.stacktrace("AddonUpdater", `Failed to download body for ${info.id}:`, error);
return;
}
const file = path.join(path.resolve(Config.dataPath, info.type + "s"), filename);
fileSystem.writeFile(file, body.toString(), () => {
Toasts.success(`${info.name} has been updated to version ${info.version}!`);
});
});
}
static showUpdateNotice() {
if (this.shown || !this.pending.length) return;
this.shown = true;
const close = Notices.info(`BetterDiscord has found updates for ${this.pending.length} of your plugins and themes!`, {
timeout: 0,
buttons: [{
label: "Update Now",
onClick: async () => {
for (const name of this.pending) await this.updatePlugin(name);
close();
}
}],
onClose: () => {
this.shown = false;
this.clearPending();
}
});
}
}

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,312 @@
import WebpackModules from "../webpackmodules";
import Patcher from "../patcher";
import Logger from "common/logger";
import {React} from "../modules";
const MenuComponents = (() => {
const out = {};
const componentMap = {
separator: "Separator",
checkbox: "CheckboxItem",
radio: "RadioItem",
control: "ControlItem",
groupstart: "Group",
customitem: "Item"
};
let ContextMenuIndex = null;
const ContextMenuModule = WebpackModules.getModule((m, _, id) => Object.values(m).some(v => v?.FLEXIBLE) && (ContextMenuIndex = id), {searchExports: false});
const rawMatches = WebpackModules.require.m[ContextMenuIndex].toString().matchAll(/if\(\w+\.type===\w+\.(\w+)\).+?type:"(.+?)"/g);
out.Menu = Object.values(ContextMenuModule).find(v => v.toString().includes(".isUsingKeyboardNavigation"));
for (const [, identifier, type] of rawMatches) {
out[componentMap[type]] = ContextMenuModule[identifier];
}
return out;
})();
const ContextMenuActions = (() => {
const out = {};
const ActionsModule = WebpackModules.getModule(m => Object.values(m).some(m => typeof m === "function" && m.toString().includes("CONTEXT_MENU_CLOSE")), {searchExports: false});
for (const key of Object.keys(ActionsModule)) {
if (ActionsModule[key].toString().includes("CONTEXT_MENU_CLOSE")) {
out.closeContextMenu = ActionsModule[key];
}
else if (ActionsModule[key].toString().includes("renderLazy")) {
out.openContextMenu = ActionsModule[key];
}
}
return out;
})();
class MenuPatcher {
static MAX_PATCH_ITERATIONS = 10;
static patches = {};
static subPatches = new WeakMap();
static initialize() {
const {module, key} = (() => {
const module = WebpackModules.getModule(m => Object.values(m).some(v => typeof v === "function" && v.toString().includes("CONTEXT_MENU_CLOSE")), {searchExports: false});
const key = Object.keys(module).find(key => module[key].length === 3);
return {module, key};
})();
Patcher.before("ContextMenuPatcher", module, key, (_, methodArguments) => {
const promise = methodArguments[1];
methodArguments[1] = async function () {
const render = await promise.apply(this, arguments);
return props => {
const res = render(props);
if (res?.props.navId) {
MenuPatcher.runPatches(res.props.navId, res, props);
}
else if (typeof res?.type === "function") {
MenuPatcher.patchRecursive(res, "type");
}
return res;
};
};
});
}
static patchRecursive(target, method, iteration = 0) {
if (iteration >= this.MAX_PATCH_ITERATIONS) return;
const proxyFunction = this.subPatches.get(target[method]) ?? (() => {
const originalFunction = target[method];
function patch() {
const res = originalFunction.apply(this, arguments);
if (!res) return res;
if (res.props?.navId) {
MenuPatcher.runPatches(res.props.navId, res, arguments[0]);
}
else {
const layer = res.props.children ? res.props.children : res;
if (typeof layer?.type == "function") {
MenuPatcher.patchRecursive(layer, "type", ++iteration);
}
}
return res;
}
patch._originalFunction = originalFunction;
Object.assign(patch, originalFunction);
this.subPatches.set(originalFunction, patch);
return patch;
})();
target[method] = proxyFunction;
}
static runPatches(id, res, props) {
if (!this.patches[id]) return;
for (const patch of this.patches[id]) {
try {
patch(res, props);
}
catch (error) {
Logger.error("ContextMenu~runPatches", `Could not run ${id} patch for`, patch, error);
}
}
}
static patch(id, callback) {
this.patches[id] ??= new Set();
this.patches[id].add(callback);
}
static unpatch(id, callback) {
this.patches[id]?.delete(callback);
}
}
/**
* `ContextMenu` is a module to help patch and create context menus. Instance is accessible through the {@link BdApi}.
* @type ContextMenu
* @summary {@link ContextMenu} is a utility class for interacting with React internals.
* @name ContextMenu
*/
class ContextMenu {
/**
* Allows you to patch a given context menu. Acts as a wrapper around the `Patcher`.
*
* @param {string} navId Discord's internal navId used to identify context menus
* @param {function} callback callback function that accepts the react render tree
* @returns {function} a function that automatically unpatches
*/
patch(navId, callback) {
MenuPatcher.patch(navId, callback);
return () => MenuPatcher.unpatch(navId, callback);
}
/**
* Allows you to remove the patch added to a given context menu.
*
* @param {string} navId the original navId from patching
* @param {function} callback the original callback from patching
*/
unpatch(navId, callback) {
MenuPatcher.unpatch(navId, callback);
}
/**
* Builds a single menu item. The only prop shown here is the type, the rest should
* match the actual component being built. View those to see what options exist
* for each, they often have less in common than you might think.
*
* @param {object} props - props used to build the item
* @param {string} [props.type="text"] - type of the item, options: text, submenu, toggle, radio, custom, separator
* @returns {object} the created component
*
* @example
* // Creates a single menu item that prints "MENU ITEM" on click
* ContextMenu.buildItem({
* label: "Menu Item",
* action: () => {console.log("MENU ITEM");}
* });
*
* @example
* // Creates a single toggle item that starts unchecked
* // and print the new value on every toggle
* ContextMenu.buildItem({
* type: "toggle",
* label: "Item Toggle",
* checked: false,
* action: (newValue) => {console.log(newValue);}
* });
*/
buildItem(props) {
const {type} = props;
if (type === "separator") return React.createElement(MenuComponents.Separator);
let Component = MenuComponents.Item;
if (type === "submenu") {
if (!props.children) props.children = this.buildMenuChildren(props.render || props.items);
}
else if (type === "toggle" || type === "radio") {
Component = type === "toggle" ? MenuComponents.CheckboxItem : MenuComponents.RadioItem;
if (props.active) props.checked = props.active;
}
else if (type === "control") {
Component = MenuComponents.ControlItem;
}
if (!props.id) props.id = `${props.label.replace(/^[^a-z]+|[^\w-]+/gi, "-")}`;
if (props.danger) props.color = "colorDanger";
if (props.onClick && !props.action) props.action = props.onClick;
props.extended = true;
return React.createElement(Component, props);
}
/**
* Creates the all the items **and groups** of a context menu recursively.
* There is no hard limit to the number of groups within groups or number
* of items in a menu.
* @param {Array<object>} setup - array of item props used to build items. See {@link ContextMenu.buildItem}
* @returns {Array<object>} array of the created component
*
* @example
* // Creates a single item group item with a toggle item
* ContextMenu.buildMenuChildren([{
* type: "group",
* items: [{
* type: "toggle",
* label: "Item Toggle",
* active: false,
* action: (newValue) => {console.log(newValue);}
* }]
* }]);
*
* @example
* // Creates two item groups with a single toggle item each
* ContextMenu.buildMenuChildren([{
* type: "group",
* items: [{
* type: "toggle",
* label: "Item Toggle",
* active: false,
* action: (newValue) => {
* console.log(newValue);
* }
* }]
* }, {
* type: "group",
* items: [{
* type: "toggle",
* label: "Item Toggle",
* active: false,
* action: (newValue) => {
* console.log(newValue);
* }
* }]
* }]);
*/
buildMenuChildren(setup) {
const mapper = s => {
if (s.type === "group") return buildGroup(s);
return this.buildItem(s);
};
const buildGroup = function(group) {
const items = group.items.map(mapper).filter(i => i);
return React.createElement(MenuComponents.Group, null, items);
};
return setup.map(mapper).filter(i => i);
}
/**
* Creates the menu *component* including the wrapping `ContextMenu`.
* Calls {@link ContextMenu.buildMenuChildren} under the covers.
* Used to call in combination with {@link ContextMenu.open}.
* @param {Array<object>} setup - array of item props used to build items. See {@link ContextMenu.buildMenuChildren}
* @returns {function} the unique context menu component
*/
buildMenu(setup) {
return (props) => {return React.createElement(MenuComponents.Menu, props, this.buildMenuChildren(setup));};
}
/**
* Function that allows you to open an entire context menu. Recommended to build the menu with this module.
*
* @param {MouseEvent} event - The context menu event. This can be emulated, requires target, and all X, Y locations.
* @param {function} menuComponent - Component to render. This can be any react component or output of {@link ContextMenu.buildMenu}
* @param {object} config - configuration/props for the context menu
* @param {string} [config.position="right"] - default position for the menu, options: "left", "right"
* @param {string} [config.align="top"] - default alignment for the menu, options: "bottom", "top"
* @param {function} [config.onClose] - function to run when the menu is closed
* @param {boolean} [config.noBlurEvent=false] - No clue
*/
open(event, menuComponent, config) {
return ContextMenuActions.openContextMenu(event, function(e) {
return React.createElement(menuComponent, Object.assign({}, e, {onClose: ContextMenuActions.closeContextMenu}));
}, config);
}
/**
* Closes the current opened context menu immediately.
*/
close() {ContextMenuActions.closeContextMenu();}
}
Object.assign(ContextMenu.prototype, MenuComponents);
Object.freeze(ContextMenu);
Object.freeze(ContextMenu.prototype);
MenuPatcher.initialize();
export default ContextMenu;

View File

@ -0,0 +1,67 @@
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 {
#callerName = "";
constructor(callerName) {
if (!callerName) return;
this.#callerName = 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
*/
save(pluginName, key, data) {
if (this.#callerName) {
data = key;
key = pluginName;
pluginName = this.#callerName;
}
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) {
if (this.#callerName) {
key = pluginName;
pluginName = this.#callerName;
}
return DataStore.getPluginData(pluginName, key);
}
/**
* 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) {
if (this.#callerName) {
key = pluginName;
pluginName = this.#callerName;
}
return DataStore.deletePluginData(pluginName, key);
}
}
Object.freeze(Data);
Object.freeze(Data.prototype);
export default Data;

View File

@ -0,0 +1,120 @@
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 {
/**
* Current width of the user's screen.
* @type {number}
*/
get screenWidth() {return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);}
/**
* Current height of the user's screen.
* @type {number}
*/
get screenHeight() {return Math.max(document.documentElement.clientHeight, window.innerHeight || 0);}
#callerName = "";
constructor(callerName) {
if (!callerName) return;
this.#callerName = 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 (this.#callerName && arguments.length === 2) {
id = arguments[1];
css = arguments[2];
}
else if (this.#callerName) {
css = id;
id = this.#callerName;
}
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 (this.#callerName && arguments.length === 1) {
id = arguments[1];
}
else if (this.#callerName) {
id = this.#callerName;
}
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);
}
/**
* Utility to help smoothly animate using JavaScript
*
* @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
*/
animate(update, duration, options = {}) {
return DOMManager.animate({update, duration, timing: options.timing});
}
/**
* Utility function to make creating DOM elements easier. Acts similarly
* to `React.createElement`
*
* @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);
Object.freeze(DOM.prototype);
export default DOM;

View File

@ -0,0 +1,122 @@
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";
import ContextMenu from "./contextmenu";
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();
const ContextMenuAPI = new ContextMenu();
/**
* `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;}
get ContextMenu() {return ContextMenuAPI;}
}
// 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;
/**
* An instance of {@link ContextMenu} for interacting with context menus
* @type ContextMenu
*/
BdApi.ContextMenu = ContextMenuAPI;
Object.freeze(BdApi);
Object.freeze(BdApi.prototype);

View File

@ -0,0 +1,514 @@
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
* @memberof BdApi
*/
const React = DiscordModules.React;
/**
* The ReactDOM module being used inside Discord.
* @type ReactDOM
* @memberof BdApi
*/
const ReactDOM = DiscordModules.ReactDOM;
/**
* A reference object to get BD's settings.
* @type object
* @deprecated
* @memberof BdApi
*/
const settings = Settings.collections;
/**
* A reference object for BD's emotes.
* @type object
* @deprecated
* @memberof BdApi
*/
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
* @memberof BdApi
*/
const version = Config.version;
/**
* Adds a `<style>` to the document with the given ID.
*
* @deprecated
* @param {string} id ID to use for style element
* @param {string} css CSS to apply to the document
* @memberof BdApi
*/
function injectCSS(id, css) {
DOMManager.injectStyle(id, css);
}
/**
* Removes a `<style>` from the document corresponding to the given ID.
*
* @deprecated
* @param {string} id ID uses for the style element
* @memberof BdApi
*/
function clearCSS(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
* @memberof BdApi
*/
function linkJS(id, url) {
return DOMManager.injectScript(id, url);
}
/**
* Removes a remotely linked JS script.
*
* @deprecated
* @param {string} id ID of the script element
* @memberof BdApi
*/
function unlinkJS(id) {
DOMManager.removeScript(id);
}
/**
* Shows a generic but very customizable modal.
*
* @deprecated
* @param {string} title title of the modal
* @param {(string|ReactElement|Array<string|ReactElement>)} content a string of text to display in the modal
* @memberof BdApi
*/
function alert(title, content) {
Modals.alert(title, content);
}
/**
* Shows a generic but very customizable confirmation modal with optional confirm and cancel callbacks.
*
* @deprecated
* @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
* @memberof BdApi
*/
function showConfirmationModal(title, content, options = {}) {
return Modals.showConfirmationModal(title, content, options);
}
/**
* Shows a toast similar to android towards the bottom of the screen.
*
* @deprecated
* @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
* @memberof BdApi
*/
function showToast(content, options = {}) {
Toasts.show(content, options);
}
/**
* Shows a notice above Discord's chat layer.
*
* @deprecated
* @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} A callback for closing the notice. Passing `true` as first parameter closes immediately without transitioning out.
* @memberof BdApi
*/
function showNotice(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`
* @memberof BdApi
*/
function findModule(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
* @memberof BdApi
*/
function findAllModules(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`
* @memberof BdApi
*/
function findModuleByProps(...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`
* @memberof BdApi
*/
function findModuleByPrototypes(...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`
* @memberof BdApi
*/
function findModuleByDisplayName(name) {
return WebpackModules.getByDisplayName(name);
}
/**
* Get the internal react data of a specified node.
*
* @deprecated
* @param {HTMLElement} node Node to get the react data from
* @returns {object|undefined} Either the found data or `undefined`
* @memberof BdApi
*/
function getInternalInstance(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.
*
* @deprecated
* @param {string} pluginName Name of the plugin loading data
* @param {string} key Which piece of data to load
* @returns {any} The stored data
* @memberof BdApi
*/
function loadData(pluginName, key) {
return DataStore.getPluginData(pluginName, key);
}
/**
* Saves JSON-serializable data.
*
* @deprecated
* @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
* @memberof BdApi
*/
function saveData(pluginName, key, data) {
return DataStore.setPluginData(pluginName, key, data);
}
/**
* Deletes a piece of stored data, this is different than saving as null or undefined.
*
* @deprecated
* @param {string} pluginName Name of the plugin deleting data
* @param {string} key Which piece of data to delete
* @memberof BdApi
*/
function deleteData(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
* @memberof BdApi
*/
function monkeyPatch(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.
*
* @deprecated
* @param {HTMLElement} node Node to be observed
* @param {function} callback Function to run when fired
* @memberof BdApi
*/
function onRemoved(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
* @memberof BdApi
*/
function suppressErrors(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
* @memberof BdApi
*/
function testJSON(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
* @memberof BdApi
*/
function isSettingEnabled(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
* @memberof BdApi
*/
function enableSetting(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
* @memberof BdApi
*/
function disableSetting(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
* @memberof BdApi
*/
function toggleSetting(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
* @memberof BdApi
*/
function getBDData(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
* @memberof BdApi
*/
function setBDData(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.
*
* @deprecated
* @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
* @memberof BdApi
*/
async function openDialog(options) {
const data = await ipc.openDialog(options);
if (data.error) throw new Error(data.error);
return data;
}
export {
React,
ReactDOM,
settings,
emotes,
version,
injectCSS,
clearCSS,
linkJS,
unlinkJS,
alert,
showConfirmationModal,
showToast,
showNotice,
findModule,
findAllModules,
findModuleByProps,
findModuleByPrototypes,
findModuleByDisplayName,
getInternalInstance,
loadData,
loadData as getData,
saveData,
saveData as setData,
deleteData,
monkeyPatch,
onRemoved,
suppressErrors,
testJSON,
isSettingEnabled,
enableSetting,
disableSetting,
toggleSetting,
getBDData,
setBDData,
openDialog
};

View File

@ -0,0 +1,100 @@
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 {
#callerName = "";
constructor(callerName) {
if (!callerName) return;
this.#callerName = 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) {
if (this.#callerName) {
callback = functionName;
functionName = moduleToPatch;
moduleToPatch = caller;
caller = this.#callerName;
}
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) {
if (this.#callerName) {
callback = functionName;
functionName = moduleToPatch;
moduleToPatch = caller;
caller = this.#callerName;
}
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) {
if (this.#callerName) {
callback = functionName;
functionName = moduleToPatch;
moduleToPatch = caller;
caller = this.#callerName;
}
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 (this.#callerName) caller = this.#callerName;
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 (this.#callerName) caller = this.#callerName;
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);
Object.freeze(Patcher.prototype);
export default Patcher;

View File

@ -0,0 +1,81 @@
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.
* @name ReactUtils
*/
const ReactUtils = {
get rootInstance() {return document.getElementById("app-mount")?._reactRootContainer?._internalRoot?.current;},
/**
* 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`
*/
getInternalInstance(node) {
if (node.__reactFiber$) return node.__reactFiber$;
return node[Object.keys(node).find(k => k.startsWith("__reactInternalInstance") || k.startsWith("__reactFiber"))] || null;
},
/**
* Attempts to find the "owner" node to the current node. This is generally
* a node with a stateNode--a class component.
* @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.
*/
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,115 @@
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.
* @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 {Tooltip} the tooltip that was generated
*/
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);
},
/**
* 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=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,71 @@
import Utilities from "../utilities";
/**
* `Utils` is a utility containing commonly reused functions. Instance is accessible through the {@link BdApi}.
* @type Utils
* @summary {@link Utils} is a utility class for interacting with React internals.
* @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,113 @@
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.
* @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 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.
* @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] Options 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.searchExports=false] Whether to execute the filter on webpack exports
* @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 (("searchExports" in options) && typeof(options.searchExports) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.searchExports", options.searchExports, "boolean expected.");
return WebpackModules.getModule(filter, options);
},
/**
* Finds multiple modules using multiple filters.
* @memberof Webpack
* @param {...object} queries Object representing the query to perform
* @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.searchExports=false] Whether to execute the filter on webpack exports
* @return {any}
*/
getBulk(...queries) {return WebpackModules.getBulk(...queries);},
/**
* Finds a module that is 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] Options for configuring 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.searchExports=false] Whether to execute the filter on webpack exports
* @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 (("searchExports" in options) && typeof(options.searchExports) !== "boolean") return Logger.error("BdApi.Webpack~getModule", "Unsupported type used for options.searchExports", options.searchExports, "boolean expected.");
return WebpackModules.getLazy(filter, options);
},
};
Object.freeze(Webpack);
Object.freeze(Webpack.Filters);
export default Webpack;

View File

@ -1,4 +1,3 @@
const path = require("path");
import LocaleManager from "./localemanager";
import Logger from "common/logger";
@ -11,12 +10,10 @@ import * as Builtins from "builtins";
import Modals from "../ui/modals";
import DataStore from "./datastore";
import DiscordModules from "./discordmodules";
import Strings from "./strings";
import IPC from "./ipc";
import LoadingIcon from "../loadingicon";
import Styles from "../styles/index.css";
import Editor from "./editor";
import AddonUpdater from "./addonupdater";
import Updater from "./updater";
export default new class Core {
async startup() {
@ -37,9 +34,6 @@ export default new class Core {
Logger.log("Startup", "Initializing LocaleManager");
LocaleManager.initialize();
Logger.log("Startup", "Getting update information");
this.checkForUpdate();
Logger.log("Startup", "Initializing Settings");
Settings.initialize();
@ -52,6 +46,8 @@ export default new class Core {
Logger.log("Startup", "Initializing Editor");
await Editor.initialize();
Modals.initialize();
Logger.log("Startup", "Initializing Builtins");
for (const module in Builtins) {
Builtins[module].initialize();
@ -65,8 +61,8 @@ export default new class Core {
// const themeErrors = [];
const themeErrors = ThemeManager.initialize();
Logger.log("Startup", "Initializing AddonUpdater");
AddonUpdater.initialize();
Logger.log("Startup", "Initializing Updater");
Updater.initialize();
Logger.log("Startup", "Removing Loading Icon");
LoadingIcon.hide();
@ -76,15 +72,8 @@ export default new class Core {
Modals.showAddonErrors({plugins: pluginErrors, themes: themeErrors});
const previousVersion = DataStore.getBDData("version");
if (Config.version > previousVersion) {
const md = [Changelog.description];
for (const type of Changelog.changes) {
md.push(`**${type.title}**`);
for (const entry of type.items) {
md.push(` - ${entry}`);
}
}
Modals.showConfirmationModal(`BetterDiscord v${Config.version}`, md, {cancelText: ""});
if (Config.version !== previousVersion) {
Modals.showChangelogModal(Changelog);
DataStore.setBDData("version", Config.version);
}
}
@ -95,57 +84,4 @@ export default new class Core {
DiscordModules.Dispatcher.subscribe("CONNECTION_OPEN", done);
});
}
async checkForUpdate() {
const resp = await fetch(`https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest`,{
method: "GET",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "BetterDiscord Updater"
}
});
const data = await resp.json();
Object.assign(Config.release, data);
const remoteVersion = data.tag_name.startsWith("v") ? data.tag_name.slice(1) : data.tag_name;
const hasUpdate = remoteVersion > Config.version;
if (!hasUpdate) return;
// TODO: move to strings file when updater is complete.
Modals.showConfirmationModal("Update Available", `BetterDiscord (v${Config.version}) has an update available (v${remoteVersion}). Would you like to update now?`, {
confirmText: "Update Now!",
cancelText: "Skip",
onConfirm: () => this.update(data)
});
}
async update(releaseInfo) {
try {
const asar = releaseInfo.assets.find(a => a.name === "betterdiscord.asar");
const request = require("request");
const buff = await new Promise((resolve, reject) =>
request(asar.url, {encoding: null, headers: {"User-Agent": "BetterDiscord Updater", "Accept": "application/octet-stream"}}, (err, resp, body) => {
if (err || resp.statusCode != 200) return reject(err || `${resp.statusCode} ${resp.statusMessage}`);
return resolve(body);
}));
const asarPath = path.join(DataStore.baseFolder, "betterdiscord.asar");
const fs = require("original-fs");
fs.writeFileSync(asarPath, buff);
Modals.showConfirmationModal("Update Successful!", "BetterDiscord updated successfully. Discord needs to restart in order for it to take effect. Do you want to do this now?", {
confirmText: Strings.Modals.restartNow,
cancelText: Strings.Modals.restartLater,
danger: true,
onConfirm: () => IPC.relaunch()
});
}
catch (err) {
Logger.stacktrace("Updater", "Failed to update", err);
Modals.showConfirmationModal("Update Failed", "BetterDiscord failed to update. Please download the latest version of the installer from GitHub (https://github.com/BetterDiscord/Installer/releases/latest) and reinstall.", {
cancelText: null
});
}
}
};
};

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,747 +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 {
static escapeID(id) {
return id.replace(/^[^a-z]+|[^\w-]+/gi, "-");
}
/**
* 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();
}
// 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);
}
});
}
/**
* 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

@ -3,7 +3,7 @@ import DiscordModules from "./discordmodules";
import Utilities from "./utilities";
import Events from "./emitter";
const {Dispatcher, LocaleStore} = DiscordModules;
const {LocaleStore} = DiscordModules;
export default new class LocaleManager {
get discordLocale() {return LocaleStore?.locale ?? this.defaultLocale;}
@ -16,7 +16,7 @@ export default new class LocaleManager {
initialize() {
this.setLocale(this.discordLocale);
Dispatcher.subscribe("USER_SETTINGS_UPDATE", (newLocale) => this.setLocale(newLocale));
LocaleStore?.addChangeListener((newLocale) => this.setLocale(newLocale));
}
setLocale(newLocale) {

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,232 +1,276 @@
/**
* Patcher that can patch other functions allowing you to run code before, after or
* instead of the original function. Can also alter arguments and return values.
*
* This is from Zerebos' library {@link https://github.com/rauenzi/BDPluginLibrary}
*
* @module Patcher
* @version 0.0.2
*/
import Logger from "common/logger";
import DiscordModules from "./discordmodules";
import WebpackModules from "./webpackmodules";
export default class Patcher {
static get patches() {return this._patches || (this._patches = []);}
/**
* Returns all the patches done by a specific caller
* @param {string} name - Name of the patch caller
* @method
*/
static getPatchesByCaller(name) {
if (!name) return [];
const patches = [];
for (const patch of this.patches) {
for (const childPatch of patch.children) {
if (childPatch.caller === name) patches.push(childPatch);
}
}
return patches;
}
/**
* Unpatches all patches passed, or when a string is passed unpatches all
* patches done by that specific caller.
* @param {Array|string} patches - Either an array of patches to unpatch or a caller name
*/
static unpatchAll(patches) {
if (typeof patches === "string") patches = this.getPatchesByCaller(patches);
for (const patch of patches) {
patch.unpatch();
}
}
static resolveModule(module) {
if (!module || typeof(module) === "function" || (typeof(module) === "object" && !Array.isArray(module))) return module;
if (typeof module === "string") return DiscordModules[module];
if (Array.isArray(module)) return WebpackModules.findByUniqueProperties(module);
return null;
}
static makeOverride(patch) {
return function () {
let returnValue;
if (!patch.children || !patch.children.length) return patch.originalFunction.apply(this, arguments);
for (const superPatch of patch.children.filter(c => c.type === "before")) {
try {
superPatch.callback(this, arguments);
}
catch (err) {
Logger.err("Patcher", `Could not fire before callback of ${patch.functionName} for ${superPatch.caller}`, err);
}
}
const insteads = patch.children.filter(c => c.type === "instead");
if (!insteads.length) {returnValue = patch.originalFunction.apply(this, arguments);}
else {
for (const insteadPatch of insteads) {
try {
const tempReturn = insteadPatch.callback(this, arguments, patch.originalFunction.bind(this));
if (typeof(tempReturn) !== "undefined") returnValue = tempReturn;
}
catch (err) {
Logger.err("Patcher", `Could not fire instead callback of ${patch.functionName} for ${insteadPatch.caller}`, err);
}
}
}
for (const slavePatch of patch.children.filter(c => c.type === "after")) {
try {
const tempReturn = slavePatch.callback(this, arguments, returnValue);
if (typeof(tempReturn) !== "undefined") returnValue = tempReturn;
}
catch (err) {
Logger.err("Patcher", `Could not fire after callback of ${patch.functionName} for ${slavePatch.caller}`, err);
}
}
return returnValue;
};
}
static rePatch(patch) {
patch.proxyFunction = patch.module[patch.functionName] = this.makeOverride(patch);
}
static makePatch(module, functionName, name) {
const patch = {
name,
module,
functionName,
originalFunction: module[functionName],
proxyFunction: null,
revert: () => { // Calling revert will destroy any patches added to the same module after this
patch.module[patch.functionName] = patch.originalFunction;
patch.proxyFunction = null;
patch.children = [];
},
counter: 0,
children: []
};
patch.proxyFunction = module[functionName] = this.makeOverride(patch);
Object.assign(module[functionName], patch.originalFunction);
module[functionName].__originalFunction = patch.originalFunction;
module[functionName].toString = () => patch.originalFunction.toString();
this.patches.push(patch);
return patch;
}
/**
* Function with no arguments and no return value that may be called to revert changes made by {@link module:Patcher}, restoring (unpatching) original method.
* @callback module:Patcher~unpatch
*/
/**
* A callback that modifies method logic. This callback is called on each call of the original method and is provided all data about original call. Any of the data can be modified if necessary, but do so wisely.
*
* The third argument for the callback will be `undefined` for `before` patches. `originalFunction` for `instead` patches and `returnValue` for `after` patches.
*
* @callback module:Patcher~patchCallback
* @param {object} thisObject - `this` in the context of the original function.
* @param {arguments} arguments - The original arguments of the original function.
* @param {(function|*)} extraValue - For `instead` patches, this is the original function from the module. For `after` patches, this is the return value of the function.
* @return {*} Makes sense only when using an `instead` or `after` patch. If something other than `undefined` is returned, the returned value replaces the value of `returnValue`. If used for `before` the return value is ignored.
*/
/**
* 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. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care.
* @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
* @param {module:Patcher~patchCallback} callback - Function to run before the original method
* @param {object} options - Object used to pass additional options.
* @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically.
* @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place).
* @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped.
*/
static before(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "before"}));}
/**
* This method patches onto another function, allowing your code to run after.
* 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. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care.
* @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
* @param {module:Patcher~patchCallback} callback - Function to run instead of the original method
* @param {object} options - Object used to pass additional options.
* @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically.
* @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place).
* @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped.
*/
static after(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "after"}));}
/**
* 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. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care.
* @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
* @param {module:Patcher~patchCallback} callback - Function to run after the original method
* @param {object} options - Object used to pass additional options.
* @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically.
* @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place).
* @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped.
*/
static instead(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "instead"}));}
/**
* This method patches onto another function, allowing your code to run before, instead or after the original function.
* Using this you are able to modify the incoming arguments before the original function is run as well as the return
* value before the original function actually returns.
*
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care.
* @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
* @param {module:Patcher~patchCallback} callback - Function to run after the original method
* @param {object} options - Object used to pass additional options.
* @param {string} [options.type=after] - Determines whether to run the function `before`, `instead`, or `after` the original.
* @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically.
* @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place).
* @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped.
*/
static pushChildPatch(caller, moduleToPatch, functionName, callback, options = {}) {
const {type = "after", forcePatch = true} = options;
const module = this.resolveModule(moduleToPatch);
if (!module) return null;
if (!module[functionName] && forcePatch) module[functionName] = function() {};
if (!(module[functionName] instanceof Function)) return null;
if (typeof moduleToPatch === "string") options.displayName = moduleToPatch;
const displayName = options.displayName || module.displayName || module.name || module.constructor.displayName || module.constructor.name;
const patchId = `${displayName}.${functionName}`;
const patch = this.patches.find(p => p.module == module && p.functionName == functionName) || this.makePatch(module, functionName, patchId);
if (!patch.proxyFunction) this.rePatch(patch);
const child = {
caller,
type,
id: patch.counter,
callback,
unpatch: () => {
patch.children.splice(patch.children.findIndex(cpatch => cpatch.id === child.id && cpatch.type === type), 1);
if (patch.children.length <= 0) {
const patchNum = this.patches.findIndex(p => p.module == module && p.functionName == functionName);
if (patchNum < 0) return;
this.patches[patchNum].revert();
this.patches.splice(patchNum, 1);
}
}
};
patch.children.push(child);
patch.counter++;
return child.unpatch;
}
}
/**
* Patcher that can patch other functions allowing you to run code before, after or
* instead of the original function. Can also alter arguments and return values.
*
* This is from Zerebos' library {@link https://github.com/rauenzi/BDPluginLibrary}
*
* @module Patcher
* @version 0.0.2
*/
import Logger from "common/logger";
import DiscordModules from "./discordmodules";
import WebpackModules from "./webpackmodules";
export default class Patcher {
static get patches() {return this._patches || (this._patches = []);}
/**
* Returns all the patches done by a specific caller
* @param {string} name - Name of the patch caller
* @method
*/
static getPatchesByCaller(name) {
if (!name) return [];
const patches = [];
for (const patch of this.patches) {
for (const childPatch of patch.children) {
if (childPatch.caller === name) patches.push(childPatch);
}
}
return patches;
}
/**
* Unpatches all patches passed, or when a string is passed unpatches all
* patches done by that specific caller.
* @param {Array|string} patches - Either an array of patches to unpatch or a caller name
*/
static unpatchAll(patches) {
if (typeof patches === "string") patches = this.getPatchesByCaller(patches);
for (const patch of patches) {
patch.unpatch();
}
}
static resolveModule(module) {
if (!module || typeof(module) === "function" || (typeof(module) === "object" && !Array.isArray(module))) return module;
if (typeof module === "string") return DiscordModules[module];
if (Array.isArray(module)) return WebpackModules.findByUniqueProperties(module);
return null;
}
static makeOverride(patch) {
return function () {
let returnValue;
if (!patch.children || !patch.children.length) return patch.originalFunction.apply(this, arguments);
for (const superPatch of patch.children.filter(c => c.type === "before")) {
try {
superPatch.callback(this, arguments);
}
catch (err) {
Logger.err("Patcher", `Could not fire before callback of ${patch.functionName} for ${superPatch.caller}`, err);
}
}
const insteads = patch.children.filter(c => c.type === "instead");
if (!insteads.length) {returnValue = patch.originalFunction.apply(this, arguments);}
else {
for (const insteadPatch of insteads) {
try {
const tempReturn = insteadPatch.callback(this, arguments, patch.originalFunction.bind(this));
if (typeof(tempReturn) !== "undefined") returnValue = tempReturn;
}
catch (err) {
Logger.err("Patcher", `Could not fire instead callback of ${patch.functionName} for ${insteadPatch.caller}`, err);
}
}
}
for (const slavePatch of patch.children.filter(c => c.type === "after")) {
try {
const tempReturn = slavePatch.callback(this, arguments, returnValue);
if (typeof(tempReturn) !== "undefined") returnValue = tempReturn;
}
catch (err) {
Logger.err("Patcher", `Could not fire after callback of ${patch.functionName} for ${slavePatch.caller}`, err);
}
}
return returnValue;
};
}
static rePatch(patch) {
patch.proxyFunction = patch.module[patch.functionName] = this.makeOverride(patch);
}
static makePatch(module, functionName, name) {
const patch = {
name,
module,
functionName,
originalFunction: module[functionName],
proxyFunction: null,
revert: () => { // Calling revert will destroy any patches added to the same module after this
if (patch.getter) {
Object.defineProperty(patch.module, functionName, {
get: () => patch.originalFunction,
configurable: true,
enumerable: true
});
} else {
patch.module[patch.functionName] = patch.originalFunction;
}
patch.proxyFunction = null;
patch.children = [];
},
counter: 0,
children: []
};
patch.proxyFunction = this.makeOverride(patch);
const descriptor = Object.getOwnPropertyDescriptor(module, functionName);
if (descriptor.get) {
patch.getter = true;
Object.defineProperty(module, functionName, {
get: () => patch.proxyFunction,
set: value => (patch.originalFunction = value),
configurable: true,
enumerable: true
});
} else {
patch.getter = false;
module[functionName] = patch.proxyFunction;
}
const descriptors = Object.assign({}, Object.getOwnPropertyDescriptors(patch.originalFunction), {
__originalFunction: {
get: () => patch.originalFunction,
configurable: true,
enumerable: true,
writeable: true
},
toString: {
value: () => patch.originalFunction.toString(),
configurable: true,
enumerable: true,
writeable: true
}
});
Object.defineProperties(patch.proxyFunction, descriptors);
this.patches.push(patch);
return patch;
}
/**
* Function with no arguments and no return value that may be called to revert changes made by {@link module:Patcher}, restoring (unpatching) original method.
* @callback module:Patcher~unpatch
*/
/**
* A callback that modifies method logic. This callback is called on each call of the original method and is provided all data about original call. Any of the data can be modified if necessary, but do so wisely.
*
* The third argument for the callback will be `undefined` for `before` patches. `originalFunction` for `instead` patches and `returnValue` for `after` patches.
*
* @callback module:Patcher~patchCallback
* @param {object} thisObject - `this` in the context of the original function.
* @param {arguments} arguments - The original arguments of the original function.
* @param {(function|*)} extraValue - For `instead` patches, this is the original function from the module. For `after` patches, this is the return value of the function.
* @return {*} Makes sense only when using an `instead` or `after` patch. If something other than `undefined` is returned, the returned value replaces the value of `returnValue`. If used for `before` the return value is ignored.
*/
/**
* 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. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care.
* @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
* @param {module:Patcher~patchCallback} callback - Function to run before the original method
* @param {object} options - Object used to pass additional options.
* @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically.
* @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place).
* @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped.
*/
static before(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "before"}));}
/**
* This method patches onto another function, allowing your code to run after.
* 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. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care.
* @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
* @param {module:Patcher~patchCallback} callback - Function to run instead of the original method
* @param {object} options - Object used to pass additional options.
* @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically.
* @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place).
* @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped.
*/
static after(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "after"}));}
/**
* 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. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care.
* @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
* @param {module:Patcher~patchCallback} callback - Function to run after the original method
* @param {object} options - Object used to pass additional options.
* @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically.
* @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place).
* @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped.
*/
static instead(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "instead"}));}
/**
* This method patches onto another function, allowing your code to run before, instead or after the original function.
* Using this you are able to modify the incoming arguments before the original function is run as well as the return
* value before the original function actually returns.
*
* @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care.
* @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype.
* @param {string} functionName - Name of the method to be patched
* @param {module:Patcher~patchCallback} callback - Function to run after the original method
* @param {object} options - Object used to pass additional options.
* @param {string} [options.type=after] - Determines whether to run the function `before`, `instead`, or `after` the original.
* @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically.
* @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place).
* @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped.
*/
static pushChildPatch(caller, moduleToPatch, functionName, callback, options = {}) {
const {type = "after", forcePatch = true} = options;
const module = this.resolveModule(moduleToPatch);
if (!module) return null;
if (!module[functionName] && forcePatch) module[functionName] = function() {};
if (!(module[functionName] instanceof Function)) return null;
if (!Object.getOwnPropertyDescriptor(module, functionName)?.configurable) {
Logger.err("Patcher", `Cannot patch ${functionName} of Module, property is readonly.`);
return null;
}
if (typeof moduleToPatch === "string") options.displayName = moduleToPatch;
const displayName = options.displayName || module.displayName || module.name || module.constructor.displayName || module.constructor.name;
const patchId = `${displayName}.${functionName}`;
const patch = this.patches.find(p => p.module == module && p.functionName == functionName) || this.makePatch(module, functionName, patchId);
if (!patch.proxyFunction) this.rePatch(patch);
const child = {
caller,
type,
id: patch.counter,
callback,
unpatch: () => {
patch.children.splice(patch.children.findIndex(cpatch => cpatch.id === child.id && cpatch.type === type), 1);
if (patch.children.length <= 0) {
const patchNum = this.patches.findIndex(p => p.module == module && p.functionName == functionName);
if (patchNum < 0) return;
this.patches[patchNum].revert();
this.patches.splice(patchNum, 1);
}
}
};
patch.children.push(child);
patch.counter++;
return child.unpatch;
}
}

View File

@ -1,691 +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";
/**
* `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);
};
/**
* 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

@ -42,17 +42,20 @@ export default new class PluginManager extends AddonManager {
initialize() {
const errors = super.initialize();
this.setupFunctions();
Settings.registerPanel("plugins", Strings.Panels.plugins, {element: () => SettingsRenderer.getAddonPanel(Strings.Panels.plugins, this.addonList, this.state, {
type: this.prefix,
folder: this.addonFolder,
onChange: this.togglePlugin.bind(this),
reload: this.reloadPlugin.bind(this),
refreshList: this.updatePluginList.bind(this),
saveAddon: this.saveAddon.bind(this),
editAddon: this.editAddon.bind(this),
deleteAddon: this.deleteAddon.bind(this),
prefix: this.prefix
})});
Settings.registerPanel("plugins", Strings.Panels.plugins, {
order: 3,
element: () => SettingsRenderer.getAddonPanel(Strings.Panels.plugins, this.addonList, this.state, {
type: this.prefix,
folder: this.addonFolder,
onChange: this.togglePlugin.bind(this),
reload: this.reloadPlugin.bind(this),
refreshList: this.updatePluginList.bind(this),
saveAddon: this.saveAddon.bind(this),
editAddon: this.editAddon.bind(this),
deleteAddon: this.deleteAddon.bind(this),
prefix: this.prefix
})
});
return errors;
}

View File

@ -111,11 +111,16 @@ export default new class SettingsManager {
for (const setting in this.state[id][category]) {
if (previousState[category][setting] == undefined) continue;
const settingObj = this.getSetting(id, category, setting);
if (settingObj.type == "switch") this.state[id][category][setting] = previousState[category][setting];
if (settingObj.type == "number") this.state[id][category][setting] = previousState[category][setting];
if (settingObj.type == "dropdown") {
const exists = settingObj.options.some(o => o.value == previousState[category][setting]);
if (exists) this.state[id][category][setting] = previousState[category][setting];
switch (settingObj.type) {
case "radio":
case "dropdown": {
const exists = settingObj.options.some(o => o.value == previousState[category][setting]);
if (exists) this.state[id][category][setting] = previousState[category][setting];
break;
}
default: {
this.state[id][category][setting] = previousState[category][setting];
}
}
}
}

View File

@ -21,17 +21,20 @@ export default new class ThemeManager extends AddonManager {
initialize() {
const errors = super.initialize();
Settings.registerPanel("themes", Strings.Panels.themes, {element: () => SettingsRenderer.getAddonPanel(Strings.Panels.themes, this.addonList, this.state, {
type: this.prefix,
folder: this.addonFolder,
onChange: this.toggleTheme.bind(this),
reload: this.reloadTheme.bind(this),
refreshList: this.updateThemeList.bind(this),
saveAddon: this.saveAddon.bind(this),
editAddon: this.editAddon.bind(this),
deleteAddon: this.deleteAddon.bind(this),
prefix: this.prefix
})});
Settings.registerPanel("themes", Strings.Panels.themes, {
order: 4,
element: () => SettingsRenderer.getAddonPanel(Strings.Panels.themes, this.addonList, this.state, {
type: this.prefix,
folder: this.addonFolder,
onChange: this.toggleTheme.bind(this),
reload: this.reloadTheme.bind(this),
refreshList: this.updateThemeList.bind(this),
saveAddon: this.saveAddon.bind(this),
editAddon: this.editAddon.bind(this),
deleteAddon: this.deleteAddon.bind(this),
prefix: this.prefix
})
});
return errors;
}

View File

@ -0,0 +1,215 @@
import request from "request";
import fileSystem from "fs";
import {Config} from "data";
import path from "path";
import Logger from "common/logger";
import Events from "./emitter";
import IPC from "./ipc";
import Strings from "./strings";
import DataStore from "./datastore";
import Settings from "./settingsmanager";
import PluginManager from "./pluginmanager";
import ThemeManager from "./thememanager";
import WebpackModules from "./webpackmodules";
import Toasts from "../ui/toasts";
import Notices from "../ui/notices";
import Modals from "../ui/modals";
import UpdaterPanel from "../ui/updater";
import DiscordModules from "./discordmodules";
const React = DiscordModules.React;
const UserSettingsWindow = WebpackModules.getByProps("updateAccount");
const base = "https://api.betterdiscord.app/v2/store/";
const route = r => `${base}${r}s`;
const redirect = addonId => `https://betterdiscord.app/gh-redirect?id=${addonId}`;
const getJSON = url => {
return new Promise(resolve => {
request(url, (error, _, body) => {
if (error) return resolve([]);
resolve(JSON.parse(body));
});
});
};
const reducer = (acc, addon) => {
if (addon.version === "Unknown") return acc;
acc[addon.file_name] = {name: addon.name, version: addon.version, id: addon.id};
return acc;
};
export default class Updater {
static initialize() {
Settings.registerPanel("updates", "Updates", {
order: 1,
element: () => {
return React.createElement(UpdaterPanel, {
coreUpdater: CoreUpdater,
pluginUpdater: PluginUpdater,
themeUpdater: ThemeUpdater
});
}
});
CoreUpdater.initialize();
PluginUpdater.initialize();
ThemeUpdater.initialize();
}
}
export class CoreUpdater {
static hasUpdate = false;
static apiData = {};
static remoteVersion = "";
static async initialize() {
this.checkForUpdate();
}
static async checkForUpdate(showNotice = true) {
const resp = await fetch(`https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest`,{
method: "GET",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "BetterDiscord Updater"
}
});
const data = await resp.json();
this.apiData = data;
const remoteVersion = data.tag_name.startsWith("v") ? data.tag_name.slice(1) : data.tag_name;
this.hasUpdate = remoteVersion > Config.version;
this.remoteVersion = remoteVersion;
if (!this.hasUpdate || !showNotice) return;
const close = Notices.info(`BetterDiscord has a new update (v${remoteVersion})`, {
buttons: [{
label: "More Info",
onClick: () => {
close();
UserSettingsWindow?.open?.("updates");
}
}]
});
}
static async update() {
try {
const asar = this.apiData.assets.find(a => a.name === "betterdiscord.asar");
const buff = await new Promise((resolve, reject) =>
request(asar.url, {encoding: null, headers: {"User-Agent": "BetterDiscord Updater", "Accept": "application/octet-stream"}}, (err, resp, body) => {
if (err || resp.statusCode != 200) return reject(err || `${resp.statusCode} ${resp.statusMessage}`);
return resolve(body);
}));
const asarPath = path.join(DataStore.baseFolder, "betterdiscord.asar");
const fs = require("original-fs");
fs.writeFileSync(asarPath, buff);
this.hasUpdate = false;
Config.version = this.remoteVersion;
Modals.showConfirmationModal("Update Successful!", "BetterDiscord updated successfully. Discord needs to restart in order for it to take effect. Do you want to do this now?", {
confirmText: Strings.Modals.restartNow,
cancelText: Strings.Modals.restartLater,
danger: true,
onConfirm: () => IPC.relaunch()
});
}
catch (err) {
Logger.stacktrace("Updater", "Failed to update", err);
Modals.showConfirmationModal("Update Failed", "BetterDiscord failed to update. Please download the latest version of the installer from GitHub (https://github.com/BetterDiscord/Installer/releases/latest) and reinstall.", {
cancelText: null
});
}
}
}
class AddonUpdater {
constructor(type) {
this.manager = type === "plugin" ? PluginManager : ThemeManager;
this.type = type;
this.cache = {};
this.pending = [];
}
async initialize() {
await this.updateCache();
this.checkAll();
Events.on(`${this.type}-loaded`, addon => {
this.checkForUpdate(addon.filename, addon.version);
});
Events.on(`${this.type}-unloaded`, addon => {
const index = this.pending.indexOf(addon.filename);
if (index >= 0) this.pending.splice(index, 1);
});
}
async updateCache() {
this.cache = {};
const addonData = await getJSON(route(this.type));
addonData.reduce(reducer, this.cache);
}
clearPending() {
this.pending.splice(0, this.pending.length);
}
checkAll(showNotice = true) {
for (const addon of this.manager.addonList) this.checkForUpdate(addon.filename, addon.version);
if (showNotice) this.showUpdateNotice();
}
checkForUpdate(filename, currentVersion) {
if (this.pending.includes(filename)) return;
const info = this.cache[path.basename(filename)];
if (!info) return;
const hasUpdate = info.version > currentVersion;
if (!hasUpdate) return;
this.pending.push(filename);
}
async updateAddon(filename) {
const info = this.cache[filename];
request(redirect(info.id), (error, _, body) => {
if (error) {
Logger.stacktrace("AddonUpdater", `Failed to download body for ${info.id}:`, error);
return;
}
const file = path.join(path.resolve(this.manager.addonFolder), filename);
fileSystem.writeFile(file, body.toString(), () => {
Toasts.success(`${info.name} has been updated to version ${info.version}!`);
this.pending.splice(this.pending.indexOf(filename), 1);
});
});
}
showUpdateNotice() {
if (!this.pending.length) return;
const close = Notices.info(`BetterDiscord has found updates for ${this.pending.length} of your ${this.type}s!`, {
buttons: [{
label: "More Info",
onClick: () => {
close();
UserSettingsWindow?.open?.("updates");
}
}]
});
}
}
export const PluginUpdater = new AddonUpdater("plugin");
export const ThemeUpdater = new AddonUpdater("theme");

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

@ -79,9 +79,11 @@ export class Filters {
*/
static byStrings(...strings) {
return module => {
if (!module?.toString || typeof(module?.toString) !== "function") return; // Not stringable
let moduleString = "";
try {moduleString = module.toString([]);}
catch (err) {moduleString = module.toString();}
try {moduleString = module?.toString([]);}
catch (err) {moduleString = module?.toString();}
if (!moduleString) return false; // Could not create string
for (const s of strings) {
if (!moduleString.includes(s)) return false;
}
@ -116,7 +118,23 @@ export class Filters {
const hasThrown = new WeakSet();
const wrapFilter = filter => (exports, module, moduleId) => {
try {
if (exports?.default?.remove && exports?.default?.set && exports?.default?.clear && exports?.default?.get && !exports?.default?.sort) return false;
if (exports.remove && exports.set && exports.clear && exports.get && !exports.sort) return false;
if (exports?.default?.getToken || exports?.default?.getEmail || exports?.default?.showToken) return false;
if (exports.getToken || exports.getEmail || exports.showToken) return false;
return filter(exports, module, moduleId);
}
catch (err) {
if (!hasThrown.has(filter)) Logger.warn("WebpackModules~getModule", "Module filter threw an exception.", filter, err);
hasThrown.add(filter);
return false;
}
};
export default class WebpackModules {
static find(filter, first = true) {return this.getModule(filter, {first});}
static findAll(filter) {return this.getModule(filter, {first: false});}
static findByUniqueProperties(props, first = true) {return first ? this.getByProps(...props) : this.getAllByProps(...props);}
@ -128,25 +146,12 @@ export default class WebpackModules {
* @param {object} [options] Set of options to customize 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.
* @param {Boolean} [options.searchExports=false] Whether to execute the filter on webpack export getters.
* @return {Any}
*/
static getModule(filter, options = {}) {
const {first = true, defaultExport = true, searchGetters = true} = options;
const wrappedFilter = (exports, module, moduleId) => {
try {
if (exports?.default?.remove && exports?.default?.set && exports?.default?.clear && exports?.default?.get && !exports?.default?.sort) return false;
if (exports.remove && exports.set && exports.clear && exports.get && !exports.sort) return false;
if (exports?.default?.getToken || exports?.default?.getEmail || exports?.default?.showToken) return false;
if (exports.getToken || exports.getEmail || exports.showToken) return false;
return filter(exports, module, moduleId);
}
catch (err) {
if (!hasThrown.has(filter)) Logger.warn("WebpackModules~getModule", "Module filter threw an exception.", filter, err);
hasThrown.add(filter);
return false;
}
};
const {first = true, defaultExport = true, searchExports = false} = options;
const wrappedFilter = wrapFilter(filter);
const modules = this.getAllModules();
const rm = [];
@ -156,35 +161,23 @@ export default class WebpackModules {
if (!modules.hasOwnProperty(index)) continue;
const module = modules[index];
const {exports} = module;
if (exports === window) continue;
let foundModule = null;
if (typeof(exports) === "object") {
const wrappers = Object.getOwnPropertyDescriptors(exports);
const getters = Object.keys(wrappers).filter(k => wrappers[k].get);
if (getters.length && searchGetters) {
for (const getter of getters) {
foundModule = null;
const wrappedExport = exports[getter];
if (!wrappedExport) continue;
if (wrappedExport.__esModule && wrappedExport.default && wrappedFilter(wrappedExport.default, module, index)) foundModule = defaultExport ? wrappedExport.default : wrappedExport;
if (wrappedFilter(wrappedExport, module, index)) foundModule = wrappedExport;
if (!foundModule) continue;
if (first) return foundModule;
rm.push(foundModule);
}
}
else {
if (!exports) continue;
if (exports.__esModule && exports.default && wrappedFilter(exports.default, module, index)) foundModule = defaultExport ? exports.default : exports;
if (wrappedFilter(exports, module, index)) foundModule = exports;
if (!exports || exports === window) continue;
if (typeof(exports) === "object" && searchExports) {
for (const key in exports) {
let foundModule = null;
const wrappedExport = exports[key];
if (!wrappedExport) continue;
if (wrappedFilter(wrappedExport, module, index)) foundModule = wrappedExport;
if (!foundModule) continue;
if (first) return foundModule;
rm.push(foundModule);
}
}
else {
if (!exports) continue;
let foundModule = null;
if (exports.Z && wrappedFilter(exports.Z, module, index)) foundModule = defaultExport ? exports.Z : exports;
if (exports.ZP && wrappedFilter(exports.ZP, module, index)) foundModule = defaultExport ? exports.ZP : exports;
if (exports.__esModule && exports.default && wrappedFilter(exports.default, module, index)) foundModule = defaultExport ? exports.default : exports;
if (wrappedFilter(exports, module, index)) foundModule = exports;
if (!foundModule) continue;
@ -205,7 +198,7 @@ export default class WebpackModules {
* @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.
* @param {Boolean} [queries.searchExports=false] Whether to execute the filter on webpack export getters.
* @return {Any}
*/
static getBulk(...queries) {
@ -221,47 +214,27 @@ export default class WebpackModules {
for (let q = 0; q < queries.length; q++) {
const query = queries[q];
const {filter, first = true, defaultExport = true, searchGetters = true} = query;
const {filter, first = true, defaultExport = true, searchExports = false} = query;
if (first && returnedModules[q]) continue; // If they only want the first, and we already found it, move on
if (!first && !returnedModules[q]) returnedModules[q] = []; // If they want multiple and we haven't setup the subarry, do it now
const wrappedFilter = (ex, mod, moduleId) => {
try {
return filter(ex, mod, moduleId);
}
catch (err) {
if (!hasThrown.has(filter)) Logger.warn("WebpackModules~getBulk", "Module filter threw an exception.", filter, err);
hasThrown.add(filter);
return false;
}
};
const wrappedFilter = wrapFilter(filter);
let foundModule = null;
if (typeof(exports) === "object") {
const wrappers = Object.getOwnPropertyDescriptors(exports);
const getters = Object.keys(wrappers).filter(k => wrappers[k].get);
if (getters.length && searchGetters) {
for (const getter of getters) {
foundModule = null;
const wrappedExport = exports[getter];
if (!wrappedExport) continue;
if (wrappedExport.__esModule && wrappedExport.default && wrappedFilter(wrappedExport.default, module, index)) foundModule = defaultExport ? wrappedExport.default : wrappedExport;
if (wrappedFilter(wrappedExport, module, index)) foundModule = wrappedExport;
if (!foundModule) continue;
if (first) returnedModules[q] = foundModule;
else returnedModules[q].push(foundModule);
}
}
else {
if (!exports) continue;
if (exports.__esModule && exports.default && wrappedFilter(exports.default, module, index)) foundModule = defaultExport ? exports.default : exports;
if (wrappedFilter(exports, module, index)) foundModule = exports;
if (typeof(exports) === "object" && searchExports) {
for (const key in exports) {
let foundModule = null;
const wrappedExport = exports[key];
if (!wrappedExport) continue;
if (wrappedFilter(wrappedExport, module, index)) foundModule = wrappedExport;
if (!foundModule) continue;
if (first) returnedModules[q] = foundModule;
else returnedModules[q].push(foundModule);
}
}
else {
let foundModule = null;
if (exports.Z && wrappedFilter(exports.Z, module, index)) foundModule = defaultExport ? exports.Z : exports;
if (exports.ZP && wrappedFilter(exports.ZP, module, index)) foundModule = defaultExport ? exports.ZP : exports;
if (exports.__esModule && exports.default && wrappedFilter(exports.default, module, index)) foundModule = defaultExport ? exports.default : exports;
if (wrappedFilter(exports, module, index)) foundModule = exports;
if (!foundModule) continue;
@ -359,24 +332,15 @@ export default class WebpackModules {
* @param {object} [options] Set of options to customize the search
* @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.
* @param {Boolean} [options.searchExports=false] Whether to execute the filter on webpack export getters.
* @returns {Promise<any>}
*/
static getLazy(filter, options = {}) {
const {signal: abortSignal, defaultExport = true, searchGetters = true} = options;
const fromCache = this.getModule(filter);
const {signal: abortSignal, defaultExport = true, searchExports = false} = options;
const fromCache = this.getModule(filter, {defaultExport, searchExports});
if (fromCache) return Promise.resolve(fromCache);
const wrappedFilter = (exports) => {
try {
return filter(exports);
}
catch (err) {
if (!hasThrown.has(filter)) Logger.warn("WebpackModules~getModule", "Module filter threw an exception.", filter, err);
hasThrown.add(filter);
return false;
}
};
const wrappedFilter = wrapFilter(filter);
return new Promise((resolve) => {
const cancel = () => this.removeListener(listener);
@ -384,26 +348,20 @@ export default class WebpackModules {
if (!exports) return;
let foundModule = null;
if (typeof(exports) === "object") {
const wrappers = Object.getOwnPropertyDescriptors(exports);
const getters = Object.keys(wrappers).filter(k => wrappers[k].get);
if (getters.length && searchGetters) {
for (const getter of getters) {
foundModule = null;
const wrappedExport = exports[getter];
if (!wrappedExport) continue;
if (wrappedExport.__esModule && wrappedExport.default && wrappedFilter(wrappedExport.default)) foundModule = defaultExport ? wrappedExport.default : wrappedExport;
if (wrappedFilter(wrappedExport)) foundModule = wrappedExport;
}
}
else {
if (exports.__esModule && exports.default && wrappedFilter(exports.default)) foundModule = defaultExport ? exports.default : exports;
if (wrappedFilter(exports)) foundModule = exports;
if (typeof(exports) === "object" && searchExports) {
for (const key in exports) {
foundModule = null;
const wrappedExport = exports[key];
if (!wrappedExport) continue;
if (wrappedFilter(wrappedExport)) foundModule = wrappedExport;
}
}
else {
if (exports.Z && wrappedFilter(exports.Z)) foundModule = defaultExport ? exports.Z : exports;
if (exports.ZP && wrappedFilter(exports.ZP)) foundModule = defaultExport ? exports.ZP : exports;
if (exports.__esModule && exports.default && wrappedFilter(exports.default)) foundModule = defaultExport ? exports.default : exports;
if (wrappedFilter(exports)) foundModule = exports;
}
if (!foundModule) return;
@ -523,4 +481,4 @@ export default class WebpackModules {
}
}
WebpackModules.initialize();
WebpackModules.initialize();

View File

@ -76,6 +76,20 @@ export const rmdirSync = function (path, options) {
Remote.filesystem.deleteDirectory(path, options);
};
export const rm = function (path, options, callback) {
try {
const result = Remote.filesystem.rm(path, options);
callback(null, result);
}
catch (error) {
callback(error, null);
}
};
export const rmSync = function (path, options) {
Remote.filesystem.rmSync(path, options);
};
export const exists = function (path, options, callback) {
try {
const result = Remote.filesystem.exists(path, options);
@ -161,6 +175,8 @@ export default {
realpathSync,
rename,
renameSync,
rm,
rmSync,
rmdir,
rmdirSync,
unlink,

View File

@ -31,7 +31,7 @@ export default class Module {
const ext = path.extname(file);
if (file === "package.json") {
const pkg = require(path.resolve(parent, file));
const pkg = __non_webpack_require__(path.resolve(parent, file));
if (!Reflect.has(pkg, "main")) continue;
return path.resolve(parent, pkg.main);

View File

@ -2,14 +2,14 @@ import Logger from "common/logger";
import {WebpackModules, IPC} from "modules";
const SortedGuildStore = WebpackModules.getByProps("getSortedGuilds");
const AvatarDefaults = WebpackModules.getByProps("getUserAvatarURL", "DEFAULT_AVATARS");
const AvatarDefaults = WebpackModules.getByProps("DEFAULT_AVATARS");
const InviteActions = WebpackModules.getByProps("acceptInvite");
// const BrowserWindow = require("electron").remote.BrowserWindow;
const betterDiscordServer = {
name: "BetterDiscord",
members: 55000,
members: 110000,
categories: ["community", "programming", "support"],
description: "Official BetterDiscord server for plugins, themes, support, etc",
identifier: "86004744966914048",

View File

@ -139,6 +139,10 @@
font-weight: 400;
}
.bd-setting-item:not(.inline) .bd-setting-note {
margin-bottom: 10px;
}
.bd-setting-divider {
width: 100%;
height: 1px;

View File

@ -0,0 +1,65 @@
.bd-color-picker-container {
display: flex;
}
.bd-color-picker-controls {
padding-left: 1px;
padding-top: 2px;
display: flex;
}
.bd-color-picker-default {
cursor: pointer;
width: 72px;
height: 54px;
border-radius: 4px;
margin-right: 9px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.bd-color-picker-custom {
position: relative;
display: inline-table;
}
.bd-color-picker-custom svg {
position: absolute;
top: 5px;
right: 5px;
}
.bd-color-picker {
outline: none;
width: 70px;
border: none;
height: 54px;
margin-top: 1px;
border-radius: 4px;
cursor: pointer;
}
.bd-color-picker::-webkit-color-swatch {
border: none;
}
.bd-color-picker-swatch {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
margin-left: 5px !important;
max-width: 340px;
}
.bd-color-picker-swatch-item {
cursor: pointer;
border-radius: 4px;
width: 23px;
height: 23px;
margin: 4px;
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -0,0 +1,54 @@
.bd-keybind-wrap {
position: relative;
min-width: 250px;
box-sizing: border-box;
border-radius: 3px;
background-color: hsla(0, calc(var(--saturation-factor, 1)*0%), 0%, .1);
border: 1px solid hsla(0, calc(var(--saturation-factor, 1)*0%), 0%, .3);
padding: 10px;
height: 40px;
cursor: pointer;
}
.bd-keybind-wrap input {
outline: none;
border: none;
pointer-events: none;
color: var(--text-normal);
background: none;
font-size: 16px;
text-transform: uppercase;
font-weight: 700;
}
.bd-keybind-wrap.recording {
border-color: hsla(359, calc(var(--saturation-factor, 1)*82.6%), 59.4%, .3);
}
.bd-keybind-wrap.recording {
box-shadow: 0 0 6px hsla(359, calc(var(--saturation-factor, 1)*82.6%), 59.4%, .3);
}
.bd-keybind-controls {
position: absolute;
right: 5px;
top: 3px;
display: flex;
align-items: center;
}
.bd-keybind-clear {
background: none!important;
opacity: 0.5;
padding-right: 4px!important;
}
.bd-keybind-clear:hover {
background: none;
opacity: 1;
}
.bd-keybind-clear svg {
width: 18px !important;
height: 18px !important;
}

View File

@ -0,0 +1,73 @@
.bd-modal-wrapper {
position: absolute;
z-index: 1000;
width: 100vw;
height: 100vh;
}
.bd-backdrop {
width: 100%;
height: 100%;
background: rgba(0,0,0, .6);
position: absolute;
}
.bd-modal {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 33%;
}
.bd-modal-inner {
background: var(--background-primary);
border-radius: 4px;
overflow: hidden;
animation: bd-modal-open ease-out;
animation-duration: 300ms;
}
.bd-modal-wrapper.closing .bd-modal-inner {
animation: bd-modal-close ease-in;
animation-duration: 300ms;
}
.bd-modal .footer {
display: flex;
justify-content: flex-end;
padding: 15px;
background: var(--background-secondary);
}
.bd-modal-body {
padding: 20px 15px;
padding-top: 0;
}
.bd-modal .header {
padding: 15px;
}
.bd-modal .title {
font-size: 22px;
color: #fff;
font-weight: 600;
}
.bd-modal-body {
color: #fff;
}
.bd-modal .footer .bd-button {
min-width: 80px;
height: 38px;
}
@keyframes bd-modal-close {
to {transform: scale(0.7);}
}
@keyframes bd-modal-open {
from {transform: scale(0.7);}
}

View File

@ -0,0 +1,55 @@
.bd-radio-group {
min-width: 300px;
}
.bd-radio-option {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
background-color: var(--background-secondary);
border-radius: 3px;
color: var(--interactive-normal);
}
.bd-radio-option:hover {
background-color: var(--background-modifier-hover);
}
.bd-radio-option.bd-radio-selected {
background-color: var(--background-modifier-selected);
color: var(--interactive-active);
}
.bd-radio-option input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.bd-radio-icon {
margin-right: 10px;
}
.bd-radio-label-wrap {
display: flex;
flex-direction: column;
}
.bd-radio-label {
font-family: var(--font-primary);
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
.bd-radio-description {
font-family: var(--font-primary);
font-size: 14px;
line-height: 18px;
font-weight: 400;
}

View File

@ -0,0 +1,42 @@
.bd-slider-wrap {
display: flex;
color: var(--text-normal);
align-items: center;
}
.bd-slider-label {
background: var(--brand-experiment);
font-weight: 700;
padding: 5px;
margin-right: 10px;
border-radius: 5px;
}
.bd-slider-input {
/* -webkit-appearance: none; */
height: 8px;
border-radius: 4px;
appearance: none;
min-width: 350px;
border-radius: 5px;
background: hsl(217,calc(var(--saturation-factor, 1)*7.6%),33.5%);
outline: none;
transition: opacity .2s;
background-image: linear-gradient(var(--brand-experiment), var(--brand-experiment));
background-size: 70% 100%;
background-repeat: no-repeat;
}
/* The slider handle (use -webkit- (Chrome, Opera, Safari, Edge) and -moz- (Firefox) to override default look) */
.bd-slider-input::-webkit-slider-thumb {
appearance: none;
width: 10px;
height: 24px;
top: 50%;
border-radius: 3px;
background-color: hsl(0,calc(var(--saturation-factor, 1)*0%),100%);
border: 1px solid hsl(210,calc(var(--saturation-factor, 1)*2.9%),86.7%);
-webkit-box-shadow: 0 3px 1px 0 hsla(0,calc(var(--saturation-factor, 1)*0%),0%,.05),0 2px 2px 0 hsla(0,calc(var(--saturation-factor, 1)*0%),0%,.1),0 3px 3px 0 hsla(0,calc(var(--saturation-factor, 1)*0%),0%,.05);
box-shadow: 0 3px 1px 0 hsla(0,calc(var(--saturation-factor, 1)*0%),0%,.05),0 2px 2px 0 hsla(0,calc(var(--saturation-factor, 1)*0%),0%,.1),0 3px 3px 0 hsla(0,calc(var(--saturation-factor, 1)*0%),0%,.05);
cursor: ew-resize;
}

View File

@ -0,0 +1,11 @@
.bd-text-input {
min-width: 250px;
font-size: 16px;
box-sizing: border-box;
border-radius: 3px;
color: var(--text-normal);
background-color: var(--input-background);
border: none;
padding: 10px;
height: 40px;
}

View File

@ -0,0 +1,104 @@
.bd-layer {
position: absolute;
}
.bd-tooltip {
position: relative;
border-radius: 5px;
font-weight: 500;
font-size: 14px;
line-height: 16px;
max-width: 190px;
box-sizing: border-box;
word-wrap: break-word;
z-index: 1002;
will-change: opacity, transform;
box-shadow: var(--elevation-high);
color: var(--header-primary);
}
.bd-tooltip-content {
padding: 8px 12px;
overflow: hidden;
}
.bd-tooltip-pointer {
pointer-events: none;
width: 0;
height: 0;
border: 5px solid transparent;
}
.bd-tooltip-primary {
background-color: var(--background-floating);
color: var(--text-normal);
}
.bd-tooltip-primary .bd-tooltip-pointer {
border-top-color: var(--background-floating);
}
.bd-tooltip-info {
background-color: #4A90E2;
}
.bd-tooltip-info .bd-tooltip-pointer {
border-top-color: #4A90E2;
}
.bd-tooltip-success {
background-color: #43B581;
}
.bd-tooltip-success .bd-tooltip-pointer {
border-top-color: #43B581;
}
.bd-tooltip-danger {
background-color: #F04747;
}
.bd-tooltip-danger .bd-tooltip-pointer {
border-top-color: #F04747;
}
.bd-tooltip-warn {
background-color: #FFA600;
}
.bd-tooltip-warn .bd-tooltip-pointer {
border-top-color: #FFA600;
}
.bd-tooltip-top .bd-tooltip-pointer {
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
}
.bd-tooltip-bottom .bd-tooltip-pointer {
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -5px;
transform: rotate(180deg);
}
.bd-tooltip-right .bd-tooltip-pointer {
position: absolute;
right: 100%;
top: 50%;
margin-top: -5px;
border-left-width: 5px;
transform: rotate(90deg);
}
.bd-tooltip-left .bd-tooltip-pointer {
position: absolute;
left: 100%;
top: 50%;
margin-top: -5px;
border-left-width: 5px;
transform: rotate(270deg);
}

View File

@ -0,0 +1,23 @@
.bd-filled-checkmark {
background: #43B581;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
padding: 3px;
}
.bd-empty-updates {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--header-primary);
font-weight: 500;
font-size: 16px;
}
.bd-empty-updates svg {
fill: #43B581;
margin-bottom: 20px;
}

View File

@ -6,7 +6,7 @@ export default class NoResults extends React.Component {
return <div className={"bd-empty-results" + (this.props.className ? ` ${this.props.className}` : "")}>
<MagnifyingGlass />
<div className="bd-empty-results-text">
{DiscordModules.Strings.SEARCH_NO_RESULTS || ""}
{this.props.text || DiscordModules.Strings.SEARCH_NO_RESULTS || ""}
</div>
</div>;
}

View File

@ -1,26 +1,27 @@
import Logger from "common/logger";
import {React, IPC} from "modules";
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {hasError: false};
}
componentDidCatch() {
this.setState({hasError: true});
}
render() {
if (this.state.hasError) return <div onClick={() => IPC.openDevTools()} className="react-error">There was an unexpected Error. Click to open console for more details.</div>;
return this.props.children;
}
}
const originalRender = ErrorBoundary.prototype.render;
Object.defineProperty(ErrorBoundary.prototype, "render", {
enumerable: false,
configurable: false,
set: function() {Logger.warn("ErrorBoundary", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");},
get: () => originalRender
});
import Logger from "common/logger";
import {React, IPC} from "modules";
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {hasError: false};
}
componentDidCatch(error) {
this.setState({hasError: true});
if (typeof this.props.onError === "function") this.props.onError(error);
}
render() {
if (this.state.hasError) return <div onClick={() => IPC.openDevTools()} className="react-error">There was an unexpected Error. Click to open console for more details.</div>;
return this.props.children;
}
}
const originalRender = ErrorBoundary.prototype.render;
Object.defineProperty(ErrorBoundary.prototype, "render", {
enumerable: false,
configurable: false,
set: function() {Logger.warn("ErrorBoundary", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");},
get: () => originalRender
});

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

@ -0,0 +1,11 @@
import {React} from "modules";
export default class Checkmark extends React.Component {
render() {
const size = this.props.size || "24px";
return <svg viewBox="0 0 24 24" fill="#FFFFFF" className={this.props.className || ""} style={{width: size, height: size}} onClick={this.props.onClick}>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" />
</svg>;
}
}

View File

@ -2,7 +2,8 @@ import {React} from "modules";
export default class CloseButton extends React.Component {
render() {
return <svg viewBox="0 0 12 12" style={{width: "18px", height: "18px"}}>
const size = this.props.size || "18px";
return <svg viewBox="0 0 12 12" style={{width: size, height: size}}>
<g className="background" fill="none" fillRule="evenodd">
<path d="M0 0h12v12H0" />
<path className="fill" fill="#dcddde" d="M9.5 3.205L8.795 2.5 6 5.295 3.205 2.5l-.705.705L5.295 6 2.5 8.795l.705.705L6 6.705 8.795 9.5l.705-.705L6.705 6" />

View File

@ -0,0 +1,11 @@
import {React} from "modules";
export default class Keyboard extends React.Component {
render() {
const size = this.props.size || "24px";
return <svg className={this.props.className} viewBox="0 0 24 24" fill="#FFFFFF" style={{width: size, height: size}}>
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z" />
<path fill="none" d="M0 0h24v24H0zm0 0h24v24H0z" />
</svg>;
}
}

View File

@ -0,0 +1,12 @@
import {React} from "modules";
export default class Radio extends React.Component {
render() {
const size = this.props.size || "24px";
return <svg className={this.props.className} viewBox="0 0 24 24" fill="#FFFFFF" style={{width: size, height: size}}>
<path fill="none" d="M0 0h24v24H0z" />
{this.props.checked && <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" />}
{!this.props.checked && <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" />}
</svg>;
}
}

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, ReactDOM, Settings, Strings, DOMManager, DiscordModules, DiscordClasses} from "modules";
import FormattableString from "../structs/string";
import AddonErrorModal from "./addonerrormodal";
import ErrorBoundary from "./errorboundary";
@ -11,24 +11,39 @@ export default class Modals {
static get shouldShowAddonErrors() {return Settings.get("settings", "addons", "addonErrors");}
static get ModalActions() {
return {
openModal: WebpackModules.getModule(m => m?.toString().includes("onCloseCallback") && m?.toString().includes("Layer")),
closeModal: WebpackModules.getModule(m => m?.toString().includes("onCloseCallback()"))
return this._ModalActions ??= {
openModal: WebpackModules.getModule(m => m?.toString().includes("onCloseCallback") && m?.toString().includes("Layer"), {searchExports: true}),
closeModal: WebpackModules.getModule(m => m?.toString().includes("onCloseCallback()"), {searchExports: true})
};
}
static get ModalStack() {return WebpackModules.getByProps("push", "update", "pop", "popWithKey");}
static get ModalComponents() {return WebpackModules.getByProps("Header", "Footer");}
static get ModalRoot() {return WebpackModules.getModule(m => m?.toString().includes("ENTERING"));}
static get ModalClasses() {return WebpackModules.getByProps("modal", "content");}
static get FlexElements() {return WebpackModules.getByProps("Child", "Align");}
static get FormTitle() {return WebpackModules.getByProps("Tags", "Sizes");}
static get TextElement() {return WebpackModules.getModule(m => m?.Sizes?.SIZE_32 && m.Colors);}
static get ConfirmationModal() {return WebpackModules.getModule(m => m?.toString()?.includes("confirmText"));}
static get Markdown() {return WebpackModules.find(m => m?.prototype?.render && m.rules);}
static get Buttons() {return WebpackModules.getByProps("BorderColors");}
static get ModalStack() {return this._ModalStack ??= WebpackModules.getByProps("push", "update", "pop", "popWithKey");}
static get ModalComponents() {return this._ModalComponents ??= WebpackModules.getByProps("Header", "Footer");}
static get ModalRoot() {return this._ModalRoot ??= WebpackModules.getModule(m => m?.toString?.()?.includes("ENTERING"), {searchExports: true});}
static get ModalClasses() {return this._ModalClasses ??= WebpackModules.getByProps("modal", "content");}
static get FlexElements() {return this._FlexElements ??= WebpackModules.getByProps("Child", "Align");}
static get FormTitle() {return this._FormTitle ??= WebpackModules.getByProps("Tags", "Sizes");}
static get TextElement() {return this._TextElement ??= WebpackModules.getModule(m => m?.Sizes?.SIZE_32 && m.Colors);}
static get ConfirmationModal() {return this._ConfirmationModal ??= WebpackModules.getModule(m => m?.toString?.()?.includes(".confirmButtonColor"));}
static get Markdown() {return this._Markdown ??= WebpackModules.find(m => m?.prototype?.render && m.rules);}
static get Buttons() {return this._Buttons ??= WebpackModules.getModule(m => m.BorderColors, {searchExports: true});}
static get ModalQueue() {return this._ModalQueue ??= [];}
static get hasModalOpen() {return !!document.getElementsByClassName("bd-modal").length;}
static default(title, content) {
const modal = DOM.createElement(`<div class="bd-modal-wrapper theme-dark">
static async initialize() {
const names = ["ModalActions", "Markdown", "ModalRoot", "ModalComponents", "Buttons", "TextElement", "FlexElements"];
for (const name of names) {
const value = this[name];
if (!value) {
Logger.warn("Modals", `Missing ${name} module!`);
}
}
}
static default(title, content, buttons = []) {
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">
@ -37,26 +52,77 @@ export default class Modals {
</div>
<div class="bd-modal-body">
<div class="scroller-wrap fade">
<div class="scroller">
${content}
</div>
<div class="scroller"></div>
</div>
</div>
<div class="footer footer-2yfCgX footer-3rDWdC footer-2gL1pp">
<button type="button" class="bd-button">${Strings.Modals.okay}</button>
</div>
<div class="footer footer-2yfCgX footer-3rDWdC footer-2gL1pp"></div>
</div>
</div>
</div>`);
modal.querySelector(".footer button").addEventListener("click", () => {
const handleClose = () => {
modal.classList.add("closing");
setTimeout(() => {modal.remove();}, 300);
});
modal.querySelector(".bd-backdrop").addEventListener("click", () => {
modal.classList.add("closing");
setTimeout(() => {modal.remove();}, 300);
});
document.querySelector("#app-mount").append(modal);
setTimeout(() => {
modal.remove();
const next = this.ModalQueue.shift();
if (!next) return;
next();
}, 300);
};
if (!buttons.length) {
buttons.push({
label: Strings.Modals.okay,
action: handleClose
});
}
const buttonContainer = modal.querySelector(".footer");
for (const button of buttons) {
const buttonEl = Object.assign(document.createElement("button"), {
onclick: (e) => {
try {button.action(e);} catch (error) {console.error(error);}
handleClose();
},
type: "button",
className: "bd-button"
});
if (button.danger) buttonEl.classList.add("bd-button-danger")
buttonEl.append(button.label);
buttonContainer.appendChild(buttonEl);
}
if (Array.isArray(content) ? content.every(el => React.isValidElement(el)) : React.isValidElement(content)) {
const container = modal.querySelector(".scroller");
try {
ReactDOM.render(content, container);
} catch (error) {
container.append(DOMManager.parseHTML(`<span style="color: red">There was an unexpected error. Modal could not be rendered.</span>`));
}
DOMManager.onRemoved(container, () => {
ReactDOM.unmountComponentAtNode(container);
});
} else {
modal.querySelector(".scroller").append(content);
}
modal.querySelector(".footer button").addEventListener("click", handleClose);
modal.querySelector(".bd-backdrop").addEventListener("click", handleClose);
const handleOpen = () => document.getElementById("app-mount").append(modal);
if (this.hasModalOpen) {
this.ModalQueue.push(handleOpen);
} else {
handleOpen();
}
}
static alert(title, content) {
@ -80,25 +146,41 @@ export default class Modals {
const Markdown = this.Markdown;
const ConfirmationModal = this.ConfirmationModal;
const ModalActions = this.ModalActions;
if (content instanceof FormattableString) content = content.toString();
if (!this.ModalActions || !this.ConfirmationModal || !this.Markdown) return this.default(title, content);
const emptyFunction = () => {};
const {onConfirm = emptyFunction, onCancel = emptyFunction, confirmText = Strings.Modals.okay, cancelText = Strings.Modals.cancel, danger = false, key = undefined} = options;
if (!this.ModalActions || !this.ConfirmationModal || !this.Markdown) return this.default(title, content, [
confirmText && {label: confirmText, action: onConfirm},
cancelText && {label: cancelText, action: onCancel, danger}
].filter(Boolean));
if (!Array.isArray(content)) content = [content];
content = content.map(c => typeof(c) === "string" ? React.createElement(Markdown, null, c) : c);
return ModalActions.openModal(props => {
return React.createElement(ConfirmationModal, Object.assign({
let modalKey = ModalActions.openModal(props => {
return React.createElement(ErrorBoundary, {
onError: () => {
setTimeout(() => {
ModalActions.closeModal(modalKey);
this.default(title, content, [
confirmText && {label: confirmText, action: onConfirm},
cancelText && {label: cancelText, action: onCancel, danger}
].filter(Boolean));
});
}
}, React.createElement(ConfirmationModal, Object.assign({
header: title,
confirmButtonColor: danger ? this.Buttons.Colors.RED : this.Buttons.Colors.BRAND,
confirmText: confirmText,
cancelText: cancelText,
onConfirm: onConfirm,
onCancel: onCancel
}, props), content);
}, props), React.createElement(ErrorBoundary, {}, content)));
}, {modalKey: key});
return modalKey;
}
static showAddonErrors({plugins: pluginErrors = [], themes: themeErrors = []}) {
@ -110,7 +192,7 @@ export default class Modals {
}
this.addonErrorsRef = React.createRef();
this.ModalActions.openModal(props => React.createElement(this.ModalRoot, Object.assign(props, {
this.ModalActions.openModal(props => React.createElement(ErrorBoundary, null, React.createElement(this.ModalRoot, Object.assign(props, {
size: "medium",
className: "bd-error-modal",
children: [
@ -127,18 +209,19 @@ export default class Modals {
className: "bd-button"
}, Strings.Modals.okay))
]
})));
}))));
}
static showChangelogModal(options = {}) {
const ModalStack = WebpackModules.getByProps("push", "update", "pop", "popWithKey");
const OriginalModalClasses = WebpackModules.getByProps("hideOnFullscreen", "root");
const ChangelogModalClasses = WebpackModules.getModule(m => m.modal && m.maxModalWidth);
const ChangelogClasses = WebpackModules.getByProps("fixed", "improved");
const TextElement = WebpackModules.getByDisplayName("LegacyText");
const FlexChild = WebpackModules.getByProps("Child");
const Titles = WebpackModules.getByProps("Tags", "default");
const Changelog = WebpackModules.getModule(m => m.defaultProps && m.defaultProps.selectable == false);
const TextElement = this.TextElement;
const FlexChild = this.FlexElements;
const Titles = this.FormTitle;
const MarkdownParser = WebpackModules.getByProps("defaultRules", "parse");
if (!Changelog || !ModalStack || !ChangelogClasses || !TextElement || !FlexChild || !Titles || !MarkdownParser) return Logger.warn("Modals", "showChangelogModal missing modules");
if (!OriginalModalClasses || !ChangelogModalClasses || !ChangelogClasses || !TextElement || !FlexChild || !Titles || !MarkdownParser) return Logger.warn("Modals", "showChangelogModal missing modules");
const {image = "https://i.imgur.com/wuh5yMK.png", description = "", changes = [], title = "BetterDiscord", subtitle = `v${Config.version}`, footer} = options;
const ce = React.createElement;
@ -154,47 +237,38 @@ export default class Modals {
changelogItems.push(list);
}
const renderHeader = function() {
return ce(FlexChild.Child, {grow: 1, shrink: 1},
ce(Titles.default, {tag: Titles.Tags.H4}, title),
ce(TextElement, {size: TextElement.Sizes.SMALL, color: TextElement.Colors.STANDARD, className: ChangelogClasses.date}, subtitle)
return ce(FlexChild, {className: OriginalModalClasses.header, grow: 0, shrink: 0, direction: FlexChild.Direction.VERTICAL},
ce(Titles, {tag: Titles.Tags.H1, size: TextElement.Sizes.SIZE_20}, title),
ce(TextElement, {size: TextElement.Sizes.SIZE_12, color: TextElement.Colors.STANDARD, className: ChangelogClasses.date}, subtitle)
);
};
const renderFooter = () => {
const Anchor = WebpackModules.getModule(m => m.displayName == "Anchor");
const AnchorClasses = WebpackModules.getByProps("anchorUnderlineOnHover") || {anchor: "anchor-3Z-8Bb", anchorUnderlineOnHover: "anchorUnderlineOnHover-2ESHQB"};
const joinSupportServer = (click) => {
click.preventDefault();
click.stopPropagation();
ModalStack.pop();
DiscordModules.InviteActions.acceptInviteAndTransitionToInviteChannel("0Tmfo5ZbORCRqbAd");
};
const supportLink = Anchor ? ce(Anchor, {onClick: joinSupportServer}, "Join our Discord Server.") : ce("a", {className: `${AnchorClasses.anchor} ${AnchorClasses.anchorUnderlineOnHover}`, onClick: joinSupportServer}, "Join our Discord Server.");
const defaultFooter = ce(TextElement, {size: TextElement.Sizes.SMALL, color: TextElement.Colors.STANDARD}, "Need support? ", supportLink);
return ce(FlexChild.Child, {grow: 1, shrink: 1}, footer ? footer : defaultFooter);
const supportLink = ce("a", {className: `${AnchorClasses.anchor} ${AnchorClasses.anchorUnderlineOnHover}`, onClick: joinSupportServer}, "Join our Discord Server.");
const defaultFooter = ce(TextElement, {size: TextElement.Sizes.SIZE_12, color: TextElement.Colors.STANDARD}, "Need support? ", supportLink);
return ce(FlexChild, {className: OriginalModalClasses.footer + " " + OriginalModalClasses.footerSeparator},
ce(FlexChild.Child, {grow: 1, shrink: 1}, footer ? footer : defaultFooter)
);
};
const ModalActions = this.ModalActions;
const OriginalModalClasses = WebpackModules.getByProps("hideOnFullscreen", "root");
const originalRoot = OriginalModalClasses.root;
if (originalRoot) OriginalModalClasses.root = `${originalRoot} bd-changelog-modal`;
const key = ModalActions.openModal(props => {
return React.createElement(Changelog, Object.assign({
className: `bd-changelog ${ChangelogClasses.container}`,
const body = ce("div", {
className: `${OriginalModalClasses.content} ${ChangelogClasses.container} ${ChangelogModalClasses.content} ${DiscordClasses.Scrollers.thin}`
}, changelogItems);
const key = this.ModalActions.openModal(props => {
return React.createElement(ErrorBoundary, null, React.createElement(this.ModalRoot, Object.assign({
className: `bd-changelog-modal ${OriginalModalClasses.root} ${OriginalModalClasses.small} ${ChangelogModalClasses.modal}`,
selectable: true,
onScroll: _ => _,
onClose: _ => _,
renderHeader: renderHeader,
renderFooter: renderFooter,
}, props), changelogItems);
}, props), renderHeader(), body, renderFooter()));
});
const closeModal = ModalActions.closeModal;
ModalActions.closeModal = function(k) {
Reflect.apply(closeModal, this, arguments);
setTimeout(() => {if (originalRoot && k === key) OriginalModalClasses.root = originalRoot;}, 1000);
ModalActions.closeModal = closeModal;
};
return key;
}
@ -241,7 +315,7 @@ export default class Modals {
};
return this.ModalActions.openModal(props => {
return React.createElement(modal, props);
return React.createElement(ErrorBoundary, null, React.createElement(modal, props));
});
}
}
}

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

@ -83,7 +83,7 @@ export default new class SettingsRenderer {
element: () => this.buildSettingsPanel(collection.id, collection.name, collection.settings, Settings.state[collection.id], Settings.onSettingChange.bind(Settings, collection.id), collection.button ? collection.button : null)
});
}
for (const panel of Settings.panels.sort((a,b) => a.order > b.order)) {
for (const panel of Settings.panels.sort((a,b) => a.order > b.order ? 1 : -1)) {
if (panel.clickListener) panel.onClick = (event) => panel.clickListener(thisObject, event, returnValue);
if (!panel.className) panel.className = `bd-${panel.id}-tab`;
if (typeof(panel.label) !== "string") panel.label = panel.label.toString();
@ -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

@ -171,7 +171,7 @@ export default class AddonCard extends React.Component {
const description = this.getString(addon.description);
const version = this.getString(addon.version);
return <div id={`${addon.id}-card`} className="bd-addon-card settings-closed">
return <div id={`${addon.id}-card`} className={"bd-addon-card" + (this.props.disabled ? " bd-addon-card-disabled" : "")}>
<div className="bd-addon-header">
{this.props.type === "plugin" ? <ExtIcon size="18px" className="bd-icon" /> : <ThemeIcon size="18px" className="bd-icon" />}
<div className="bd-title">{this.buildTitle(name, version, {name: author, id: this.props.addon.authorId, link: this.props.addon.authorLink})}</div>

View File

@ -0,0 +1,109 @@
import {React, WebpackModules} from "modules";
const TooltipWrapper = WebpackModules.getByPrototypes("renderTooltip");
const Checkmark = React.memo((props) => (
<svg width="16" height="16" viewBox="0 0 24 24" {...props}>
<path fillRule="evenodd" clipRule="evenodd" fill={props.color ?? "#fff"} d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7.00003L19.5899 5.59003L8.99991 16.17Z" />
</svg>
));
const Dropper = React.memo((props) => (
<svg width="14" height="14" viewBox="0 0 16 16" {...props}>
<g fill="none">
<path d="M-4-4h24v24H-4z"/>
<path fill={props.color ?? "#fff"} d="M14.994 1.006C13.858-.257 11.904-.3 10.72.89L8.637 2.975l-.696-.697-1.387 1.388 5.557 5.557 1.387-1.388-.697-.697 1.964-1.964c1.13-1.13 1.3-2.985.23-4.168zm-13.25 10.25c-.225.224-.408.48-.55.764L.02 14.37l1.39 1.39 2.35-1.174c.283-.14.54-.33.765-.55l4.808-4.808-2.776-2.776-4.813 4.803z" />
</g>
</svg>
));
const defaultColors = [1752220, 3066993, 3447003, 10181046, 15277667, 15844367, 15105570, 15158332, 9807270, 6323595, 1146986, 2067276, 2123412, 7419530, 11342935, 12745742, 11027200, 10038562, 9936031, 5533306];
const resolveColor = (color, hex = true) => {
switch (typeof color) {
case (hex && "number"): return `#${color.toString(16)}`;
case (!hex && "string"): return Number.parseInt(color.replace("#", ""), 16);
case (!hex && "number"): return color;
case (hex && "string"): return color;
default: return color;
}
};
const getRGB = (color) => {
let result = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(color);
if (result) return [parseInt(result[1]), parseInt(result[2]), parseInt(result[3])];
result = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)%\s*,\s*([0-9]+(?:\.[0-9]+)?)%\s*,\s*([0-9]+(?:\.[0-9]+)?)%\s*\)/.exec(color);
if (result) return [parseFloat(result[1]) * 2.55, parseFloat(result[2]) * 2.55, parseFloat(result[3]) * 2.55];
result = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(color);
if (result) return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)];
result = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(color);
if (result) return [parseInt(result[1] + result[1], 16), parseInt(result[2] + result[2], 16), parseInt(result[3] + result[3], 16)];
};
const luma = (color) => {
const rgb = (typeof(color) === "string") ? getRGB(color) : color;
return (0.2126 * rgb[0]) + (0.7152 * rgb[1]) + (0.0722 * rgb[2]); // SMPTE C, Rec. 709 weightings
};
const getContrastColor = (color) => {
return (luma(color) >= 165) ? "#000" : "#fff";
};
export default class Color extends React.Component {
constructor(props) {
super(props);
this.state = {value: this.props.value};
this.onChange = this.onChange.bind(this);
}
onChange(e) {
this.setState({value: e.target.value});
if (this.props.onChange) this.props.onChange(resolveColor(e.target.value));
}
render() {
const intValue = resolveColor(this.state.value, false);
const {colors = defaultColors, defaultValue} = this.props;
return <div className="bd-color-picker-container">
<div className="bd-color-picker-controls">
<TooltipWrapper text="Default" position="bottom">
{props => (
<div {...props} className="bd-color-picker-default" style={{backgroundColor: resolveColor(defaultValue)}} onClick={() => this.onChange({target: {value: defaultValue}})}>
{intValue === resolveColor(defaultValue, false)
? <Checkmark width="25" height="25" />
: null
}
</div>
)}
</TooltipWrapper>
<TooltipWrapper text="Custom Color" position="bottom">
{props => (
<div className="bd-color-picker-custom">
<Dropper color={getContrastColor(resolveColor(this.state.value, true))} />
<input {...props} style={{backgroundColor: resolveColor(this.state.value)}} type="color" className="bd-color-picker" value={resolveColor(this.state.value)} onChange={this.onChange} />
</div>
)}
</TooltipWrapper>
</div>
<div className="bd-color-picker-swatch">
{
colors.map((int, index) => (
<div key={index} className="bd-color-picker-swatch-item" style={{backgroundColor: resolveColor(int)}} onClick={() => this.onChange({target: {value: int}})}>
{intValue === int
? <Checkmark color={getContrastColor(resolveColor(this.state.value, true))} />
: null
}
</div>
))
}
</div>
</div>;
}
}

View File

@ -2,12 +2,13 @@ import {React} from "modules";
export default class SettingItem extends React.Component {
render() {
return <div className={"bd-setting-item"}>
return <div className={"bd-setting-item" + (this.props.inline ? " inline" : "")}>
<div className={"bd-setting-header"}>
<label htmlFor={this.props.id} className={"bd-setting-title"}>{this.props.name}</label>
{this.props.children}
{this.props.inline && this.props.children}
</div>
<div className={"bd-setting-note"}>{this.props.note}</div>
{!this.props.inline && this.props.children}
<div className={"bd-setting-divider"} />
</div>;
}

View File

@ -0,0 +1,80 @@
import {React} from "modules";
import Keyboard from "../../icons/keyboard";
import Close from "../../icons/close";
export default class Keybind extends React.Component {
constructor(props) {
super(props);
this.state = {value: this.props.value, isRecording: false};
this.onClick = this.onClick.bind(this);
this.keyHandler = this.keyHandler.bind(this);
this.clearKeybind = this.clearKeybind.bind(this);
this.accum = [];
this.max = this.props.max ?? 2;
}
componentDidMount() {
window.addEventListener("keydown", this.keyHandler);
}
componentWillUnmount() {
window.removeEventListener("keydown", this.keyHandler);
}
/**
*
* @param {KeyboardEvent} event
*/
keyHandler(event) {
if (!this.state.isRecording) return;
event.stopImmediatePropagation();
event.stopPropagation();
event.preventDefault();
if (event.repeat || this.accum.includes(event.key)) return;
this.accum.push(event.key);
if (this.accum.length == this.max) {
if (this.props.onChange) this.props.onChange(this.accum);
this.setState({value: this.accum.slice(0), isRecording: false}, () => this.accum.splice(0, this.accum.length));
}
}
/**
*
* @param {MouseEvent} e
*/
onClick(e) {
if (e.target?.className?.includes?.("bd-keybind-clear") || e.target?.closest(".bd-button")?.className?.includes("bd-keybind-clear")) return this.clearKeybind(e);
this.setState({isRecording: !this.state.isRecording});
}
/**
*
* @param {MouseEvent} event
*/
clearKeybind(event) {
event.stopPropagation();
event.preventDefault();
this.accum.splice(0, this.accum.length);
if (this.props.onChange) this.props.onChange(this.accum);
this.setState({value: this.accum, isRecording: false});
}
display() {
if (this.state.isRecording) return "Recording...";
if (!this.state.value.length) return "N/A";
return this.state.value.join(" + ");
}
render() {
const {clearable = true} = this.props;
return <div className={"bd-keybind-wrap" + (this.state.isRecording ? " recording" : "")} onClick={this.onClick}>
<input readOnly={true} type="text" className="bd-keybind-input" value={this.display()} />
<div className="bd-keybind-controls">
<button className={"bd-button bd-keybind-record" + (this.state.isRecording ? " bd-button-danger" : "")}><Keyboard size="24px" /></button>
{clearable && <button onClick={this.clearKeybind} className="bd-button bd-keybind-clear"><Close size="24px" /></button>}
</div>
</div>;
}
}

View File

@ -0,0 +1,44 @@
import {React} from "modules";
import RadioIcon from "../../icons/radio";
export default class Radio extends React.Component {
constructor(props) {
super(props);
this.state = {value: this.props.options.findIndex(o => o.value === this.props.value)};
this.onChange = this.onChange.bind(this);
this.renderOption = this.renderOption.bind(this);
}
onChange(e) {
const index = parseInt(e.target.value);
const newValue = this.props.options[index].value;
this.setState({value: index});
if (this.props.onChange) this.props.onChange(newValue);
}
renderOption(opt, index) {
const isSelected = this.state.value === index;
return <label className={"bd-radio-option" + (isSelected ? " bd-radio-selected" : "")}>
<input onChange={this.onChange} type="radio" name={this.props.name} checked={isSelected} value={index} />
{/* <span className="bd-radio-button"></span> */}
<RadioIcon className="bd-radio-icon" size="24" checked={isSelected} />
<div className="bd-radio-label-wrap">
<div className="bd-radio-label">{opt.name}</div>
<div className="bd-radio-description">{opt.desc || opt.description}</div>
</div>
</label>;
}
render() {
return <div className="bd-radio-group">
{this.props.options.map(this.renderOption)}
</div>;
}
}
/* <label class="container">
<input type="radio" name="test" checked="checked">
<span class="checkmark"></span>
<div class="test">One<div class="desc">Description</div></div>
</label> */

View File

@ -0,0 +1,21 @@
import {React} from "modules";
export default class Slider extends React.Component {
constructor(props) {
super(props);
this.state = {value: this.props.value};
this.onChange = this.onChange.bind(this);
}
onChange(e) {
this.setState({value: e.target.value});
// e.target.style.backgroundSize = (e.target.value - this.props.min) * 100 / (this.props.max - this.props.min) + "% 100%";
if (this.props.onChange) this.props.onChange(e.target.value);
}
render() {
return <div className="bd-slider-wrap">
<div className="bd-slider-label">{this.state.value}</div><input onChange={this.onChange} type="range" className="bd-slider-input" min={this.props.min} max={this.props.max} step={this.props.step} value={this.state.value} style={{backgroundSize: (this.state.value - this.props.min) * 100 / (this.props.max - this.props.min) + "% 100%"}} />
</div>;
}
}

View File

@ -0,0 +1,18 @@
import {React} from "modules";
export default class Textbox extends React.Component {
constructor(props) {
super(props);
this.state = {value: this.props.value};
this.onChange = this.onChange.bind(this);
}
onChange(e) {
this.setState({value: e.target.value});
if (this.props.onChange) this.props.onChange(e.target.value);
}
render() {
return <input onChange={this.onChange} onKeyDown={this.props.onKeyDown} type="text" className="bd-text-input" placeholder={this.props.placeholder} maxLength={this.props.maxLength} value={this.state.value} />;
}
}

View File

@ -0,0 +1,53 @@
import {React} from "modules";
import Title from "./title";
import Divider from "../divider";
const baseClassName = "bd-settings-group";
export default class Drawer extends React.Component {
constructor(props) {
super(props);
if (this.props.button && this.props.collapsible) {
const original = this.props.button.onClick;
this.props.button.onClick = (event) => {
event.stopPropagation();
original(...arguments);
};
}
if (!this.props.hasOwnProperty("shown")) this.props.shown = true;
this.container = React.createRef();
this.state = {
collapsed: this.props.collapsible && !this.props.shown
};
this.toggleCollapse = this.toggleCollapse.bind(this);
}
toggleCollapse() {
const container = this.container.current;
const timeout = this.state.collapsed ? 300 : 1;
container.style.setProperty("height", container.scrollHeight + "px");
container.classList.add("animating");
this.setState({collapsed: !this.state.collapsed}, () => setTimeout(() => {
container.style.setProperty("height", "");
container.classList.remove("animating");
}, timeout));
if (this.props.onDrawerToggle) this.props.onDrawerToggle(this.state.collapsed);
}
render() {
const collapseClass = this.props.collapsible ? `collapsible ${this.state.collapsed ? "collapsed" : "expanded"}` : "";
const groupClass = `${baseClassName} ${collapseClass}`;
return <div className={groupClass}>
<Title text={this.props.name} collapsible={this.props.collapsible} onClick={this.toggleCollapse} button={this.props.button} isGroup={true} />
<div className="bd-settings-container" ref={this.container}>
{this.props.children}
</div>
{this.props.showDivider && <Divider />}
</div>;
}
}

View File

@ -1,47 +1,23 @@
import Logger from "common/logger";
import {React} from "modules";
import Drawer from "./drawer";
import Title from "./title";
import Divider from "../divider";
import Switch from "./components/switch";
import Dropdown from "./components/dropdown";
import Number from "./components/number";
import Item from "./components/item";
import Textbox from "./components/textbox";
import Slider from "./components/slider";
import Radio from "./components/radio";
import Keybind from "./components/keybind";
import Color from "./components/color";
const baseClassName = "bd-settings-group";
export default class Group extends React.Component {
constructor(props) {
super(props);
if (this.props.button && this.props.collapsible) {
const original = this.props.button.onClick;
this.props.button.onClick = (event) => {
event.stopPropagation();
original(...arguments);
};
}
if (!this.props.hasOwnProperty("shown")) this.props.shown = true;
this.container = React.createRef();
this.state = {
collapsed: this.props.collapsible && !this.props.shown
};
this.onChange = this.onChange.bind(this);
this.toggleCollapse = this.toggleCollapse.bind(this);
}
toggleCollapse() {
const container = this.container.current;
const timeout = this.state.collapsed ? 300 : 1;
container.style.setProperty("height", container.scrollHeight + "px");
container.classList.add("animating");
this.setState({collapsed: !this.state.collapsed}, () => setTimeout(() => {
container.style.setProperty("height", "");
container.classList.remove("animating");
}, timeout));
if (this.props.onDrawerToggle) this.props.onDrawerToggle(this.state.collapsed);
}
onChange(id, value) {
@ -53,30 +29,21 @@ export default class Group extends React.Component {
render() {
const {settings} = this.props;
const collapseClass = this.props.collapsible ? `collapsible ${this.state.collapsed ? "collapsed" : "expanded"}` : "";
const groupClass = `${baseClassName} ${collapseClass}`;
return <div className={groupClass}>
<Title text={this.props.name} collapsible={this.props.collapsible} onClick={this.toggleCollapse} button={this.props.button} isGroup={true} />
<div className="bd-settings-container" ref={this.container}>
{settings.filter(s => !s.hidden).map((setting) => {
let component = null;
if (setting.type == "dropdown") component = <Dropdown disabled={setting.disabled} id={setting.id} options={setting.options} value={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "number") component = <Number disabled={setting.disabled} id={setting.id} min={setting.min} max={setting.max} step={setting.step} value={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "switch") component = <Switch disabled={setting.disabled} id={setting.id} checked={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (!component) return null;
return <Item id={setting.id} key={setting.id} name={setting.name} note={setting.note}>{component}</Item>;
})}
</div>
{this.props.showDivider && <Divider />}
</div>;
return <Drawer collapsible={this.props.collapsible} name={this.props.name} button={this.props.button} shown={this.props.shown} onDrawerToggle={this.props.onDrawerToggle} showDivider={this.props.showDivider}>
{settings.filter(s => !s.hidden).map((setting) => {
let component = null;
if (setting.type == "dropdown") component = <Dropdown disabled={setting.disabled} id={setting.id} options={setting.options} value={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "number") component = <Number disabled={setting.disabled} id={setting.id} min={setting.min} max={setting.max} step={setting.step} value={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "switch") component = <Switch disabled={setting.disabled} id={setting.id} checked={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "text") component = <Textbox disabled={setting.disabled} id={setting.id} value={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "slider") component = <Slider disabled={setting.disabled} id={setting.id} min={setting.min} max={setting.max} step={setting.step} value={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "radio") component = <Radio disabled={setting.disabled} id={setting.id} name={setting.id} options={setting.options} value={setting.value} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "keybind") component = <Keybind disabled={setting.disabled} id={setting.id} value={setting.value} max={setting.max} onChange={this.onChange.bind(this, setting.id)} />;
if (setting.type == "color") component = <Color disabled={setting.disabled} id={setting.id} value={setting.value} defaultValue={setting.defaultValue} colors={setting.colors} onChange={this.onChange.bind(this, setting.id)} />;
if (!component) return null;
return <Item id={setting.id} inline={setting.type !== "radio"} key={setting.id} name={setting.name} note={setting.note}>{component}</Item>;
})}
</Drawer>;
}
}
const originalRender = Group.prototype.render;
Object.defineProperty(Group.prototype, "render", {
enumerable: false,
configurable: false,
set: function() {Logger.warn("Group", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");},
get: () => originalRender
});
}

View File

@ -4,12 +4,23 @@ const className = "bd-settings-title";
const className2 = "bd-settings-title bd-settings-group-title";
export default class SettingsTitle extends React.Component {
constructor(props) {
super(props);
this.buttonClick = this.buttonClick.bind(this);
}
buttonClick(event) {
event.stopPropagation();
event.preventDefault();
this.props?.button?.onClick?.(event);
}
render() {
const baseClass = this.props.isGroup ? className2 : className;
const titleClass = this.props.className ? `${baseClass} ${this.props.className}` : baseClass;
return <h2 className={titleClass} onClick={() => {this.props.onClick && this.props.onClick();}}>
{this.props.text}
{this.props.button && <button className="bd-button bd-button-title" onClick={this.props.button.onClick}>{this.props.button.title}</button>}
{this.props.button && <button className="bd-button bd-button-title" onClick={this.buttonClick}>{this.props.button.title}</button>}
{this.props.otherChildren}
</h2>;
}

View File

@ -64,7 +64,7 @@ export default class Toasts {
const form = container ? container.querySelector("form") : null;
const left = container ? container.getBoundingClientRect().left : 310;
const right = memberlist ? memberlist.getBoundingClientRect().left : 0;
const width = right ? right - container.getBoundingClientRect().left : container.offsetWidth;
const width = right ? right - container.getBoundingClientRect().left : (container?.offsetWidth ?? document.body.offsetWidth / 2);
const bottom = form ? form.offsetHeight : 80;
const toastWrapper = document.createElement("div");
toastWrapper.classList.add("bd-toasts");
@ -73,4 +73,4 @@ export default class Toasts {
toastWrapper.style.setProperty("bottom", bottom + "px");
DOMManager.bdBody.appendChild(toastWrapper);
}
}
}

164
renderer/src/ui/tooltip.js Normal file
View File

@ -0,0 +1,164 @@
import Logger from "common/logger";
import {DOMManager} from "modules";
const toPx = function(value) {
return `${value}px`;
};
const styles = ["primary", "info", "success", "warn", "danger"];
const sides = ["top", "right", "bottom", "left"];
export default class Tooltip {
/**
*
* @constructor
* @param {HTMLElement} node - DOM node to monitor and show the tooltip on
* @param {string|HTMLElement} tip - 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
*/
constructor(node, text, options = {}) {
const {style = "primary", side = "top", preventFlip = false, disabled = false} = options;
this.node = node;
this.label = text;
this.style = style.toLowerCase();
this.side = side.toLowerCase();
this.preventFlip = preventFlip;
this.disabled = disabled;
this.active = false;
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 = 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];
if (text instanceof HTMLElement) this.labelElement.append(text);
else this.labelElement.textContent = text;
this.element.append(this.tooltipElement);
this.node.addEventListener("mouseenter", () => {
if (this.disabled) return;
this.show();
});
this.node.addEventListener("mouseleave", () => {
this.hide();
});
}
/** Alias for the constructor */
static create(node, text, options = {}) {return new Tooltip(node, text, options);}
/** Container where the tooltip will be appended. */
get container() {return document.querySelector(`#app-mount`);}
/** 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 <= 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 <= DOMManager.screenWidth;}
/** Hides the tooltip. Automatically called on mouseleave. */
hide() {
/** Don't rehide if already inactive */
if (!this.active) return;
this.active = false;
this.element.remove();
}
/** Shows the tooltip. Automatically called on mouseenter. Will attempt to flip if position was wrong. */
show() {
/** Don't reshow if already active */
if (this.active) return;
this.active = true;
this.labelElement.textContent = this.label;
this.container.append(this.element);
if (this.side == "top") {
if (this.canShowAbove || (!this.canShowAbove && this.preventFlip)) this.showAbove();
else this.showBelow();
}
if (this.side == "bottom") {
if (this.canShowBelow || (!this.canShowBelow && this.preventFlip)) this.showBelow();
else this.showAbove();
}
if (this.side == "left") {
if (this.canShowLeft || (!this.canShowLeft && this.preventFlip)) this.showLeft();
else this.showRight();
}
if (this.side == "right") {
if (this.canShowRight || (!this.canShowRight && this.preventFlip)) this.showRight();
else this.showLeft();
}
/** Do not create a new observer each time if one already exists! */
if (this.observer) return;
/** Use an observer in show otherwise you'll cause unclosable tooltips */
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
const nodes = Array.from(mutation.removedNodes);
const directMatch = nodes.indexOf(this.node) > -1;
const parentMatch = nodes.some(parent => parent.contains(this.node));
if (directMatch || parentMatch) {
this.hide();
this.observer.disconnect();
}
});
});
this.observer.observe(document.body, {subtree: true, childList: true});
}
/** Force showing the tooltip above the node. */
showAbove() {
this.tooltipElement.classList.add("bd-tooltip-top");
this.element.style.setProperty("top", toPx(this.node.getBoundingClientRect().top - this.element.offsetHeight - 10));
this.centerHorizontally();
}
/** Force showing the tooltip below the node. */
showBelow() {
this.tooltipElement.classList.add("bd-tooltip-bottom");
this.element.style.setProperty("top", toPx(this.node.getBoundingClientRect().top + this.node.offsetHeight + 10));
this.centerHorizontally();
}
/** Force showing the tooltip to the left of the node. */
showLeft() {
this.tooltipElement.classList.add("bd-tooltip-left");
this.element.style.setProperty("left", toPx(this.node.getBoundingClientRect().left - this.element.offsetWidth - 10));
this.centerVertically();
}
/** Force showing the tooltip to the right of the node. */
showRight() {
this.tooltipElement.classList.add("bd-tooltip-right");
this.element.style.setProperty("left", toPx(this.node.getBoundingClientRect().left + this.node.offsetWidth + 10));
this.centerVertically();
}
centerHorizontally() {
const nodecenter = this.node.getBoundingClientRect().left + (this.node.offsetWidth / 2);
this.element.style.setProperty("left", toPx(nodecenter - (this.element.offsetWidth / 2)));
}
centerVertically() {
const nodecenter = this.node.getBoundingClientRect().top + (this.node.offsetHeight / 2);
this.element.style.setProperty("top", toPx(nodecenter - (this.element.offsetHeight / 2)));
}
}
window.Tooltip = Tooltip;

129
renderer/src/ui/updater.jsx Normal file
View File

@ -0,0 +1,129 @@
import {Config} from "data";
import {React, Events} from "modules";
import Drawer from "./settings/drawer";
import SettingItem from "./settings/components/item";
import SettingsTitle from "./settings/title";
import Toasts from "./toasts";
import Checkmark from "./icons/check";
class CoreUpdaterPanel extends React.Component {
render() {
return <Drawer name="BetterDiscord" collapsible={true}>
<SettingItem name={`Core v${Config.version}`} note={this.props.hasUpdate ? `Version ${this.props.remoteVersion} now available!` : "No updates available."} inline={true} id={"core-updater"}>
{!this.props.hasUpdate && <div className="bd-filled-checkmark"><Checkmark /></div>}
{this.props.hasUpdate && <button className="bd-button">Update!</button>}
</SettingItem>
</Drawer>;
}
}
class NoUpdates extends React.Component {
render() {
return <div className="bd-empty-updates">
<Checkmark size="48px" />
{`All of your ${this.props.type} seem to be up to date!`}
</div>;
}
}
class AddonUpdaterPanel extends React.Component {
render() {
const filenames = this.props.pending;
return <Drawer name={this.props.type} collapsible={true} button={filenames.length ? {title: "Update All!", onClick: () => this.props.updateAll(this.props.type)} : null}>
{!filenames.length && <NoUpdates type={this.props.type} />}
{filenames.map(f => {
const info = this.props.updater.cache[f];
const addon = this.props.updater.manager.addonList.find(a => a.filename === f);
return <SettingItem name={`${addon.name} v${addon.version}`} note={`Version ${info.version} now available!`} inline={true} id={addon.name}>
<button className="bd-button" onClick={() => this.props.update(this.props.type, f)}>Update!</button>
</SettingItem>;
})}
</Drawer>;
}
}
export default class UpdaterPanel extends React.Component {
constructor(props) {
super(props);
this.state = {
hasCoreUpdate: this.props.coreUpdater.hasUpdate,
plugins: this.props.pluginUpdater.pending.slice(0),
themes: this.props.themeUpdater.pending.slice(0)
};
this.checkForUpdates = this.checkForUpdates.bind(this);
this.updateAddon = this.updateAddon.bind(this);
this.updateAllAddons = this.updateAllAddons.bind(this);
this.update = this.update.bind(this);
}
update() {
this.checkAddons("plugins");
this.checkAddons("themes");
}
componentDidMount() {
Events.on(`plugin-loaded`, this.update);
Events.on(`plugin-unloaded`, this.update);
Events.on(`theme-loaded`, this.update);
Events.on(`theme-unloaded`, this.update);
}
componentWillUnmount() {
Events.off(`plugin-loaded`, this.update);
Events.off(`plugin-unloaded`, this.update);
Events.off(`theme-loaded`, this.update);
Events.off(`theme-unloaded`, this.update);
}
async checkForUpdates() {
Toasts.info("Checking for updates!");
await this.checkCoreUpdate();
await this.checkAddons("plugins");
await this.checkAddons("themes");
Toasts.info("Finished checking for updates!");
}
async checkCoreUpdate() {
await this.props.coreUpdater.checkForUpdate(false);
this.setState({hasCoreUpdate: this.props.coreUpdater.hasUpdate});
}
async updateCore() {
await this.props.coreUpdater.update();
this.setState({hasCoreUpdate: false});
}
async checkAddons(type) {
const updater = type === "plugins" ? this.props.pluginUpdater : this.props.themeUpdater;
await updater.checkAll(false);
this.setState({[type]: updater.pending.slice(0)});
}
async updateAddon(type, filename) {
const updater = type === "plugins" ? this.props.pluginUpdater : this.props.themeUpdater;
await updater.updateAddon(filename);
this.setState(prev => {
prev[type].splice(prev[type].indexOf(filename), 1);
return prev;
});
}
async updateAllAddons(type) {
const toUpdate = this.state[type].slice(0);
for (const filename of toUpdate) {
await this.updateAddon(type, filename);
}
}
render() {
return [
<SettingsTitle text="Updates" button={{title: "Check For Updates!", onClick: this.checkForUpdates}} />,
<CoreUpdaterPanel remoteVersion={this.props.coreUpdater.remoteVersion} hasUpdate={this.state.hasCoreUpdate} />,
<AddonUpdaterPanel type="plugins" pending={this.state.plugins} update={this.updateAddon} updateAll={this.updateAllAddons} updater={this.props.pluginUpdater} />,
<AddonUpdaterPanel type="themes" pending={this.state.themes} update={this.updateAddon} updateAll={this.updateAllAddons} updater={this.props.themeUpdater} />,
];
}
}