├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.yml │ └── SUGGESTION.yml └── workflows │ ├── check.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── dist └── bd │ ├── BetterFolders.plugin.js │ ├── BetterVolume.plugin.js │ ├── CollapseEmbeds.plugin.js │ ├── NoReplyMention.plugin.js │ ├── OnlineFriendCount.plugin.js │ ├── README.md │ └── VoiceEvents.plugin.js ├── global.d.ts ├── package-lock.json ├── package.json ├── packages ├── bd-meta │ ├── index.ts │ └── package.json ├── dium │ ├── README.md │ ├── package.json │ ├── src │ │ ├── api │ │ │ ├── data.ts │ │ │ ├── filters.ts │ │ │ ├── finder.ts │ │ │ ├── index.ts │ │ │ ├── logger.ts │ │ │ ├── patcher.ts │ │ │ └── styles.ts │ │ ├── components │ │ │ ├── button.ts │ │ │ ├── clickable.ts │ │ │ ├── common.ts │ │ │ ├── embed.ts │ │ │ ├── flex.ts │ │ │ ├── form.ts │ │ │ ├── guild.ts │ │ │ ├── icon.ts │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── link.ts │ │ │ ├── margins.ts │ │ │ ├── menu.ts │ │ │ ├── message.ts │ │ │ ├── radio.ts │ │ │ ├── select.ts │ │ │ ├── slider.ts │ │ │ ├── switch.ts │ │ │ ├── text-area.ts │ │ │ └── text.ts │ │ ├── index.tsx │ │ ├── meta.ts │ │ ├── modules │ │ │ ├── channel.ts │ │ │ ├── client.ts │ │ │ ├── component-dispatch.ts │ │ │ ├── dispatcher.ts │ │ │ ├── experiment.ts │ │ │ ├── flux.ts │ │ │ ├── general.ts │ │ │ ├── guild.ts │ │ │ ├── index.ts │ │ │ ├── media.ts │ │ │ ├── message.ts │ │ │ ├── npm.ts │ │ │ ├── popout-window.ts │ │ │ ├── router.ts │ │ │ ├── user.ts │ │ │ └── util.ts │ │ ├── react-internals.ts │ │ ├── require.ts │ │ ├── settings-container.tsx │ │ ├── settings.ts │ │ └── utils │ │ │ ├── general.ts │ │ │ ├── index.ts │ │ │ └── react.ts │ ├── tests │ │ ├── mock.ts │ │ └── utils │ │ │ ├── general.ts │ │ │ └── react.tsx │ └── tsconfig.json ├── rollup-plugin-bd-meta │ ├── index.ts │ └── package.json ├── rollup-plugin-bd-wscript │ ├── index.ts │ ├── package.json │ └── wscript.js └── rollup-plugin-style-modules │ ├── index.ts │ └── package.json ├── rollup.config.ts ├── scripts └── build.ts ├── src ├── BetterFolders │ ├── icon.tsx │ ├── index.tsx │ ├── modal.tsx │ ├── package.json │ ├── settings.ts │ ├── styles.module.scss │ └── uploader.tsx ├── BetterVolume │ ├── experiment.tsx │ ├── index.tsx │ ├── input.tsx │ ├── package.json │ ├── settings.ts │ ├── styles.module.scss │ └── sync.ts ├── CollapseEmbeds │ ├── hider.tsx │ ├── index.tsx │ ├── package.json │ ├── settings-panel.tsx │ ├── settings.ts │ └── styles.module.scss ├── DevTools │ ├── finder.ts │ ├── index.tsx │ └── package.json ├── Emulator │ ├── index.tsx │ └── package.json ├── NoReplyMention │ ├── index.tsx │ └── package.json ├── OnlineFriendCount │ ├── context-menu.tsx │ ├── counter.tsx │ ├── index.tsx │ ├── package.json │ ├── settings.ts │ └── styles.module.scss ├── SVGInspect │ ├── index.tsx │ └── package.json └── VoiceEvents │ ├── index.tsx │ ├── package.json │ ├── settings-panel.tsx │ ├── settings.tsx │ └── voice.tsx └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | parser: "@typescript-eslint/parser", 4 | env: { 5 | browser: true, 6 | node: true 7 | }, 8 | plugins: [ 9 | "@typescript-eslint", 10 | "node", 11 | "import", 12 | "react", 13 | "react-hooks" 14 | ], 15 | settings: { 16 | react: { 17 | version: "17.0.2" // stable Discord 18 | } 19 | }, 20 | extends: [ 21 | "eslint:recommended", 22 | "plugin:react/recommended", 23 | "google" 24 | ], 25 | rules: { 26 | indent: "off", 27 | semi: "off", 28 | quotes: "off", 29 | "comma-dangle": ["error", "never"], 30 | "quote-props": ["error", "as-needed"], 31 | "operator-linebreak": ["error", "before"], 32 | "no-multiple-empty-lines": ["error", {max: 1}], 33 | "spaced-comment": ["error", "always", { 34 | block: { 35 | balanced: true 36 | } 37 | }], 38 | "linebreak-style": "off", 39 | "max-len": "off", 40 | "require-jsdoc": "off", 41 | "valid-jsdoc": "off", 42 | "react/display-name": "off", 43 | "new-cap": "off" 44 | }, 45 | overrides: [{ 46 | files: ["*.ts", "*.tsx"], 47 | extends: [ 48 | "plugin:@typescript-eslint/recommended" 49 | ], 50 | parserOptions: { 51 | project: ["./tsconfig.json"] 52 | }, 53 | rules: { 54 | "@typescript-eslint/indent": ["error", 4], 55 | "@typescript-eslint/semi": "error", 56 | "@typescript-eslint/quotes": ["error", "double"], 57 | "@typescript-eslint/member-delimiter-style": ["error"], 58 | "@typescript-eslint/no-unused-vars": ["error", { 59 | argsIgnorePattern: "^_", 60 | varsIgnorePattern: "^_", 61 | caughtErrorsIgnorePattern: "^_" 62 | }], 63 | "@typescript-eslint/prefer-optional-chain": "error", 64 | "@typescript-eslint/no-empty-function": "off", 65 | "@typescript-eslint/explicit-module-boundary-types": "error", 66 | "@typescript-eslint/no-explicit-any": "off" 67 | } 68 | }] 69 | }; 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a bug report 3 | body: 4 | - type: textarea 5 | attributes: 6 | label: Bug description 7 | validations: 8 | required: true 9 | - type: input 10 | attributes: 11 | label: Plugin 12 | placeholder: BetterFolders, BetterVolume etc. 13 | validations: 14 | required: true 15 | - type: input 16 | attributes: 17 | label: Plugin version 18 | placeholder: v1.x.x 19 | validations: 20 | required: true 21 | - type: dropdown 22 | attributes: 23 | label: Discord client 24 | options: [Stable, PTB, Canary] 25 | validations: 26 | required: true 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/SUGGESTION.yml: -------------------------------------------------------------------------------- 1 | name: Suggestion 2 | description: Suggest a new feature or idea 3 | body: 4 | - type: input 5 | attributes: 6 | label: Plugin 7 | placeholder: BetterFolders, BetterVolume etc. 8 | validations: 9 | required: true 10 | - type: textarea 11 | attributes: 12 | label: Description 13 | validations: 14 | required: true 15 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "**/*.ts" 9 | - "**/*.tsx" 10 | - "**/package.json" 11 | - "**/tsconfig.json" 12 | pull_request: 13 | branches: 14 | - master 15 | paths: 16 | - "**/*.ts" 17 | - "**/*.tsx" 18 | - "**/package.json" 19 | - "**/tsconfig.json" 20 | 21 | jobs: 22 | check: 23 | name: Check 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: 22 30 | - run: npm ci 31 | - run: npm run check 32 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | - run: npm ci 21 | - run: npm run lint 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - dium/** 9 | pull_request: 10 | branches: 11 | - master 12 | paths: 13 | - dium/** 14 | 15 | jobs: 16 | tests: 17 | name: Tests 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | - run: npm ci 25 | - run: npm run test --workspaces --if-present 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Zerthox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BetterDiscord Plugins 2 | 3 | Plugins for the [BetterDiscord](https://betterdiscord.app) client modification for [Discord](https://discord.com). 4 | 5 | To get to the **distributed plugin files [click here](dist/bd/)**. 6 | 7 | ## Building from source 8 | ```sh 9 | # install dependencies 10 | npm install 11 | 12 | # build all plugins 13 | npm run build 14 | 15 | # build specific plugins 16 | npm run build -- BetterFolders BetterVolume 17 | 18 | # build plugin to BetterDiscord folder & watch for changes 19 | npm run dev -- BetterFolders 20 | ``` 21 | -------------------------------------------------------------------------------- /dist/bd/README.md: -------------------------------------------------------------------------------- 1 | ## [BetterFolders](https://betterdiscord.app/plugin/BetterFolders) `v3.6.2` *(Updated: 11/05/2025)* 2 | Adds new functionality to server folders. Custom Folder Icons. Close other folders on open. 3 | 4 | ## [BetterVolume](https://betterdiscord.app/plugin/BetterVolume) `v3.1.1` *(Updated: 31/01/2025)* 5 | Set user volume values manually instead of using a slider. Allows setting volumes higher than 200%. 6 | 7 | ## [CollapseEmbeds](https://betterdiscord.app/plugin/CollapseEmbeds) `v2.0.0` *(Updated: 28/04/2025)* 8 | Adds a button to collapse embeds & attachments. 9 | 10 | ## [OnlineFriendCount](https://betterdiscord.app/plugin/OnlineFriendCount) `v3.2.3` *(Updated: 08/05/2025)* 11 | Adds the old online friend count and similar counters back to server list. Because nostalgia. 12 | 13 | ## [VoiceEvents](https://betterdiscord.app/plugin/VoiceEvents) `v2.6.2` *(Updated: 31/01/2025)* 14 | Adds TTS Event Notifications to your selected Voice Channel. TeamSpeak feeling. 15 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.scss" { 2 | const classNames: Record; 3 | export default classNames; 4 | export const css: string; 5 | } 6 | 7 | declare module "*.scss" { 8 | const css: string; 9 | export default css; 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "betterdiscord-plugins", 3 | "version": "0.1.0", 4 | "description": "Plugins for BetterDiscord.", 5 | "author": "Zerthox", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "build": "ts-node scripts/build.ts", 10 | "dev": "ts-node scripts/build.ts --dev --watch", 11 | "check": "tsc --noemit", 12 | "lint": "eslint . -f node_modules/eslint-friendly-formatter", 13 | "lint-fix": "eslint . -f node_modules/eslint-friendly-formatter --fix" 14 | }, 15 | "repository": "https://github.com/Zerthox/BetterDiscord-Plugins", 16 | "bugs": "https://github.com/Zerthox/BetterDiscord-Plugins/issues", 17 | "homepage": "https://github.com/Zerthox/BetterDiscord-Plugins#readme", 18 | "workspaces": [ 19 | "packages/*", 20 | "src/*" 21 | ], 22 | "dependencies": { 23 | "@types/betterdiscord": "github:zerthox/betterdiscord-types", 24 | "@types/node": "^22.15.16", 25 | "@types/react": "^19.0.0", 26 | "@types/react-dom": "^19.0.0", 27 | "dium": "*" 28 | }, 29 | "devDependencies": { 30 | "@rollup/plugin-json": "^6.0.0", 31 | "@rollup/plugin-typescript": "^11.0.0", 32 | "@types/eslint": "^8.4.10", 33 | "@types/lodash.camelcase": "^4.3.7", 34 | "@types/minimist": "^1.2.2", 35 | "@typescript-eslint/eslint-plugin": "^7.1.0", 36 | "@typescript-eslint/parser": "^7.1.0", 37 | "bd-meta": "*", 38 | "chalk": "^4.1.2", 39 | "eslint": "^8.13.0", 40 | "eslint-config-google": "^0.14.0", 41 | "eslint-friendly-formatter": "^4.0.1", 42 | "eslint-plugin-import": "^2.25.2", 43 | "eslint-plugin-node": "^11.1.0", 44 | "eslint-plugin-react": "^7.26.1", 45 | "eslint-plugin-react-hooks": "^4.2.0", 46 | "minimist": "^1.2.6", 47 | "rollup": "^4.12.0", 48 | "rollup-plugin-bd-meta": "*", 49 | "rollup-plugin-bd-wscript": "*", 50 | "rollup-plugin-cleanup": "^3.2.1", 51 | "rollup-plugin-scss": "^4.0.0", 52 | "rollup-plugin-style-modules": "*", 53 | "sass": "^1.43.4", 54 | "ts-node": "^10.4.0", 55 | "tslib": "^2.6.2", 56 | "typescript": "^5.0.4" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/bd-meta/index.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import {promises as fs} from "fs"; 3 | import camelCase from "lodash.camelcase"; 4 | import upperFirst from "lodash.upperfirst"; 5 | import type {PackageJson} from "type-fest"; 6 | import type {Meta} from "betterdiscord"; 7 | 8 | export type {Meta} from "betterdiscord"; 9 | 10 | export interface PackageWithMeta extends PackageJson.PackageJsonStandard { 11 | meta?: Partial; 12 | } 13 | 14 | export async function resolvePkg(dir: string): Promise { 15 | let current = path.resolve(dir); 16 | 17 | // eslint-disable-next-line no-constant-condition 18 | while (true) { 19 | try { 20 | const file = path.join(dir, "package.json"); 21 | if (await fs.stat(file)) { 22 | return file; 23 | } 24 | } catch { 25 | const parent = path.dirname(current); 26 | if (parent != current) { 27 | current = parent; 28 | } else { 29 | break; 30 | } 31 | } 32 | } 33 | return undefined; 34 | } 35 | 36 | export interface Options { 37 | authorGithub?: boolean; 38 | } 39 | 40 | export async function readMetaFromPkg(file: string, {authorGithub = false}: Options = {}): Promise { 41 | const pkg = JSON.parse(await fs.readFile(file, "utf8")) as PackageWithMeta; 42 | return { 43 | name: upperFirst(camelCase(pkg.name)), 44 | version: pkg.version, 45 | author: typeof pkg.author === "string" ? pkg.author : pkg.author.name, 46 | authorLink: typeof pkg.author === "object" ? pkg.author.url : (authorGithub ? `https://github.com/${pkg.author}` : undefined), 47 | description: pkg.description, 48 | website: pkg.homepage, 49 | ...pkg.meta 50 | }; 51 | } 52 | 53 | export function writeMeta(meta: Partial): string { 54 | let result = "/**"; 55 | for (const [key, value] of Object.entries(meta)) { 56 | if (typeof value === "string") { 57 | result += `\n * @${key} ${value.replace(/\n/g, "\\n")}`; 58 | } 59 | } 60 | return result + "\n**/\n"; 61 | } 62 | -------------------------------------------------------------------------------- /packages/bd-meta/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bd-meta", 3 | "version": "0.1.0", 4 | "author": "Zerthox", 5 | "dependencies": { 6 | "@types/betterdiscord": "github:zerthox/betterdiscord-types", 7 | "lodash.camelcase": "^4.3.0", 8 | "lodash.upperfirst": "^4.3.1", 9 | "type-fest": "^4.24.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/dium/README.md: -------------------------------------------------------------------------------- 1 | # Dium 2 | A work-in-progress framework for Discord plugins. 3 | 4 | Eventually, this might be moved to its own repository. 5 | 6 | **Important:** This library relies heavily on tree-shaking (dead code elimination) to eliminate searches for unused modules. 7 | 8 | ```tsx 9 | /** 10 | * @name Example 11 | * @version 0.1.0 12 | * @author Zerthox 13 | * @description Example plugin using dium. 14 | */ 15 | 16 | import { 17 | createPlugin, 18 | createSettings, 19 | Logger, 20 | Filters, 21 | Finder, 22 | Patcher, 23 | Styles, 24 | Data, 25 | Utils, 26 | React, 27 | ReactInternals, 28 | ReactDOM, 29 | ReactDOMInternals, 30 | Flux 31 | } from "dium"; 32 | import {classNames, lodash} from "@dium/modules"; 33 | 34 | const Settings = createSettings({ 35 | enabled: false 36 | }); 37 | 38 | export default createPlugin({ 39 | start: async () => { 40 | // do something on plugin start 41 | }, 42 | stop: () => { 43 | // do something on plugin stop 44 | }, 45 | styles: `.example-clickable { 46 | color: red; 47 | cursor: pointer; 48 | }`, 49 | Settings, 50 | SettingsPanel: () => { 51 | // use settings via hook 52 | const [settings, setSettings] = Settings.useState(); 53 | 54 | // render settings panel 55 | return ( 56 |
57 |
58 | Setting is {settings.enabled ? "enabled" : "disabled"} 59 |
60 |
setSettings({enabled: !settings.enabled})} 63 | >Click to toggle
64 |
65 | ); 66 | } 67 | }); 68 | ``` 69 | -------------------------------------------------------------------------------- /packages/dium/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dium", 3 | "version": "0.6.3", 4 | "description": "Framework for Discord plugins.", 5 | "author": "Zerthox", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "check": "tsc --noemit", 10 | "test": "mocha -r ts-node/register tests/**/*.{ts,tsx}" 11 | }, 12 | "main": "src/index", 13 | "repository": "https://github.com/Zerthox/BetterDiscord-Plugins", 14 | "bugs": "https://github.com/Zerthox/BetterDiscord-Plugins/issues", 15 | "homepage": "https://github.com/Zerthox/BetterDiscord-Plugins/tree/master/dium#readme", 16 | "dependencies": { 17 | "@react-spring/rafz": "^10.0.0-beta.0", 18 | "@react-spring/web": "^10.0.0-beta.0", 19 | "@types/betterdiscord": "github:zerthox/betterdiscord-types", 20 | "@types/history": "^4.7.11", 21 | "@types/lodash": "^4.14.176", 22 | "@types/node": "^22.15.16", 23 | "@types/platform": "^1.3.4", 24 | "@types/react": "^19.0.0", 25 | "@types/react-dom": "^19.0.0", 26 | "@types/react-reconciler": "^0.32.0", 27 | "@types/react-router": "^5.1.20", 28 | "@types/semver": "^7.3.9", 29 | "classnames": "^2.3.1", 30 | "highlight.js": "^11.5.1", 31 | "immutable": "^3.8.2", 32 | "lottie-web": "^5.9.6", 33 | "moment": "^2.29.3", 34 | "simple-markdown": "^0.7.3", 35 | "stemmer": "^2.0.0" 36 | }, 37 | "devDependencies": { 38 | "@types/mocha": "^10.0.0", 39 | "mocha": "^10.0.0", 40 | "react": "^19.0.0", 41 | "react-reconciler": "^0.32.0", 42 | "ts-node": "^10.4.0", 43 | "typescript": "^5.0.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/dium/src/api/data.ts: -------------------------------------------------------------------------------- 1 | import {getMeta} from "../meta"; 2 | 3 | /** Loads data from a key. */ 4 | export const load = (key: string): any => BdApi.Data.load(getMeta().name, key); 5 | 6 | /** Saves data to a key. */ 7 | export const save = (key: string, value: unknown): void => BdApi.Data.save(getMeta().name, key, value); 8 | 9 | /** Deletes data stored under a key. */ 10 | export const deleteEntry = (key: string): void => BdApi.Data.delete(getMeta().name, key); 11 | -------------------------------------------------------------------------------- /packages/dium/src/api/filters.ts: -------------------------------------------------------------------------------- 1 | export type Filter = (data: any) => boolean; 2 | 3 | export type TypeOrPredicate = T | ((data: T) => boolean); 4 | 5 | export interface Query { 6 | filter?: Filter | Filter[]; 7 | name?: string; 8 | keys?: string[]; 9 | protos?: string[]; 10 | source?: TypeOrPredicate[]; 11 | } 12 | 13 | /** Joins multiple filters together. */ 14 | export const join = boolean>(...filters: F[]): F => { 15 | return ((...args) => filters.every((filter) => filter(...args))) as any; 16 | }; 17 | 18 | /** Creates a filter from query options. */ 19 | export const query = ({filter, name, keys, protos, source}: Query): Filter => join(...[ 20 | ...[filter].flat(), 21 | typeof name === "string" ? byName(name) : null, 22 | keys instanceof Array ? byKeys(...keys) : null, 23 | protos instanceof Array ? byProtos(...protos) : null, 24 | source instanceof Array ? bySource(...source) : null 25 | ].filter(Boolean)); 26 | 27 | /** 28 | * Determines whether values can be checked. 29 | * 30 | * This also skips checking values on `window` as well as directly exported prototypes. 31 | */ 32 | export const checkObjectValues = (target: unknown): boolean => target !== window && target instanceof Object && target.constructor?.prototype !== target; 33 | 34 | /** Creates a filter matching on values in the exported object. */ 35 | export const byEntry = boolean>(filter: F, every = false): F => { 36 | return ((target, ...args) => { 37 | if (checkObjectValues(target)) { 38 | const values = Object.values(target); 39 | return values.length > 0 && values[every ? "every" : "some"]((value) => filter(value, ...args)); 40 | } else { 41 | return false; 42 | } 43 | }) as any; 44 | }; 45 | 46 | /** Creates a filter searching by `displayName`. */ 47 | export const byName = (name: string): Filter => { 48 | return (target) => (target?.displayName ?? target?.constructor?.displayName) === name; 49 | }; 50 | 51 | /** Creates a filter searching by export property names. */ 52 | export const byKeys = (...keys: string[]): Filter => { 53 | return (target) => target instanceof Object && keys.every((key) => key in target); 54 | }; 55 | 56 | /** Creates a filter searching by prototype names. */ 57 | export const byProtos = (...protos: string[]): Filter => { 58 | return (target) => target instanceof Object && target.prototype instanceof Object && protos.every((proto) => proto in target.prototype); 59 | }; 60 | 61 | /** 62 | * Creates a filter searching by function source fragments. 63 | * 64 | * Also searches a potential `render()` function on the prototype in order to handle React class components. 65 | * For ForwardRef or Memo exotic components the wrapped component is checked. 66 | */ 67 | export const bySource = (...fragments: TypeOrPredicate[]): Filter => { 68 | return (target) => { 69 | // handle exotic components 70 | while (target instanceof Object && "$$typeof" in target) { 71 | target = target.render ?? target.type; 72 | } 73 | 74 | if (target instanceof Function) { 75 | const source = target.toString(); 76 | const renderSource = (target.prototype as React.Component)?.render?.toString(); 77 | 78 | return fragments.every((fragment) => typeof fragment === "string" ? ( 79 | source.includes(fragment) || renderSource?.includes(fragment) 80 | ) : ( 81 | fragment(source) || renderSource && fragment(renderSource) 82 | )); 83 | } else { 84 | return false; 85 | } 86 | }; 87 | }; 88 | -------------------------------------------------------------------------------- /packages/dium/src/api/finder.ts: -------------------------------------------------------------------------------- 1 | import * as Filters from "./filters"; 2 | import {mappedProxy} from "../utils/general"; 3 | import type {Query, TypeOrPredicate} from "./filters"; 4 | import type {Module, Exports} from "../require"; 5 | 6 | export type Filter = (exports: Exports, module: Module, id: string) => boolean; 7 | 8 | export interface FindOptions { 9 | /** Whether to resolve the matching export or return the whole exports object. */ 10 | resolve?: boolean; 11 | 12 | /** Whether to check all export entries. */ 13 | entries?: boolean; 14 | } 15 | 16 | /** Finds a module using a set of filter functions. */ 17 | export const find = (filter: Filter, {resolve = true, entries = false}: FindOptions = {}): any => BdApi.Webpack.getModule(filter, { 18 | defaultExport: resolve, 19 | searchExports: entries 20 | }); 21 | 22 | /** Finds a module using query options. */ 23 | export const query = (query: Query, options?: FindOptions): any => find(Filters.query(query), options); 24 | 25 | /** Finds a module using filters matching its entries. */ 26 | export const byEntries = (...filters: Filter[]): any => find(Filters.join(...filters.map((filter) => Filters.byEntry(filter)))); 27 | 28 | /** Finds a module using the name of its export. */ 29 | export const byName = (name: string, options?: FindOptions): any => find(Filters.byName(name), options); 30 | 31 | /** Finds a module using property names of its export. */ 32 | // TODO: using spread here turns an accidentally passed string into a single character array 33 | export const byKeys = (keys: string[], options?: FindOptions): any => find(Filters.byKeys(...keys), options); 34 | 35 | /** Finds a module using prototype names of its export. */ 36 | export const byProtos = (protos: string[], options?: FindOptions): any => find(Filters.byProtos(...protos), options); 37 | 38 | /** Finds a module using source code contents of its export entries. */ 39 | export const bySource = (contents: TypeOrPredicate[], options?: FindOptions): any => find(Filters.bySource(...contents), options); 40 | 41 | /** Returns all module results. */ 42 | export const all = { 43 | /** Finds all modules using a set of filter functions. */ 44 | find: (filter: Filter, {resolve = true, entries = false}: FindOptions = {}): any[] => BdApi.Webpack.getModule(filter, { 45 | first: false, 46 | defaultExport: resolve, 47 | searchExports: entries 48 | }) ?? [], 49 | 50 | /** Finds all modules using query options. */ 51 | query: (query: Query, options?: FindOptions): any[] => all.find(Filters.query(query), options), 52 | 53 | /** Finds all modules using the name of its export. */ 54 | byName: (name: string, options?: FindOptions): any[] => all.find(Filters.byName(name), options), 55 | 56 | /** Finds all modules using property names of its export. */ 57 | byKeys: (keys: string[], options?: FindOptions): any[] => all.find(Filters.byKeys(...keys), options), 58 | 59 | /** Finds all modules using prototype names of it export. */ 60 | byProtos: (protos: string[], options?: FindOptions): any[] => all.find(Filters.byProtos(...protos), options), 61 | 62 | /** Finds all modules using source code contents of its export entries. */ 63 | bySource: (contents: TypeOrPredicate[], options?: FindOptions): any[] => all.find(Filters.bySource(...contents), options) 64 | }; 65 | 66 | /** Resolves the key corresponding to the value matching the filter function. */ 67 | export const resolveKey = (target: T, filter: Filters.Filter): [T, string] => [target, Object.entries(target ?? {}).find(([, value]) => filter(value))?.[0]]; 68 | 69 | /** Resolves the key corresponding to the value matching the filter function. */ 70 | export const findWithKey = (filter: Filters.Filter): [Record, string] => resolveKey(find(Filters.byEntry(filter)), filter); 71 | 72 | type Mapping = Record boolean>; 73 | type Mapped = {[K in keyof M]: any}; 74 | 75 | /** 76 | * Finds a module and demangles its export entries by applying filters. 77 | * 78 | * Keys in `required` are the filters required to match a module. 79 | * By default all filters are required. 80 | * 81 | * Using `proxy` the result can be wrapped a proxy making it compatible with e.g. patching. 82 | */ 83 | export const demangle = (mapping: M, required?: (keyof M)[], proxy = false): Mapped => { 84 | const req = required ?? Object.keys(mapping); 85 | 86 | const found = find((target) => ( 87 | Filters.checkObjectValues(target) 88 | && req.every((req) => Object.values(target).some((value) => mapping[req](value))) 89 | )); 90 | 91 | return proxy ? mappedProxy(found, Object.fromEntries(Object.entries(mapping).map(([key, filter]) => [ 92 | key, 93 | Object.entries(found ?? {}).find(([, value]) => filter(value))?.[0] 94 | ]))) as any : Object.fromEntries( 95 | Object.entries(mapping).map(([key, filter]) => [ 96 | key, 97 | Object.values(found ?? {}).find((value) => filter(value)) 98 | ]) 99 | ) as any; 100 | }; 101 | 102 | let controller = new AbortController(); 103 | 104 | /** Waits for a lazy loaded module. */ 105 | // TODO: waitFor with callback that is skipped when aborted? 106 | export const waitFor = (filter: Filter, {resolve = true, entries = false}: FindOptions = {}): Promise => BdApi.Webpack.waitForModule(filter, { 107 | signal: controller.signal, 108 | defaultExport: resolve, 109 | searchExports: entries 110 | }); 111 | 112 | /** Aborts search for any lazy loaded modules. */ 113 | export const abort = (): void => { 114 | // abort current controller 115 | controller.abort(); 116 | 117 | // new controller for future 118 | controller = new AbortController(); 119 | }; 120 | -------------------------------------------------------------------------------- /packages/dium/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * as Data from "./data"; 2 | export * as Filters from "./filters"; 3 | export * as Finder from "./finder"; 4 | export * as Logger from "./logger"; 5 | export * as Patcher from "./patcher"; 6 | export {PatchData, PatchDataWithResult} from "./patcher"; 7 | export * as Styles from "./styles"; 8 | -------------------------------------------------------------------------------- /packages/dium/src/api/logger.ts: -------------------------------------------------------------------------------- 1 | import {getMeta} from "../meta"; 2 | 3 | const COLOR = "#3a71c1"; 4 | 5 | /** Prints data to a custom output. */ 6 | export const print = (output: (...data: any[]) => void, ...data: any[]): void => output( 7 | `%c[${getMeta().name}] %c${getMeta().version ? `(v${getMeta().version})` : ""}`, 8 | `color: ${COLOR}; font-weight: 700;`, 9 | "color: #666; font-size: .8em;", 10 | ...data 11 | ); 12 | 13 | /** Logs a message to the console. */ 14 | export const log = (...data: any[]): void => print(console.log, ...data); 15 | 16 | /** Logs a warning to the console. */ 17 | export const warn = (...data: any[]): void => print(console.warn, ...data); 18 | 19 | /** Logs an error to the console. */ 20 | export const error = (...data: any[]): void => print(console.error, ...data); 21 | -------------------------------------------------------------------------------- /packages/dium/src/api/patcher.ts: -------------------------------------------------------------------------------- 1 | import * as Logger from "./logger"; 2 | import {getMeta} from "../meta"; 3 | 4 | /** Patcher options. */ 5 | export interface Options { 6 | /** Execute the patch once, then unpatch. */ 7 | once?: boolean; 8 | 9 | /** Disable console output when patching. */ 10 | silent?: boolean; 11 | 12 | /** Name of the patch target displayed in console output. */ 13 | name?: string; 14 | } 15 | 16 | export type Cancel = () => void; 17 | 18 | type Args = T extends (...args: any) => any ? Parameters : IArguments; 19 | 20 | type Return = T extends (...args: any) => any ? ReturnType : any; 21 | 22 | type This = ThisParameterType extends unknown ? ( 23 | P extends React.Component ? P : any 24 | ) : ThisParameterType; 25 | 26 | export interface PatchData { 27 | cancel: Cancel; 28 | original: Original; 29 | context: This; 30 | args: Args; 31 | } 32 | 33 | export interface PatchDataWithResult extends PatchData { 34 | result: Return; 35 | } 36 | 37 | const patch = ( 38 | type: "before" | "after" | "instead", 39 | object: Module, 40 | method: Key, 41 | callback: (cancel: Cancel, original: Module[Key], ...args: any) => any, 42 | options: Options 43 | ) => { 44 | const original = object?.[method]; 45 | if (!(original instanceof Function)) { 46 | throw TypeError(`patch target ${original} is not a function`); 47 | } 48 | 49 | const cancel = BdApi.Patcher[type]( 50 | getMeta().name, 51 | object, 52 | method, 53 | options.once ? (...args: any) => { 54 | const result = callback(cancel, original, ...args); 55 | cancel(); 56 | return result; 57 | } : (...args: any) => callback(cancel, original, ...args) 58 | ); 59 | 60 | if (!options.silent) { 61 | Logger.log(`Patched ${options.name ?? String(method)}`); 62 | } 63 | 64 | return cancel; 65 | }; 66 | 67 | /** Patches the method, executing a callback **instead** of the original. */ 68 | export const instead = ( 69 | object: Module, 70 | method: Key, 71 | callback: (data: PatchData) => unknown, 72 | options: Options = {} 73 | ): Cancel => patch( 74 | "instead", 75 | object, 76 | method, 77 | (cancel, original, context, args) => callback({cancel, original, context, args}), 78 | options 79 | ); 80 | 81 | /** 82 | * Patches the method, executing a callback **before** the original. 83 | * 84 | * Typically used to modify arguments passed to the original. 85 | */ 86 | export const before = ( 87 | object: Module, 88 | method: Key, 89 | callback: (data: PatchData) => unknown, 90 | options: Options = {} 91 | ): Cancel => patch( 92 | "before", 93 | object, 94 | method, 95 | (cancel, original, context, args) => callback({cancel, original, context, args}), 96 | options 97 | ); 98 | 99 | /** 100 | * Patches the method, executing a callback **after** the original. 101 | * 102 | * Typically used to modify the return value of the original. 103 | * 104 | * Has access to the original method's return value via `result`. 105 | */ 106 | export const after = ( 107 | object: Module, 108 | method: Key, 109 | callback: (data: PatchDataWithResult) => unknown, 110 | options: Options = {} 111 | ): Cancel => patch( 112 | "after", 113 | object, 114 | method, 115 | (cancel, original, context, args, result) => callback({cancel, original, context, args, result}), 116 | options 117 | ); 118 | 119 | /** Storage for context menu patches. */ 120 | let menuPatches: Cancel[] = []; 121 | 122 | /** Patches a context menu using its "navId". */ 123 | export const contextMenu = ( 124 | navId: string, 125 | callback: (result: React.JSX.Element) => React.JSX.Element | void, 126 | options: Options = {} 127 | ): Cancel => { 128 | const cancel = BdApi.ContextMenu.patch(navId, options.once ? (tree) => { 129 | const result = callback(tree); 130 | cancel(); 131 | return result; 132 | } : callback); 133 | menuPatches.push(cancel); 134 | 135 | if (!options.silent) { 136 | Logger.log(`Patched ${options.name ?? `"${navId}"`} context menu`); 137 | } 138 | 139 | return cancel; 140 | }; 141 | 142 | /** Reverts all patches done by this patcher. */ 143 | export const unpatchAll = (): void => { 144 | if (menuPatches.length + BdApi.Patcher.getPatchesByCaller(getMeta().name).length > 0) { 145 | for (const cancel of menuPatches) { 146 | cancel(); 147 | } 148 | menuPatches = []; 149 | BdApi.Patcher.unpatchAll(getMeta().name); 150 | Logger.log("Unpatched all"); 151 | } 152 | }; 153 | -------------------------------------------------------------------------------- /packages/dium/src/api/styles.ts: -------------------------------------------------------------------------------- 1 | import {getMeta} from "../meta"; 2 | 3 | /** Inject CSS. */ 4 | export const inject = (styles?: string): void => { 5 | if (typeof styles === "string") { 6 | BdApi.DOM.addStyle(getMeta().name, styles); 7 | } 8 | }; 9 | 10 | /** Clear previously injected CSS. */ 11 | export const clear = (): void => BdApi.DOM.removeStyle(getMeta().name); 12 | 13 | /** Create suffixed versions for a list of class names. */ 14 | export const suffix = (...classNames: T[]): Record => { 15 | const result: Record = {} as any; 16 | for (const className of classNames) { 17 | Object.defineProperty(result, className, { 18 | get: () => { 19 | const value = className + "-" + getMeta().name; 20 | Object.defineProperty(result, className, { 21 | value, 22 | configurable: true, 23 | enumerable: true 24 | }); 25 | return value; 26 | }, 27 | configurable: true, 28 | enumerable: true 29 | }); 30 | } 31 | return result; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/dium/src/components/button.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | type Handlers = Pick, "onClick" | "onDoubleClick" | "onMouseDown" | "onMouseUp" | "onMouseEnter" | "onMouseLeave" | "onKeyDown">; 4 | 5 | export interface ButtonProps extends Handlers { 6 | look?: string; 7 | color?: string; 8 | borderColor?: string; 9 | hover?: string; 10 | size?: string; 11 | fullWidth?: any; 12 | grow?: any; 13 | disabled?: boolean; 14 | submitting?: any; 15 | type?: any; 16 | style?: React.CSSProperties; 17 | wrapperClassName?: string; 18 | className?: string; 19 | innerClassName?: string; 20 | children?: React.ReactNode; 21 | rel?: any; 22 | buttonRef?: any; 23 | focusProps?: any; 24 | "aria-label"?: string; 25 | submittingStartedLabel?: any; 26 | submittingFinishedLabel?: any; 27 | } 28 | 29 | export interface Button extends React.FunctionComponent { 30 | Looks: { 31 | FILLED: string; 32 | INVERTED: string; 33 | OUTLINED: string; 34 | LINK: string; 35 | BLANK: string; 36 | }; 37 | Colors: { 38 | BRAND: string; 39 | RED: string; 40 | YELLOW: string; 41 | GREEN: string; 42 | PRIMARY: string; 43 | LINK: string; 44 | WHITE: string; 45 | BLACK: string; 46 | TRANSPARENT: string; 47 | BRAND_NEW: string; 48 | CUSTOM: ""; 49 | }; 50 | BorderColors: { 51 | BRAND: string; 52 | RED: string; 53 | GREEN: string; 54 | YELLOW: string; 55 | PRIMARY: string; 56 | LINK: string; 57 | WHITE: string; 58 | BLACK: string; 59 | TRANSPARENT: string; 60 | BRAND_NEW: string; 61 | }; 62 | Hovers: { 63 | DEFAULT: ""; 64 | BRAND: string; 65 | RED: string; 66 | GREEN: string; 67 | YELLOW: string; 68 | PRIMARY: string; 69 | LINK: string; 70 | WHITE: string; 71 | BLACK: string; 72 | TRANSPARENT: string; 73 | }; 74 | Sizes: { 75 | NONE: ""; 76 | TINY: string; 77 | SMALL: string; 78 | MEDIUM: string; 79 | LARGE: string; 80 | XLARGE: string; 81 | MIN: string; 82 | MAX: string; 83 | ICON: string; 84 | }; 85 | Link: React.FunctionComponent; 86 | } 87 | 88 | export const Button: Button = /* @__PURE__ */ Finder.byKeys(["Colors", "Link"], {entries: true}); 89 | -------------------------------------------------------------------------------- /packages/dium/src/components/clickable.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export interface ClickableProps { 4 | role?: string; 5 | tabIndex?: number; 6 | tag?: string; 7 | href?: string; 8 | className?: string; 9 | onClick?: React.MouseEventHandler; 10 | onKeyPress?: React.KeyboardEventHandler; 11 | ignoreKeyPress?: boolean; 12 | children?: React.ReactNode; 13 | focusProps?: any; 14 | innerRef?: React.Ref; 15 | } 16 | 17 | export interface Clickable extends React.ComponentClass { 18 | contextType: React.Context; 19 | defaultProps: { 20 | role: "button"; 21 | tabIndex: 0; 22 | tag: "div"; 23 | }; 24 | } 25 | 26 | export const Clickable: Clickable = /* @__PURE__ */ Finder.bySource(["ignoreKeyPress:"], {entries: true}); 27 | -------------------------------------------------------------------------------- /packages/dium/src/components/common.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | type CommonComponents = Record; 4 | 5 | // most are mangled 6 | export const Common: CommonComponents = /* @__PURE__ */ Finder.byKeys(["ConfirmModal", "Text"]); 7 | -------------------------------------------------------------------------------- /packages/dium/src/components/embed.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import {Embed as MessageEmbed} from "../modules/message"; 3 | 4 | export interface EmbedProps { 5 | embed?: MessageEmbed; 6 | className?: string; 7 | allowFullScreen?: boolean; 8 | autoPlayGif?: boolean; 9 | hideMedia?: boolean; 10 | maxMediaHeight?: number; 11 | maxMediaWidth?: number; 12 | maxThumbnailHeight?: number; 13 | maxThumbnailWidth?: number; 14 | spoiler?: boolean; 15 | onSuppressEmbed?: (event: React.MouseEvent) => void; 16 | renderTitle: (embed: MessageEmbed, title: string) => React.JSX.Element; 17 | renderDescription: (embed: MessageEmbed, description: string) => React.JSX.Element; 18 | renderLinkComponent: React.FunctionComponent; 19 | renderImageComponent: React.FunctionComponent; 20 | renderVideoComponent: React.FunctionComponent; 21 | } 22 | 23 | export interface Embed extends React.ComponentClass { 24 | defaultProps: { 25 | allowFullScreen: true; 26 | hideMedia: false; 27 | maxMediaHeight: 300; 28 | maxMediaWidth: 400; 29 | maxThumbnailHeight: 80; 30 | maxThumbnailWidth: 80; 31 | spoiler: false; 32 | }; 33 | } 34 | 35 | export const Embed: Embed = /* @__PURE__ */ Finder.byProtos(["renderSuppressButton"], {entries: true}); 36 | -------------------------------------------------------------------------------- /packages/dium/src/components/flex.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export interface FlexProps { 4 | children?: React.ReactNode; 5 | className?: string; 6 | direction?: string; 7 | justify?: string; 8 | align?: string; 9 | wrap?: string; 10 | shrink?: number; 11 | grow?: number; 12 | basis?: string; 13 | style?: React.CSSProperties; 14 | } 15 | 16 | export interface Flex extends React.FunctionComponent { 17 | Direction: { 18 | VERTICAL: string; 19 | HORIZONTAL: string; 20 | HORIZONTAL_REVERSE: string; 21 | }; 22 | Justify: { 23 | AROUND: string; 24 | BETWEEN: string; 25 | CENTER: string; 26 | END: string; 27 | START: string; 28 | }; 29 | Align: { 30 | STRETCH: string; 31 | START: string; 32 | END: string; 33 | CENTER: string; 34 | BASELINE: string; 35 | }; 36 | Wrap: { 37 | NO_WRAP: string; 38 | WRAP: string; 39 | WRAP_REVERSE: string; 40 | }; 41 | defaultProps: { 42 | shrink: 1; 43 | grow: 1; 44 | basis: "auto"; 45 | }; 46 | Child: React.FunctionComponent; 47 | } 48 | 49 | export const Flex: Flex = /* @__PURE__ */ Finder.byKeys(["Child", "Justify", "Align"], {entries: true}); 50 | -------------------------------------------------------------------------------- /packages/dium/src/components/form.ts: -------------------------------------------------------------------------------- 1 | import {Finder, Filters} from "../api"; 2 | 3 | export const enum FormTags { 4 | H1 = "h1", 5 | H2 = "h2", 6 | H3 = "h3", 7 | H4 = "h4", 8 | H5 = "h5", 9 | LABEL = "label", 10 | LEGEND = "legend" 11 | } 12 | 13 | export interface FormSectionProps { 14 | children?: React.ReactNode; 15 | className?: string; 16 | titleClassName?: string; 17 | title?: React.ReactNode; 18 | icon?: any; 19 | disabled?: boolean; 20 | htmlFor?: any; 21 | tag?: string; 22 | } 23 | 24 | export interface FormSection extends React.FunctionComponent { 25 | Tags: typeof FormTags; 26 | } 27 | 28 | export interface FormItemProps { 29 | children?: React.ReactNode; 30 | disabled?: boolean; 31 | className?: string; 32 | titleClassName?: string; 33 | tag?: string; 34 | required?: boolean; 35 | style?: React.CSSProperties; 36 | title?: any; 37 | error?: any; 38 | } 39 | 40 | export interface FormItem extends React.FunctionComponent { 41 | Tags: typeof FormTags; 42 | } 43 | 44 | export interface FormTitleProps { 45 | tag?: string; 46 | children?: React.ReactNode; 47 | className?: string; 48 | faded?: boolean; 49 | disabled?: boolean; 50 | required?: boolean; 51 | error?: any; 52 | } 53 | 54 | export interface FormTitle extends React.FunctionComponent { 55 | Tags: typeof FormTags; 56 | } 57 | 58 | export interface FormTextProps { 59 | type?: string; 60 | className?: string; 61 | disabled?: boolean; 62 | selectable?: boolean; 63 | children?: React.ReactNode; 64 | style?: React.CSSProperties; 65 | } 66 | 67 | export const enum FormTextTypes { 68 | INPUT_PLACEHOLDER = "placeholder", 69 | DESCRIPTION = "description", 70 | LABEL_BOLD = "labelBold", 71 | LABEL_SELECTED = "labelSelected", 72 | LABEL_DESCRIPTOR = "labelDescriptor", 73 | ERROR = "error", 74 | SUCCESS = "success" 75 | } 76 | 77 | export interface FormText extends React.FunctionComponent { 78 | Types: typeof FormTextTypes; 79 | } 80 | 81 | export interface FormSwitchProps { 82 | value?: boolean; 83 | disabled?: boolean; 84 | hideBorder?: boolean; 85 | tooltipNote?: any; 86 | onChange?: (checked: boolean) => void; 87 | className?: string; 88 | style?: React.CSSProperties; 89 | note?: any; 90 | helpdeskArticleId?: any; 91 | children?: React.ReactNode; 92 | } 93 | 94 | export const enum FormNoticeTypes { 95 | BRAND = "cardBrand", 96 | CUSTOM = "card", 97 | DANGER = "cardDanger", 98 | PRIMARY = "cardPrimary", 99 | SUCCESS = "cardSuccess", 100 | WARNING = "cardWarning" 101 | } 102 | 103 | export interface FormNoticeProps { 104 | type?: string; 105 | imageData?: { 106 | src: string; 107 | height?: number; 108 | width?: number; 109 | position?: "left" | "right"; 110 | }; 111 | button?: any; 112 | className?: string; 113 | iconClassName?: string; 114 | title?: React.ReactNode; 115 | body?: React.ReactNode; 116 | style?: React.CSSProperties; 117 | align?: string; 118 | } 119 | 120 | export interface FormNotice extends React.FunctionComponent { 121 | Types: typeof FormNoticeTypes; 122 | } 123 | 124 | interface FormComponents { 125 | FormSection: FormSection; 126 | FormItem: FormItem; 127 | FormTitle: FormTitle; 128 | FormText: FormText; 129 | // FormLabel: React.FunctionComponent; 130 | FormDivider: React.FunctionComponent; 131 | FormSwitch: React.FunctionComponent; 132 | FormNotice: FormNotice; 133 | } 134 | 135 | export const { 136 | FormSection, 137 | FormItem, 138 | FormTitle, 139 | FormText, 140 | // FormLabel, 141 | FormDivider, 142 | FormSwitch, 143 | FormNotice 144 | }: FormComponents = /* @__PURE__ */ Finder.demangle({ 145 | FormSection: Filters.bySource("titleClassName:", ".sectionTitle"), 146 | FormItem: Filters.bySource("titleClassName:", "required:"), 147 | FormTitle: Filters.bySource("faded:", "required:"), 148 | FormText: (target) => target.Types?.INPUT_PLACEHOLDER, 149 | FormDivider: Filters.bySource(".divider", "style:"), 150 | FormSwitch: Filters.bySource("tooltipNote:"), 151 | FormNotice: Filters.bySource("imageData:", ".formNotice") 152 | } as const, ["FormSection", "FormItem", "FormDivider"]); 153 | -------------------------------------------------------------------------------- /packages/dium/src/components/guild.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export const GuildsNav: React.MemoExoticComponent> = /* @__PURE__ */ Finder.bySource(["guildsnav"], {entries: true}); 4 | 5 | export const GuildItem: React.MemoExoticComponent> = /* @__PURE__ */ Finder.bySource(["folderNode", ".isFolderExpanded"]); 6 | 7 | export const HomeButton: React.FunctionComponent = /* @__PURE__ */ Finder.bySource(["unviewedTrialCount", "unviewedDiscountCount"], {entries: true}); 8 | 9 | export const GuildSeparator: React.FunctionComponent = /* @__PURE__ */ Finder.bySource([".guildSeparator"]); 10 | -------------------------------------------------------------------------------- /packages/dium/src/components/icon.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export interface IconArrowProps extends Record { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | colorClass?: string; 8 | } 9 | 10 | // chevron small down icon 11 | export const IconArrow: React.FunctionComponent = /* @__PURE__ */ Finder.bySource(["d:\"M5.3 9."], {entries: true}); 12 | -------------------------------------------------------------------------------- /packages/dium/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./button"; 2 | export * from "./clickable"; 3 | export * from "./common"; 4 | export * from "./embed"; 5 | export * from "./flex"; 6 | export * from "./form"; 7 | export * from "./guild"; 8 | export * from "./icon"; 9 | export * from "./link"; 10 | export * from "./margins"; 11 | export * from "./menu"; 12 | export * from "./message"; 13 | export * from "./radio"; 14 | export * from "./select"; 15 | export * from "./slider"; 16 | export * from "./switch"; 17 | export * from "./text-area"; 18 | export * from "./input"; 19 | export * from "./text"; 20 | -------------------------------------------------------------------------------- /packages/dium/src/components/input.ts: -------------------------------------------------------------------------------- 1 | import {Finder, Filters} from "../api"; 2 | 3 | export const enum TextInputSizes { 4 | DEFAULT = "default", 5 | MINI = "mini" 6 | } 7 | 8 | export interface TextInputProps extends Omit, "size" | "onChange"> { 9 | value?: string; 10 | name?: string; 11 | placeholder?: string; 12 | error?: any; 13 | minLength?: any; 14 | maxLength?: any; 15 | onChange?: (value: string) => void; 16 | className?: string; 17 | inputClassName?: string; 18 | inputPrefix?: any; 19 | disabled?: boolean; 20 | size?: string; 21 | editable?: boolean; 22 | autoFocus?: boolean; 23 | inputRef?: React.Ref; 24 | prefixElement?: any; 25 | focusProps?: any; 26 | titleId?: any; 27 | "aria-labelledby"?: any; 28 | } 29 | 30 | export interface TextInput extends React.ComponentClass { 31 | Sizes: typeof TextInputSizes; 32 | contextType: React.Context; 33 | defaultProps: { 34 | name: ""; 35 | size: "default"; 36 | disabled: false; 37 | type: "text"; 38 | placeholder: ""; 39 | autoFocus: false; 40 | maxLength: 999; 41 | }; 42 | } 43 | 44 | interface InputComponents { 45 | TextInput: TextInput; 46 | InputError: React.FunctionComponent; 47 | } 48 | 49 | export const {TextInput, InputError}: InputComponents = /* @__PURE__ */ Finder.demangle({ 50 | TextInput: (target) => target?.defaultProps?.type === "text", 51 | InputError: Filters.bySource("error:", "text-danger") 52 | } as const, ["TextInput"]); 53 | 54 | export const ImageInput: React.ComponentClass = /* @__PURE__ */ Finder.find( 55 | (target) => typeof target.defaultProps?.multiple === "boolean" && typeof target.defaultProps?.maxFileSizeBytes === "number" 56 | ); 57 | -------------------------------------------------------------------------------- /packages/dium/src/components/link.ts: -------------------------------------------------------------------------------- 1 | import {Filters, Finder} from "../api"; 2 | import type {Router} from "../modules"; 3 | 4 | export interface LinkProps extends Record { 5 | component?: any; 6 | replace?: any; 7 | to?: any; 8 | innerRef?: React.Ref; 9 | history?: any; 10 | location?: any; 11 | children?: React.ReactNode; 12 | className?: string; 13 | tabIndex?: number; 14 | } 15 | 16 | interface Links extends Omit { 17 | BrowserRouter: React.ComponentClass; 18 | Link: React.ForwardRefExoticComponent; 19 | } 20 | 21 | const mapping = { 22 | Link: Filters.bySource(".component", ".to"), 23 | BrowserRouter: Filters.bySource("this.history") 24 | // NavLink: Filters.bySource(".sensitive", ".to"), 25 | }; 26 | 27 | export const {Link, BrowserRouter}: Pick = /* @__PURE__ */ Finder.demangle(mapping, ["Link", "BrowserRouter"]); 28 | -------------------------------------------------------------------------------- /packages/dium/src/components/margins.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export interface Margins { 4 | marginBottom4: string; 5 | marginBottom8: string; 6 | marginBottom20: string; 7 | marginBottom40: string; 8 | marginBottom60: string; 9 | marginCenterHorz: string; 10 | marginLeft8: string; 11 | marginReset: string; 12 | marginTop4: string; 13 | marginTop8: string; 14 | marginTop20: string; 15 | marginTop40: string; 16 | marginTop60: string; 17 | } 18 | 19 | export const margins: Margins = /* @__PURE__ */ Finder.byKeys(["marginBottom40", "marginTop4"]); 20 | -------------------------------------------------------------------------------- /packages/dium/src/components/menu.ts: -------------------------------------------------------------------------------- 1 | export interface MenuProps extends Record { 2 | navId: string; 3 | onClose: () => void; 4 | onSelect: () => void; 5 | "aria-label"?: string; 6 | } 7 | 8 | export interface MenuGroupProps { 9 | children: React.ReactNode; 10 | } 11 | 12 | interface BaseItemProps { 13 | id: string; 14 | label?: React.ReactNode; 15 | subtext?: React.ReactNode; 16 | isFocused?: boolean; 17 | action?: () => void; 18 | onClose?: () => void; 19 | } 20 | 21 | export interface MenuItemProps extends BaseItemProps { 22 | color?: string; 23 | hint?: any; 24 | children?: React.ReactNode; 25 | icon?: React.ComponentType; 26 | iconProps?: any; 27 | showIconFirst?: boolean; 28 | imageUrl?: any; 29 | render?: () => React.JSX.Element; 30 | } 31 | 32 | export interface MenuCheckboxItemProps extends BaseItemProps { 33 | checked?: boolean; 34 | disabled?: boolean; 35 | } 36 | 37 | export interface MenuRadioItemProps extends BaseItemProps { 38 | checked?: boolean; 39 | group?: string; 40 | } 41 | 42 | export interface MenuControlItemProps extends BaseItemProps { 43 | control: (props: ControlProps, ref: React.MutableRefObject) => React.JSX.Element; 44 | } 45 | 46 | interface ControlProps { 47 | disabled?: boolean; 48 | isFocused?: boolean; 49 | onClose: () => void; 50 | } 51 | 52 | interface MenuComponents { 53 | Menu: React.FunctionComponent; 54 | Group: React.FunctionComponent; 55 | Item: React.FunctionComponent; 56 | Separator: React.FunctionComponent; 57 | CheckboxItem: React.FunctionComponent; 58 | RadioItem: React.FunctionComponent; 59 | ControlItem: React.FunctionComponent; 60 | } 61 | 62 | export const { 63 | Menu, 64 | Group: MenuGroup, 65 | Item: MenuItem, 66 | Separator: MenuSeparator, 67 | CheckboxItem: MenuCheckboxItem, 68 | RadioItem: MenuRadioItem, 69 | ControlItem: MenuControlItem 70 | } = BdApi.ContextMenu as MenuComponents; 71 | -------------------------------------------------------------------------------- /packages/dium/src/components/message.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import {Attachment, Channel, Message} from "../modules"; 3 | 4 | export interface MessageFooterProps { 5 | channel: Channel; 6 | message: Message; 7 | compact: boolean; 8 | className?: string; 9 | canDeleteAttachments?: boolean; 10 | canSuppressEmbeds?: boolean; 11 | disableReactionCreates?: boolean; 12 | disableReactionReads?: boolean; 13 | disableReactionUpdates?: boolean; 14 | gifAutoPlay?: boolean; 15 | hasSpoilerEmbeds?: boolean; 16 | inlineAttachmentMedia?: boolean; 17 | inlineEmbedMedia?: boolean; 18 | isCurrentUser?: boolean; 19 | isInteracting?: boolean; 20 | isLurking?: boolean; 21 | isPendingMember?: boolean; 22 | forceAddReactions?: any; 23 | onAttachmentContextMenu?: (e: any, t: any) => any; 24 | renderComponentAccessory?: any; 25 | renderEmbeds?: boolean; 26 | renderSuppressEmbeds?: any; 27 | renderThreadAccessory?: any; 28 | } 29 | 30 | export interface MessageFooter extends React.ComponentClass { 31 | defaultProps: { 32 | compact: false; 33 | renderEmbeds: true; 34 | }; 35 | } 36 | 37 | export const MessageFooter: MessageFooter = /* @__PURE__ */ Finder.byProtos(["renderRemoveAttachmentConfirmModal"], {entries: true}); 38 | 39 | export interface MediaItemProps extends Record { 40 | mediaLayoutType: string; 41 | isSingleMosaicItem: boolean; 42 | maxWidth: number; 43 | maxHeight: number; 44 | message: Message; 45 | item: { 46 | uniqueId: string; 47 | type: string; 48 | contentType: string; 49 | height: number; 50 | width: number; 51 | downloadUrl: string; 52 | originalItem: Attachment; 53 | spoiler: boolean; 54 | }; 55 | useFullWidth: boolean; 56 | canRemoveItem: boolean; 57 | autoPlayGif: boolean; 58 | className: string; 59 | imgClassName: string; 60 | imgContainerClassName: string; 61 | footer?: any; 62 | onClick(); 63 | onPlay(); 64 | onRemoveItem(); 65 | renderAudioComponent(): any; 66 | renderGenericFileComponent(): any; 67 | renderImageComponent(): any; 68 | renderMosaicItemFooter(): any; 69 | renderPlaintextFilePreview(): any; 70 | renderVideoComponent(): any; 71 | getObscureReason(): any; 72 | gifFavoriteButton(): any; 73 | } 74 | 75 | export const MediaItem: React.FunctionComponent = /* @__PURE__ */ Finder.bySource(["getObscureReason", "isSingleMosaicItem"]); 76 | -------------------------------------------------------------------------------- /packages/dium/src/components/radio.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export interface RadioGroupOption { 4 | name: React.ReactNode; 5 | value: T; 6 | desc?: React.ReactNode; 7 | } 8 | 9 | export interface RadioGroupProps { 10 | itemInfoClassName?: string; 11 | itemTitleClassName?: string; 12 | radioItemClassName?: string; 13 | className?: string; 14 | value?: T; 15 | size?: string; 16 | onChange?: (option: RadioGroupOption) => void; 17 | disabled?: boolean; 18 | options?: RadioGroupOption[]; 19 | "aria-labelledby"?: any; 20 | orientation?: any; 21 | withTransparentBackground?: any; 22 | } 23 | 24 | export interface RadioGroup { 25 | (props: RadioGroupProps): React.JSX.Element; 26 | Sizes: { 27 | MEDIUM: string; 28 | NONE: string; 29 | NOT_SET: string; 30 | SMALL: string; 31 | }; 32 | } 33 | 34 | export const RadioGroup: RadioGroup = /* @__PURE__ */ Finder.bySource(["radioPosition:", "radioItemClassName:", "options:"], {entries: true}); 35 | -------------------------------------------------------------------------------- /packages/dium/src/components/select.ts: -------------------------------------------------------------------------------- 1 | import {Finder, Filters} from "../api"; 2 | 3 | export interface SelectOption { 4 | label: React.ReactNode; 5 | value: T; 6 | } 7 | 8 | export interface SelectProps> { 9 | options: O[]; 10 | placeholder?: any; 11 | className?: string; 12 | isDisabled?: boolean; 13 | maxVisibleItems?: any; 14 | look?: any; 15 | autoFocus?: any; 16 | popoutWidth?: any; 17 | clearable?: boolean; 18 | onClose?: (...args: any) => void; 19 | onOpen?: (...args: any) => void; 20 | renderOptionLabel?: (option: O) => React.JSX.Element; 21 | renderOptionValue?: (option: O[]) => React.JSX.Element; 22 | popoutClassName?: string; 23 | popoutPosition?: any; 24 | optionClassName?: string; 25 | closeOnSelect?: any; 26 | select?: (value: T) => void; 27 | isSelected?: (value: T) => boolean; 28 | clear?: () => void; 29 | serialize?: (value: T) => string; 30 | hideIcon?: boolean; 31 | "aria-label"?: any; 32 | "aria-labelledby"?: any; 33 | } 34 | 35 | export interface SingleSelectProps> extends Omit, "select" | "isSelected" | "clear"> { 36 | value: T; 37 | onChange?: (value: T) => void; 38 | } 39 | 40 | interface SelectComponents { 41 | Select: >(props: SelectProps) => React.JSX.Element; 42 | SingleSelect: >(props: SingleSelectProps) => React.JSX.Element; 43 | } 44 | 45 | export const {Select, SingleSelect}: SelectComponents = /* @__PURE */ Finder.demangle({ 46 | Select: Filters.bySource("renderOptionLabel:", "renderOptionValue:", "popoutWidth:"), 47 | SingleSelect: Filters.bySource((source) => /{value:[a-zA-Z_$],onChange:[a-zA-Z_$]}/.test(source)) 48 | }, ["Select"]); 49 | -------------------------------------------------------------------------------- /packages/dium/src/components/slider.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export interface SliderProps extends Record { 4 | initialValue?: number; 5 | maxValue?: number; 6 | minValue?: number; 7 | disabled?: boolean; 8 | handleSize?: number; 9 | keyboardStep?: number; 10 | asValueChanges?: any; 11 | stickToMarkers?: boolean; 12 | className?: string; 13 | children?: React.ReactNode; 14 | barStyles?: React.CSSProperties; 15 | fillStyles?: React.CSSProperties; 16 | mini?: any; 17 | hideBubble?: any; 18 | defaultValue?: any; 19 | orientation?: any; 20 | onValueRender?: any; 21 | renderMarker?: any; 22 | getAriaValueText?: any; 23 | barClassName?: string; 24 | grabberClassName?: string; 25 | grabberStyles?: any; 26 | markerPosition?: any; 27 | "aria-hidden"?: any; 28 | "aria-label"?: any; 29 | "aria-labelledby"?: any; 30 | "aria-describedby"?: any; 31 | } 32 | 33 | export interface SliderState extends Record { 34 | value: any; 35 | active: any; 36 | focused: any; 37 | sortedMarkers: any; 38 | markerPositions: any; 39 | closestMarkerIndex: any; 40 | newClosestIndex: any; 41 | min: any; 42 | max: any; 43 | } 44 | 45 | export interface Slider extends React.ComponentClass { 46 | defaultProps: { 47 | disabled: false; 48 | fillStyles: Record; 49 | handleSize: 10; 50 | initialValue: 10; 51 | keyboardStep: 1; 52 | maxValue: 100; 53 | minValue: 0; 54 | stickToMarkers: false; 55 | }; 56 | } 57 | 58 | export const Slider: Slider = /* @__PURE__ */ Finder.bySource(["markerPositions:", "asValueChanges:"], {entries: true}); 59 | -------------------------------------------------------------------------------- /packages/dium/src/components/switch.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export interface SwitchProps { 4 | id?: any; 5 | onChange?: (checked: boolean) => void; 6 | checked?: boolean; 7 | disabled?: boolean; 8 | className?: string; 9 | focusProps?: any; 10 | innerRef?: any; 11 | } 12 | 13 | export const Switch: React.FunctionComponent = /* @__PURE__ */ Finder.bySource(["checked:", "reducedMotion:"], {entries: true}); 14 | -------------------------------------------------------------------------------- /packages/dium/src/components/text-area.ts: -------------------------------------------------------------------------------- 1 | import * as Finder from "../api/finder"; 2 | import {ForwardRefExoticComponent, MemoExoticComponent} from "../react-internals"; 3 | 4 | export const ChannelTextArea: MemoExoticComponent> = Finder.bySource(["pendingReply"]); 5 | -------------------------------------------------------------------------------- /packages/dium/src/components/text.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export interface TextProps { 4 | variant?: TextVariants; 5 | tag?: any; 6 | selectable?: boolean; 7 | tabularNumbers?: boolean; 8 | scaleFontToUserSetting?: boolean; 9 | className?: string; 10 | lineClamp?: number; 11 | color?: string; 12 | style?: React.CSSProperties; 13 | children?: React.ReactNode; 14 | } 15 | 16 | type TextVariants = 17 | "code" 18 | | "display-lg" 19 | | "display-md" 20 | | "display-sm" 21 | | "eyebrow" 22 | | "heading-deprecated-12/bold" 23 | | "heading-deprecated-12/extrabold" 24 | | "heading-deprecated-12/medium" 25 | | "heading-deprecated-12/normal" 26 | | "heading-deprecated-12/semibold" 27 | | "heading-lg/bold" 28 | | "heading-lg/extrabold" 29 | | "heading-lg/medium" 30 | | "heading-lg/normal" 31 | | "heading-lg/semibold" 32 | | "heading-md/bold" 33 | | "heading-md/extrabold" 34 | | "heading-md/medium" 35 | | "heading-md/normal" 36 | | "heading-md/semibold" 37 | | "heading-sm/bold" 38 | | "heading-sm/extrabold" 39 | | "heading-sm/medium" 40 | | "heading-sm/normal" 41 | | "heading-sm/semibold" 42 | | "heading-xl/bold" 43 | | "heading-xl/extrabold" 44 | | "heading-xl/medium" 45 | | "heading-xl/normal" 46 | | "heading-xl/semibold" 47 | | "heading-xxl/bold" 48 | | "heading-xxl/extrabold" 49 | | "heading-xxl/medium" 50 | | "heading-xxl/normal" 51 | | "heading-xxl/semibold" 52 | | "text-lg/bold" 53 | | "text-lg/medium" 54 | | "text-lg/normal" 55 | | "text-lg/semibold" 56 | | "text-md/bold" 57 | | "text-md/medium" 58 | | "text-md/normal" 59 | | "text-md/semibold" 60 | | "text-sm/bold" 61 | | "text-sm/medium" 62 | | "text-sm/normal" 63 | | "text-sm/semibold" 64 | | "text-xs/bold" 65 | | "text-xs/medium" 66 | | "text-xs/normal" 67 | | "text-xs/semibold" 68 | | "text-xxs/bold" 69 | | "text-xxs/medium" 70 | | "text-xxs/normal" 71 | | "text-xxs/semibold"; 72 | 73 | export const Text: React.FunctionComponent = /* @__PURE__ */ Finder.bySource(["lineClamp:", "variant:", "tabularNumbers:"], {entries: true}); 74 | -------------------------------------------------------------------------------- /packages/dium/src/index.tsx: -------------------------------------------------------------------------------- 1 | import {Logger, Finder, Styles, Patcher} from "./api"; 2 | import {SettingsStore} from "./settings"; 3 | import {React} from "./modules"; 4 | import {SettingsContainer} from "./settings-container"; 5 | import type * as BD from "betterdiscord"; 6 | import type * as Webpack from "./require"; 7 | import {setMeta} from "./meta"; 8 | 9 | export * from "./api"; 10 | export {createSettings, SettingsStore, SettingsType} from "./settings"; 11 | export {ReactInternals, ReactDOMInternals, Fiber} from "./react-internals"; 12 | export * as Utils from "./utils"; 13 | export {React, ReactDOM, Flux} from "./modules"; 14 | export {getMeta, setMeta, Meta} from "./meta"; 15 | export {version} from "../package.json"; 16 | export type {Webpack}; 17 | 18 | export interface Plugin> { 19 | /** Called on plugin start. */ 20 | start?(): void | Promise; 21 | 22 | /** 23 | * Called on plugin stop. 24 | * 25 | * Be cautious when doing async work here. 26 | */ 27 | stop?(): void; 28 | 29 | /** 30 | * Plugin styles. 31 | * 32 | * Passed as CSS in string form. 33 | * Injected/removed when the plugin is started/stopped. 34 | */ 35 | styles?: string; 36 | 37 | /** Plugin settings store. */ 38 | Settings?: SettingsStore; 39 | 40 | /** Settings UI as React component. */ 41 | SettingsPanel?: React.ComponentType; 42 | } 43 | 44 | /** 45 | * Creates a BetterDiscord plugin. 46 | * 47 | * @param plugin Plugin or callback receiving the meta and returning a plugin. 48 | */ 49 | export const createPlugin = >( 50 | plugin: Plugin | ((meta: BD.Meta) => Plugin) 51 | ): BD.PluginCallback => (meta) => { 52 | // set meta 53 | setMeta(meta); 54 | 55 | // get plugin info 56 | const {start, stop, styles, Settings, SettingsPanel} = (plugin instanceof Function ? plugin(meta) : plugin); 57 | 58 | // load settings 59 | Settings?.load(); 60 | 61 | // construct plugin 62 | return { 63 | start() { 64 | Logger.log("Enabled"); 65 | Styles.inject(styles); 66 | start?.(); 67 | }, 68 | stop() { 69 | Finder.abort(); 70 | Patcher.unpatchAll(); 71 | Styles.clear(); 72 | stop?.(); 73 | Logger.log("Disabled"); 74 | }, 75 | getSettingsPanel: SettingsPanel ? () => ( 76 | Settings.reset() : null}> 77 | 78 | 79 | ) : null 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /packages/dium/src/meta.ts: -------------------------------------------------------------------------------- 1 | import type * as BD from "betterdiscord"; 2 | 3 | export type Meta = BD.Meta; 4 | 5 | /** Meta of this plugin. */ 6 | let meta: Meta = null; 7 | 8 | /** 9 | * Returns the plugin meta. 10 | * 11 | * This will throw an error when accessed before the plugin was initialized. 12 | */ 13 | export const getMeta = (): Meta => { 14 | if (meta) { 15 | return meta; 16 | } else { 17 | throw Error("Accessing meta before initialization"); 18 | } 19 | }; 20 | 21 | /** 22 | * Updates the plugin meta. 23 | * 24 | * This is populated with information automatically, but can be called manually as well. 25 | */ 26 | export const setMeta = (newMeta: Meta): void => { 27 | meta = newMeta; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/dium/src/modules/channel.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import type {Snowflake, ActionModule} from "./general"; 3 | import type {Store} from "./flux"; 4 | 5 | /** A Channel. */ 6 | export interface Channel { 7 | id: Snowflake; 8 | name: string; 9 | type: ChannelTypes; 10 | topic: string; 11 | 12 | bitrate: number; 13 | defaultAutoArchiveDuration?: any; 14 | icon?: any; 15 | userLimit: number; 16 | 17 | member?: any; 18 | memberCount?: any; 19 | memberIdsPreview?: any; 20 | memberListId?: any; 21 | messageCount?: any; 22 | 23 | nicks: Record; 24 | nsfw: boolean; 25 | 26 | originChannelId?: Snowflake; 27 | ownerId?: Snowflake; 28 | 29 | permissionOverwrites: Record; 35 | 36 | position: number; 37 | lastMessageId: Snowflake; 38 | lastPinTimestamp: string; 39 | rateLimitPerUser: number; 40 | rawRecipients: any[]; 41 | recipients: any[]; 42 | rtcRegion?: any; 43 | threadMetadata?: any; 44 | videoQualityMode?: any; 45 | 46 | accessPermission: any; 47 | lastActiveTimestamp: any; 48 | viewPermission: any; 49 | 50 | computeLurkerPermissionsAllowList(): any; 51 | getApplicationId(): any; 52 | getGuildId(): Snowflake; 53 | getRecipientId(): any; 54 | 55 | isActiveThread(): boolean; 56 | isArchivedThread(): boolean; 57 | isCategory(): boolean; 58 | isDM(): boolean; 59 | isDirectory(): boolean; 60 | isGroupDM(): boolean; 61 | isGuildStageVoice(): boolean; 62 | isGuildVoice(): boolean; 63 | isListenModeCapable(): boolean; 64 | isManaged(): boolean; 65 | isMultiUserDM(): boolean; 66 | isNSFW(): boolean; 67 | isOwner(memberId: Snowflake): boolean; 68 | isPrivate(): boolean; 69 | isSubscriptionGatedInGuild(arg: any): boolean; 70 | isSystemDM(): boolean; 71 | isThread(): boolean; 72 | isVocal(): boolean; 73 | } 74 | 75 | /** Types of Channels. */ 76 | export const enum ChannelTypes { 77 | GuildText = 0, 78 | DM = 1, 79 | GuildVoice = 2, 80 | GroupDM = 3, 81 | GuildCategory = 4, 82 | GuildAnnouncement = 5, 83 | GuildStore = 6, 84 | AnnouncementThread = 10, 85 | PublicThread = 11, 86 | PrivateThread = 12, 87 | GuildStageVoice = 13, 88 | GuildDirectory = 14, 89 | GuildForum = 15 90 | } 91 | 92 | export interface ChannelStore extends Store { 93 | getAllThreadsForParent(e); 94 | getBasicChannel(e); 95 | getCachedChannelJsonForGuild(e); 96 | getChannel(id: Snowflake): Channel; 97 | getDMFromUserId(e); 98 | getDMUserIds(); 99 | getGuildChannelsVersion(e); 100 | getInitialOverlayState(); 101 | getMutableBasicGuildChannelsForGuild(e); 102 | getMutableGuildChannelsForGuild(e); 103 | getMutablePrivateChannels(); 104 | getPrivateChannelsVersion(); 105 | getSortedPrivateChannels(); 106 | hasChannel(e); 107 | hasRestoredGuild(e); 108 | loadAllGuildAndPrivateChannelsFromDisk(); 109 | __getLocalVars(); 110 | } 111 | 112 | export const ChannelStore: ChannelStore = /* @__PURE__ */ Finder.byName("ChannelStore"); 113 | 114 | export const ChannelActions: ActionModule = /* @__PURE__ */ Finder.byKeys(["selectChannel"]); 115 | 116 | export interface SelectedChannelStore extends Store { 117 | getChannelId(e); 118 | getCurrentlySelectedChannelId(e); 119 | getLastChannelFollowingDestination(); 120 | getLastSelectedChannelId(e?: any): Snowflake; 121 | getLastSelectedChannels(e); 122 | getMostRecentSelectedTextChannelId(e); 123 | getVoiceChannelId(): Snowflake; 124 | __getLocalVars(); 125 | } 126 | 127 | export const SelectedChannelStore: SelectedChannelStore = /* @__PURE__ */ Finder.byName("SelectedChannelStore"); 128 | 129 | export interface VoiceState { 130 | channelId: Snowflake; 131 | userId: Snowflake; 132 | sessionId: string; 133 | deaf: boolean; 134 | mute: boolean; 135 | selfMute: boolean; 136 | selfDeaf: boolean; 137 | selfVideo: boolean; 138 | selfStream: boolean; 139 | suppress: boolean; 140 | requestToSpeakTimestamp?: any; 141 | } 142 | 143 | export interface VoiceStateStore extends Store { 144 | getAllVoiceStates(): Record>; 145 | getVoiceStates(id?: string): Record; 146 | getVoiceStatesForChannel(channelId: Snowflake): Record; 147 | getVideoVoiceStatesForChannel(channelId: Snowflake): any; 148 | 149 | getVoiceState(id: string, userId: Snowflake): VoiceState; 150 | getVoiceStateForChannel(channelId: Snowflake, userId?: Snowflake): VoiceState; 151 | getVoiceStateForSession(userId: Snowflake, session?: string): VoiceState; 152 | getVoiceStateForUser(userId: Snowflake): VoiceState; 153 | 154 | getCurrentClientVoiceChannelId(id: string): Snowflake; 155 | getUserVoiceChannelId(id: string, userId: Snowflake): Snowflake; 156 | 157 | hasVideo(userId: Snowflake): boolean; 158 | isCurrentClientInVoiceChannel(): boolean; 159 | isInChannel(channelId: Snowflake, userId?: Snowflake): boolean; 160 | 161 | get userHasBeenMovedVersion(): number; 162 | __getLocalVars(): any; 163 | } 164 | 165 | export const VoiceStateStore: VoiceStateStore = /* @__PURE__ */ Finder.byName("VoiceStateStore"); 166 | -------------------------------------------------------------------------------- /packages/dium/src/modules/client.ts: -------------------------------------------------------------------------------- 1 | import {Filters, Finder} from "../api"; 2 | import type {Store} from "./flux"; 3 | 4 | export interface Platforms { 5 | PlatformTypes: { 6 | WINDOWS: "WINDOWS"; 7 | OSX: "OSX"; 8 | LINUX: "LINUX"; 9 | WEB: "WEB"; 10 | }; 11 | isPlatformEmbedded: boolean; 12 | 13 | getDevicePushProvider(): any; 14 | getOS(): string; 15 | getPlatform(): "WINDOWS" | "OSX" | "LINUX" | "WEB"; 16 | getPlatformName(): string; 17 | 18 | isAndroid(): boolean; 19 | isAndroidChrome(): boolean; 20 | isAndroidWeb(): boolean; 21 | isDesktop(): boolean; 22 | isIOS(): boolean; 23 | isLinux(): boolean; 24 | isOSX(): boolean; 25 | isWeb(): boolean; 26 | isWindows(): boolean; 27 | } 28 | 29 | // TODO: demangle 30 | export const Platforms: Platforms = /* @__PURE__ */ Finder.find(Filters.byEntry(Filters.byKeys("WINDOWS", "WEB"))); 31 | 32 | export interface ClientActions { 33 | selectGuild(e); 34 | addGuild(e, t, n, r); 35 | joinGuild(e, t); 36 | createGuild(e); 37 | deleteGuild(e); 38 | transitionToGuildSync(e, t, n, r); 39 | fetchApplications(e, t); 40 | 41 | collapseAllFolders(); 42 | setGuildFolderExpanded(e, t); 43 | toggleGuildFolderExpand(e); 44 | 45 | setChannel(e, t, n); 46 | batchChannelUpdate(e, t); 47 | escapeToDefaultChannel(e); 48 | move(e, t, n, r); 49 | moveById(e, t, n, r); 50 | nsfwAgree(e); 51 | nsfwReturnToSafety(e); 52 | 53 | createRole(e); 54 | createRoleWithNameColor(e, t, n); 55 | deleteRole(e, t); 56 | batchRoleUpdate(e, t); 57 | updateRole(e, t, n); 58 | updateRolePermissions(e, t, n); 59 | assignGuildRoleConnection(e, t); 60 | fetchGuildRoleConnectionsEligibility(e, t); 61 | 62 | requestMembers(e, t, n, r); 63 | requestMembersById(e, t, n); 64 | setServerDeaf(e, t, n); 65 | setServerMute(e, t, n); 66 | fetchGuildBans(e); 67 | banUser(e, t, n, r); 68 | unbanUser(e, t); 69 | kickUser(e, t, n); 70 | 71 | setCommunicationDisabledUntil(e, t, n, i, o); 72 | } 73 | 74 | export const ClientActions: ClientActions = /* @__PURE__ */ Finder.byKeys(["toggleGuildFolderExpand"]); 75 | 76 | export interface UserSetting { 77 | getSetting(): T; 78 | updateSetting(n: T): any; 79 | useSetting(): T; 80 | } 81 | 82 | export type UserSettings = Record>; 83 | 84 | export const UserSettings: UserSettings = /* @__PURE__ */ Finder.find(Filters.byEntry(Filters.byKeys("updateSetting"), true)); 85 | 86 | export interface UserSettingsProtoStore extends Store { 87 | computeState(): any; 88 | frecencyWithoutFetchingLatest: any; 89 | getDismissedGuildContent(e: any): any; 90 | getFullState(): any; 91 | getGuildFolders(): any; 92 | getGuildRecentsDismissedAt(e: any): any; 93 | getGuildsProto(): any; 94 | getState(): any; 95 | hasLoaded(e: any): any; 96 | settings: any; 97 | wasMostRecentUpdateFromServer: any; 98 | } 99 | 100 | export const UserSettingsProtoStore: UserSettingsProtoStore = /* @__PURE__ */ Finder.byName("UserSettingsProtoStore"); 101 | 102 | export interface LocaleStore extends Store { 103 | get locale(): any; 104 | __getLocalVars(): any; 105 | } 106 | 107 | export const LocaleStore: LocaleStore = /* @__PURE__ */ Finder.byName("LocaleStore"); 108 | 109 | export interface ThemeStore extends Store { 110 | get theme(): any; 111 | getState(): any; 112 | __getLocalVars(): any; 113 | } 114 | 115 | export const ThemeStore: ThemeStore = /* @__PURE__ */ Finder.byName("ThemeStore"); 116 | -------------------------------------------------------------------------------- /packages/dium/src/modules/component-dispatch.ts: -------------------------------------------------------------------------------- 1 | import {Filters, Finder} from "../api"; 2 | 3 | export interface ComponentDispatch { 4 | emitter: NodeJS.EventEmitter; 5 | 6 | dispatch(type: string, payload: any): any; 7 | dispatchKeyed(type: string, payload: any): any; 8 | dispatchToLastSubscribed(type: string, payload: any): any; 9 | safeDispatch(arg: any): any; 10 | 11 | hasSubscribers(type: string): any; 12 | subscribe(e: any, t: any): any; 13 | subscribeKeyed(e: any, t: any, n: any): any; 14 | subscribeOnce(e: any, t: any): any; 15 | resubscribe(e: any, t: any): any; 16 | unsubscribe(e: any, t: any): any; 17 | unsubscribeKeyed(e: any, t: any, n: any): any; 18 | 19 | reset(): any; 20 | _checkSavedDispatches(e: any): any; 21 | } 22 | 23 | export interface ComponentDispatcher { 24 | new(): ComponentDispatch; 25 | } 26 | 27 | interface ComponentDispatchModule { 28 | ComponentDispatch: ComponentDispatch; 29 | ComponentDispatcher: ComponentDispatcher; 30 | } 31 | 32 | export const {ComponentDispatch, ComponentDispatcher}: ComponentDispatchModule = /* @__PURE__ */ Finder.demangle({ 33 | ComponentDispatch: Filters.byKeys("dispatchToLastSubscribed"), 34 | ComponentDispatcher: Filters.byProtos("dispatchToLastSubscribed") 35 | }); 36 | -------------------------------------------------------------------------------- /packages/dium/src/modules/dispatcher.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import type {Flux} from "."; 3 | 4 | export const Dispatcher: Flux.Dispatcher = /* @__PURE__ */ Finder.byKeys(["dispatch", "subscribe"]); 5 | -------------------------------------------------------------------------------- /packages/dium/src/modules/experiment.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import type {Snowflake} from "."; 3 | import type {Store} from "./flux"; 4 | 5 | export interface Experiment> { 6 | definition: { 7 | id: string; 8 | kind: string; 9 | label: string; 10 | defaultConfig: Config; 11 | treatments: Config[]; 12 | }; 13 | 14 | getCurrentConfig(t: any, n: any): Config; 15 | useExperiment(t: any, n: any): Config; 16 | subscribe(t: any, n: any, r: any): any; 17 | trackExposure(t: any, r: any): any; 18 | } 19 | 20 | export interface ExperimentInfo { 21 | type: ExperimentType; 22 | title: string; 23 | description: string[]; 24 | buckets: number[]; 25 | clientFilter?: any; 26 | } 27 | 28 | export interface UserExperimentDescriptor { 29 | type: "user"; 30 | bucket: number; 31 | override: boolean; 32 | population: number; 33 | revision: number; 34 | } 35 | 36 | export const enum ExperimentType { 37 | GUILD = "guild", 38 | NONE_LEGACY = "none", 39 | USER = "user" 40 | } 41 | 42 | export const enum ExperimentExposureType { 43 | AUTO = "auto", 44 | MANUAL = "manual" 45 | } 46 | 47 | export const enum ExperimentTreatment { 48 | NOT_ELIGIBLE = -1, 49 | CONTROL = 0, 50 | TREATMENT_1 = 1, 51 | TREATMENT_2 = 2, 52 | TREATMENT_3 = 3, 53 | TREATMENT_4 = 4, 54 | TREATMENT_5 = 5, 55 | TREATMENT_6 = 6, 56 | TREATMENT_7 = 7, 57 | TREATMENT_8 = 8, 58 | TREATMENT_9 = 9, 59 | TREATMENT_10 = 10, 60 | TREATMENT_11 = 11, 61 | TREATMENT_12 = 12, 62 | TREATMENT_13 = 13, 63 | TREATMENT_14 = 14, 64 | TREATMENT_15 = 15, 65 | TREATMENT_16 = 16, 66 | TREATMENT_17 = 17, 67 | TREATMENT_18 = 18, 68 | TREATMENT_19 = 19, 69 | TREATMENT_20 = 20, 70 | TREATMENT_21 = 21, 71 | TREATMENT_22 = 22, 72 | TREATMENT_23 = 23, 73 | TREATMENT_24 = 24, 74 | TREATMENT_25 = 25 75 | } 76 | 77 | export type GuildExperimentDescriptor = Record; 78 | 79 | export interface ExperimentStore extends Store { 80 | get hasLoadedExperiments(): boolean; 81 | 82 | getExperimentOverrideDescriptor(experiment: string): any; 83 | getAllExperimentOverrideDescriptors(): Record; 84 | 85 | getAllUserExperimentDescriptors(): Record; 86 | getUserExperimentDescriptor(experiment: string): UserExperimentDescriptor; 87 | getUserExperimentBucket(experiment: string): number; 88 | 89 | getGuildExperiments(): Record; 90 | getGuildExperimentDescriptor(experiment: string, guild: Snowflake): GuildExperimentDescriptor; 91 | getGuildExperimentBucket(experiment: string, guild: Snowflake): number; 92 | 93 | getRegisteredExperiments(): Record; 94 | getSerializedState(): any; 95 | hasRegisteredExperiment(experiment: string): boolean; 96 | isEligibleForExperiment(experiment: string, guild?: Snowflake): boolean; 97 | 98 | __getLocalVars(): any; 99 | } 100 | 101 | export const ExperimentStore: ExperimentStore = /* @__PURE__ */ Finder.byName("ExperimentStore"); 102 | -------------------------------------------------------------------------------- /packages/dium/src/modules/general.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | /** A timestamped ID stored as a string. */ 4 | export type Snowflake = string; 5 | 6 | export type ActionModule = Record any>; 7 | 8 | export const Constants: any = /* @__PURE__ */ Finder.byKeys(["Permissions", "RelationshipTypes"]); 9 | 10 | export const i18n: any = /* @__PURE__ */ Finder.byKeys(["languages", "getLocale"]); 11 | -------------------------------------------------------------------------------- /packages/dium/src/modules/guild.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import type {Snowflake, ActionModule} from "."; 3 | import type {Store, SnapshotStore} from "./flux"; 4 | 5 | /** A Guild (server). */ 6 | export interface Guild { 7 | id: Snowflake; 8 | name: string; 9 | icon: string; 10 | ownerId: Snowflake; 11 | description?: string; 12 | 13 | banner?: any; 14 | splash?: any; 15 | 16 | maxMembers: number; 17 | maxVideoChannelUsers: number; 18 | defaultMessageNotifications: number; 19 | region: string; 20 | preferredLocale: string; 21 | 22 | verificationLevel: number; 23 | explicitContentFilter: number; 24 | mfaLevel: number; 25 | nsfwLevel: number; 26 | premiumTier: number; 27 | premiumSubscriberCount: number; 28 | premiumProgressBarEnabled: boolean; 29 | 30 | features: Set; 31 | 32 | joinedAt: Date; 33 | 34 | roles: Record; 35 | 36 | rulesChannelId?: Snowflake; 37 | publicUpdatesChannelId?: Snowflake; 38 | 39 | afkChannelId?: Snowflake; 40 | afkTimeout?: number; 41 | 42 | systemChannelId?: Snowflake; 43 | systemChannelFlags?: number; 44 | 45 | get acronym(): string; 46 | 47 | getApplicationId(): any; 48 | getIconSource(arg1: any, arg2: any): any; 49 | getIconURL(arg1: any, arg2: any): any; 50 | getMaxEmojiSlot(): number; 51 | getRole(roleId: Snowflake): Role; 52 | 53 | hasFeature(feature: GuildFeature): boolean; 54 | isLurker(): boolean; 55 | isNew(memberId: Snowflake): boolean; 56 | isOwner(memberId: Snowflake): boolean; 57 | isOwnerWithRequiredMfaLevel(memberId: Snowflake): boolean; 58 | 59 | toString(): string; 60 | } 61 | 62 | /** A Guild Role. */ 63 | export interface Role { 64 | id: Snowflake; 65 | name: string; 66 | 67 | color: number; 68 | colorString?: string; 69 | icon?: any; 70 | unicodeEmoji?: any; 71 | tags?: any; 72 | 73 | mentionable: boolean; 74 | hoist: boolean; 75 | managed: boolean; 76 | position: number; 77 | originalPosition: number; 78 | 79 | permissions: Permissions; 80 | } 81 | 82 | /** A Guild Feature. */ 83 | export type GuildFeature = any; 84 | 85 | /** A collection of Guild permissions stored as bitflag number. */ 86 | export type Permissions = number; 87 | 88 | /** A Member of a Guild. */ 89 | export interface Member { 90 | guildId: Snowflake; 91 | userId: Snowflake; 92 | 93 | colorString: string; 94 | nick?: string; 95 | joinedAt: string; 96 | guildMemberAvatar?: any; 97 | guildMemberBanner?: any; 98 | guildMemberBio: string; 99 | 100 | hoistRoleId: string; 101 | iconRoleId?: any; 102 | 103 | communicationDisabledUntil?: any; 104 | isPending: boolean; 105 | premiumSince?: any; 106 | 107 | roles: Snowflake[]; 108 | } 109 | 110 | export interface GuildStore extends Store { 111 | getGuild(id: Snowflake): Guild; 112 | getGuildCount(): number; 113 | getGuilds(): Record; 114 | __getLocalVars(): any; 115 | } 116 | 117 | export const GuildStore: GuildStore = /* @__PURE__ */ Finder.byName("GuildStore"); 118 | 119 | export const GuildActions: ActionModule = /* @__PURE__ */ Finder.byKeys(["requestMembers"]); 120 | 121 | export interface GuildMemberStore extends Store { 122 | getCommunicationDisabledUserMap(); 123 | getCommunicationDisabledVersion(); 124 | getMember(guild: Snowflake, user: Snowflake): Member; 125 | getMemberIds(guild: Snowflake): Snowflake[]; 126 | getMembers(guild: Snowflake): Member[]; 127 | getMutableAllGuildsAndMembers(); 128 | getNick(guild: Snowflake, user: Snowflake): string; 129 | getNicknameGuildsMapping(user: Snowflake): Record; 130 | getNicknames(user: Snowflake): string[]; 131 | isMember(guild: Snowflake, user: Snowflake): boolean; 132 | memberOf(arg: any): any; 133 | __getLocalVars(): any; 134 | } 135 | 136 | export const GuildMemberStore: GuildMemberStore = /* @__PURE__ */ Finder.byName("GuildMemberStore"); 137 | 138 | export interface GuildsTreeNodeBase { 139 | id: number | string; 140 | type?: string; 141 | } 142 | 143 | export interface GuildsTreeGuild extends GuildsTreeNodeBase { 144 | type: "guild"; 145 | id: string; 146 | parentId: number; 147 | unavailable: boolean; 148 | } 149 | 150 | export interface GuildsTreeFolder extends GuildsTreeNodeBase { 151 | type: "folder"; 152 | id: number; 153 | color: number; 154 | name: string; 155 | children: GuildsTreeGuild[]; 156 | muteConfig: any; 157 | expanded: boolean; 158 | } 159 | 160 | type GuildsTreeNode = GuildsTreeGuild | GuildsTreeFolder; 161 | 162 | export interface GuildsTreeRoot { 163 | type: "root"; 164 | children: GuildsTreeNode[]; 165 | } 166 | 167 | export interface GuildsTree { 168 | nodes: Record; 169 | root: GuildsTreeRoot; 170 | version: number; 171 | get size(): number; 172 | 173 | addNode(node: GuildsTreeNodeBase); 174 | allNodes(): GuildsTreeNode[]; 175 | convertToFolder(node: GuildsTreeNodeBase); 176 | getNode(nodeId: number); 177 | getRoots(): GuildsTreeNode[]; 178 | moveInto(node: GuildsTreeNodeBase, parent: GuildsTreeNodeBase); 179 | moveNextTo(node: GuildsTreeNodeBase, sibling: GuildsTreeNodeBase); 180 | removeNode(node: GuildsTreeNodeBase); 181 | replaceNode(node: GuildsTreeNodeBase, toReplace: GuildsTreeNode); 182 | sortedGuildNodes(): GuildsTreeGuild[]; 183 | _pluckNode(node: GuildsTreeNodeBase); 184 | } 185 | 186 | export interface GuildFolder { 187 | folderId?: number; 188 | folderName?: string; 189 | folderColor?: number; 190 | guildIds: Snowflake[]; 191 | expanded?: boolean; 192 | } 193 | 194 | export interface SortedGuildStore extends SnapshotStore { 195 | getGuildsTree(): GuildsTree; 196 | getGuildFolders(): GuildFolder[]; 197 | getFlattenedGuilds(): GuildsTreeGuild[]; 198 | getFlattenedGuildIds(): Snowflake[]; 199 | getCompatibleGuildFolders(): GuildFolder[]; 200 | } 201 | 202 | export const SortedGuildStore: SortedGuildStore = /* @__PURE__ */ Finder.byName("SortedGuildStore"); 203 | 204 | export interface DeprecatedGuildFolder { 205 | index: number; 206 | guilds: Snowflake[]; 207 | folderId?: number; 208 | folderName?: string; 209 | folderColor?: number; 210 | } 211 | 212 | export interface DeprecatedSortedGuild { 213 | index: number; 214 | guilds: Guild[]; 215 | folderId?: number; 216 | folderName?: string; 217 | folderColor?: number; 218 | } 219 | 220 | export interface ExpandedGuildFolderStore extends Store { 221 | getExpandedFolders(): Set; 222 | getState(): {expandedFolders: number[]}; 223 | isFolderExpanded(folderId: number): boolean; 224 | __getLocalVars(): any; 225 | } 226 | 227 | export const ExpandedGuildFolderStore: ExpandedGuildFolderStore = /* @__PURE__ */ Finder.byName("ExpandedGuildFolderStore"); 228 | -------------------------------------------------------------------------------- /packages/dium/src/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./channel"; 2 | export * from "./client"; 3 | export * from "./component-dispatch"; 4 | export * from "./dispatcher"; 5 | export * from "./experiment"; 6 | export * as Flux from "./flux"; 7 | export * from "./general"; 8 | export * from "./guild"; 9 | export * from "./media"; 10 | export * from "./message"; 11 | export * from "./npm"; 12 | export * from "./popout-window"; 13 | export * from "./router"; 14 | export * from "./user"; 15 | export * from "./util"; 16 | 17 | export type Untyped = T & Record; 18 | -------------------------------------------------------------------------------- /packages/dium/src/modules/media.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import type {ActionModule, Snowflake, Untyped} from "."; 3 | import type {Store} from "./flux"; 4 | 5 | export type MediaEngineContext = string; 6 | 7 | export const enum MediaEngineContextType { 8 | DEFAULT = "default", 9 | STREAM = "stream" 10 | } 11 | 12 | export interface MediaEngineStore extends Untyped { 13 | getMediaEngine(): any; 14 | getLocalPan(userId: Snowflake, context?: MediaEngineContext): {left: number; right: number}; 15 | getLocalVolume(userId: Snowflake, context?: MediaEngineContext): number; 16 | getLoopback(): boolean; 17 | getNoiseCancellation(): boolean; 18 | getNoiseSuppression(): boolean; 19 | isDeaf(): boolean; 20 | isMute(): boolean; 21 | isLocalMute(): boolean; 22 | isHardwareMute(): boolean; 23 | isSelfDeaf(context?: MediaEngineContext): boolean; 24 | isSelfMute(context?: MediaEngineContext): boolean; 25 | isSelfMutedTemporarily(): boolean; 26 | isVideoAvailable(): boolean; 27 | isVideoEnabled(): boolean; 28 | isSoundSharing(): boolean; 29 | getInputDevices(): Record; 30 | getInputDeviceId(): string; 31 | getInputVolume(): number; 32 | getOutputDevices(): Record; 33 | getOutputDeviceId(): string; 34 | getOutputVolume(): number; 35 | getVideoDevices(): Record; 36 | getVideoDeviceId(): string; 37 | } 38 | 39 | export const MediaEngineStore: MediaEngineStore = /* @__PURE__ */ Finder.byName("MediaEngineStore"); 40 | 41 | export interface MediaEngineDesktopSource { 42 | sourceId?: any; 43 | preset?: any; 44 | resolution?: any; 45 | frameRate?: any; 46 | sound?: any; 47 | context?: any; 48 | } 49 | 50 | export interface MediaEngineActions extends ActionModule { 51 | enable(unmute?: any): Promise; 52 | toggleSelfMute(options?: {context?: MediaEngineContext; syncRemove?: any}): Promise; 53 | setTemporarySelfMute(mute: any): void; 54 | toggleSelfDeaf(options?: {context?: MediaEngineContext; syncRemove?: any}): void; 55 | toggleLocalMute(userId: Snowflake, context?: MediaEngineContext): void; 56 | toggleLocalSoundboardMute(userId: Snowflake, context?: MediaEngineContext): void; 57 | setDisableLocalVideo(userId: Snowflake, disableVideo: any, context?: MediaEngineContext, persist?: boolean, isAutomatic?: boolean, isNoop?: boolean): void; 58 | setLocalVolume(userId: Snowflake, volume: number, context?: MediaEngineContext): void; 59 | setLocalPan(userId: Snowflake, left: number, right: number, context?: MediaEngineContext): void; 60 | setMode(mode: any, options?: any, context?: MediaEngineContext): void; 61 | setInputVolume(volume: number): void; 62 | setOutputVolume(volume: number): void; 63 | setInputDevice(id: any, t: any): void; 64 | setOutputDevice(id: any, t: any): void; 65 | setVideoDevice(id: any, t: any): void; 66 | setEchoCancellation(enabled: boolean, location: any): void; 67 | setLoopback(enabled: boolean): void; 68 | setNoiseSuppression(enabled: boolean, location: any): void; 69 | setNoiseCancellation(enabled: boolean, location: any): void; 70 | setAutomaticGainControl(enabled: boolean, location: any): void; 71 | setExperimentalEncoders(enabled: boolean): void; 72 | setHardwareH264(enabled: boolean): void; 73 | setAttenuation(attentuation: any, attenuateWhileSpeakingSelf: any, attenuateWhileSpeakingOthers: any): void; 74 | setQoS(enabled: boolean): void; 75 | reset(): void; 76 | setSilenceWarning(enabled: boolean): void; 77 | setDebugLogging(enabled: boolean): void; 78 | setVideoHook(enabled: boolean): void; 79 | setExperimentalSoundshare(enabled: boolean): void; 80 | setAudioSubsystem(subsystem: any): void; 81 | setVideoEnabled(enabled: boolean): void; 82 | setDesktopSource(source: MediaEngineDesktopSource): void; 83 | setOpenH264(enabled: boolean): void; 84 | setAV1Enabled(enabled: boolean): void; 85 | setH265Enabled(enabled: boolean): void; 86 | setAecDump(enabled: boolean): void; 87 | interact(): void; 88 | enableSoundshare(): void; 89 | } 90 | 91 | export const MediaEngineActions: MediaEngineActions = /* @__PURE__ */ Finder.byKeys(["setLocalVolume"]); 92 | 93 | export const MediaEngineHelpers: Record = /* @__PURE__ */ Finder.byKeys(["MediaEngineEvent"]); 94 | 95 | export const PendingAudioSettings: Record = /* @__PURE__ */ Finder.byKeys(["getPendingAudioSettings"]); 96 | -------------------------------------------------------------------------------- /packages/dium/src/modules/message.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import type {Untyped, Snowflake, User, ActionModule} from "."; 3 | import type {Store} from "./flux"; 4 | 5 | export const enum MessageTypes { 6 | Default = 0, 7 | RecipientAdd = 1, 8 | RecipientRemove = 2, 9 | Call = 3, 10 | ChannelNameChange = 4, 11 | ChannelIconChange = 5, 12 | ChannelPinnedMessage = 6, 13 | UserJoin = 7, 14 | UserPremiumGuildSubscription = 8, 15 | UserPremiumGuildSubscriptionTier1 = 9, 16 | UserPremiumGuildSubscriptionTier2 = 10, 17 | UserPremiumGuildSubscriptionTier3 = 11, 18 | ChannelFollowAdd = 12, 19 | GuildStream = 13, 20 | GuildDiscoveryDisqualified = 14, 21 | GuildDiscoveryRequalified = 15, 22 | GuildDiscoveryGracePeriodInitialWarning = 16, 23 | GuildDiscoveryGracePeriodFinalWarning = 17, 24 | ThreadCreated = 18, 25 | Reply = 19, 26 | ChatInputCommand = 20, 27 | ThreadStarterMessage = 21, 28 | GuildInviteReminder = 22, 29 | ContextMenuCommand = 23 30 | } 31 | 32 | export const enum MessageStates { 33 | Sending = "SENDING", 34 | SendFailed = "SEND_FAILED", 35 | Sent = "SENT" 36 | } 37 | 38 | export const enum MessageFlags { 39 | None = 0, 40 | Crossposted = 1, 41 | IsCrosspost = 2, 42 | SuppressEmbeds = 4, 43 | SourceMessageDeleted = 8, 44 | Urgent = 16, 45 | HasThread = 32, 46 | Ephemeral = 64, 47 | Loading = 128, 48 | FailedToMentionSomeRolesInThread = 256 49 | } 50 | 51 | /** A message. */ 52 | export interface Message { 53 | type: MessageTypes; 54 | author: User; 55 | content: string; 56 | 57 | timestamp: import("moment").Moment; 58 | editedTimestamp?: any; 59 | state: MessageStates; 60 | 61 | /** Message ID of parent. */ 62 | nonce?: Snowflake; 63 | 64 | flags: MessageFlags; 65 | attachments: any[]; 66 | codedLinks: any[]; 67 | components: any[]; 68 | embeds: Embed[]; 69 | giftCodes: any[]; 70 | mentionChannels: any[]; 71 | mentionEveryone: boolean; 72 | mentionRoles: any[]; 73 | mentioned: boolean; 74 | mentions: any[]; 75 | messageReference?: any; 76 | reactions: any[]; 77 | stickerItems: any[]; 78 | stickers: any[]; 79 | 80 | activity?: any; 81 | application?: any; 82 | applicationId?: any; 83 | blocked: boolean; 84 | bot: boolean; 85 | call?: any; 86 | colorString?: any; 87 | customRenderedContent?: any; 88 | id: Snowflake; 89 | interaction?: any; 90 | interactionData?: any; 91 | interactionError?: any; 92 | isSearchHit: boolean; 93 | loggingName?: any; 94 | nick?: any; 95 | pinned: boolean; 96 | tts: boolean; 97 | webhookId?: any; 98 | 99 | addReaction(arg1: any, arg2: any): void; 100 | getChannelId(): Snowflake; 101 | getReaction(arg: any): any; 102 | hasFlag(flag: MessageFlags): boolean; 103 | isCommandType(): boolean; 104 | isEdited(): boolean; 105 | isSystemDM(): boolean; 106 | removeReaction(arg1: any, arg2: any): void; 107 | removeReactionsForEmoji(arg: any): void; 108 | toJS(): any; 109 | } 110 | 111 | export const enum EmbedTypes { 112 | TEXT = "text", 113 | RICH = "rich", 114 | IMAGE = "image", 115 | VIDEO = "video", 116 | GIFV = "gifv", 117 | ARTICLE = "article", 118 | LINK = "link", 119 | TWEET = "tweet", 120 | APPLICATION_NEWS = "application_news", 121 | AUTO_MODERATION_MESSAGE = "auto_moderation_message", 122 | AUTO_MODERATION_NOTIFICATION = "auto_moderation_notification" 123 | } 124 | 125 | export interface Embed { 126 | type?: EmbedTypes; 127 | id?: string; 128 | referenceId?: any; 129 | rawTitle?: string; 130 | author?: EmbedAuthor; 131 | rawDescription?: string; 132 | url?: string; 133 | color?: number; 134 | image?: EmbedMedia; 135 | thumbnail?: EmbedMedia; 136 | video?: EmbedMedia; 137 | provider?: EmbedProvider; 138 | fields?: EmbedField[]; 139 | footer?: EmbedFooter; 140 | } 141 | 142 | export interface EmbedAuthor { 143 | name: string; 144 | url?: string; 145 | icon_url?: string; 146 | proxy_icon_url?: string; 147 | } 148 | 149 | export interface EmbedProvider { 150 | name?: string; 151 | url?: string; 152 | } 153 | 154 | export interface EmbedMedia { 155 | url: string; 156 | proxy_url?: string; 157 | height?: number; 158 | width?: number; 159 | } 160 | 161 | export interface EmbedField { 162 | name: string; 163 | value: string; 164 | inline?: boolean; 165 | } 166 | 167 | export interface EmbedFooter { 168 | text: string; 169 | icon_url?: string; 170 | proxy_icon_url?: string; 171 | } 172 | 173 | export interface Attachment { 174 | id?: string; 175 | filename?: string; 176 | url?: string; 177 | proxy_url?: string; 178 | content_type?: string; 179 | height?: number; 180 | width?: number; 181 | size?: number; 182 | spoiler?: boolean; 183 | } 184 | 185 | export const MessageStore: Untyped = /* @__PURE__ */ Finder.byName("MessageStore"); 186 | 187 | export interface MessageActions extends ActionModule { 188 | receiveMessage(e: any, t: any): any; 189 | sendBotMessage(e: any, t: any, n: any): any; 190 | sendClydeError(e: any): any; 191 | truncateMessages(e: any, t: any, n: any): any; 192 | clearChannel(e: any): any; 193 | 194 | jumpToPresent(e: any, t: any): any; 195 | jumpToMessage(e: any): any; 196 | trackJump(e: any, t: any, n: any, r: any): any; 197 | focusMessage(e: any): any; 198 | 199 | fetchMessages(e: any): any; 200 | _tryFetchMessagesCached(e: any): any; 201 | 202 | sendMessage(e: any, t: any, n?: any, r?: any): any; 203 | getSendMessageOptionsForReply(e: any): any; 204 | sendInvite(e: any, t: any, n: any, r: any): any; 205 | sendStickers(e: any, t: any): any; 206 | sendGreetMessage(e: any, t: any): any; 207 | _sendMessage(e: any, t: any, r: any): any; 208 | 209 | startEditMessage(e: any, t: any, n: any): any; 210 | updateEditMessage(e: any, t: any, n: any): any; 211 | endEditMessage(e: any, t: any): any; 212 | editMessage(e: any, t: any, n: any): any; 213 | suppressEmbeds(e: any, t: any): any; 214 | patchMessageAttachments(e: any, t: any, n: any): any; 215 | 216 | deleteMessage(e: any, t: any): any; 217 | dismissAutomatedMessage(e: any): any; 218 | 219 | revealMessage(e: any, t: any): any; 220 | crosspostMessage(e: any, t: any): any; 221 | trackInvite(e: any): any; 222 | } 223 | 224 | export const MessageActions: MessageActions = /* @__PURE__ */ Finder.byKeys(["jumpToMessage", "_sendMessage"]); 225 | -------------------------------------------------------------------------------- /packages/dium/src/modules/npm.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | 3 | export const {React} = BdApi; 4 | 5 | export const {ReactDOM} = BdApi; 6 | 7 | export const ReactSpring: typeof import("@react-spring/web") = /* @__PURE__ */ Finder.byKeys(["SpringContext", "animated"]); 8 | 9 | export const classNames: typeof import("classnames") = /* @__PURE__ */ Finder.find( 10 | (exports) => exports instanceof Object && exports.default === exports && Object.keys(exports).length === 1 11 | ); 12 | 13 | export const EventEmitter: typeof import ("node:events") = /* @__PURE__ */ Finder.find( 14 | (exports) => exports.prototype instanceof Object && Object.prototype.hasOwnProperty.call(exports.prototype, "prependOnceListener") 15 | ); 16 | 17 | export const lodash: typeof import("lodash") = /* @__PURE__ */ Finder.byKeys(["cloneDeep", "flattenDeep"]); 18 | 19 | export const Immutable: typeof import("immutable") = /* @__PURE__ */ Finder.byKeys(["OrderedSet"]); 20 | 21 | export const semver: typeof import("semver") = /* @__PURE__ */ Finder.byKeys(["SemVer"]); 22 | 23 | export const moment: typeof import("moment") = /* @__PURE__ */ Finder.byKeys(["utc", "months"]); 24 | 25 | export const SimpleMarkdown: typeof import("simple-markdown") = /* @__PURE__ */ Finder.byKeys(["parseBlock", "parseInline"]); 26 | 27 | export const hljs: typeof import("highlight.js").default = /* @__PURE__ */ Finder.byKeys(["highlight", "highlightBlock"]); 28 | 29 | export const platform: typeof import("platform") = /* @__PURE__ */ Finder.byKeys(["os", "manufacturer"]); 30 | 31 | export const lottie: typeof import("lottie-web").default = /* @__PURE__ */ Finder.byKeys(["setSubframeRendering"]); 32 | 33 | export const stemmer: typeof import("stemmer").stemmer = /* @__PURE__ */ Finder.bySource([".codePointAt", ".test", ".exec"], {entries: true}); 34 | -------------------------------------------------------------------------------- /packages/dium/src/modules/popout-window.ts: -------------------------------------------------------------------------------- 1 | import * as Finder from "../api/finder"; 2 | import type {Action, Store} from "./flux"; 3 | 4 | export type PopoutWindowKey = string; 5 | 6 | export interface PopoutWindowState extends Record { 7 | alwaysOnTop: boolean; 8 | height: number; 9 | width: number; 10 | x: number; 11 | y: number; 12 | } 13 | 14 | export interface PopoutWindowAction extends Action { 15 | key: PopoutWindowKey; 16 | features: Record; 17 | render: React.ReactElement; 18 | } 19 | 20 | export interface PopoutWindowStore extends Store { 21 | getState(): Record; 22 | getWindowKeys(): PopoutWindowKey[]; 23 | 24 | getWindow(key: PopoutWindowKey): Window; 25 | getWindowState(key: PopoutWindowKey): PopoutWindowState; 26 | getWindowOpen(key: PopoutWindowKey): boolean; 27 | getWindowFocused(key: PopoutWindowKey): boolean; 28 | getIsAlwaysOnTop(key: PopoutWindowKey): boolean; 29 | 30 | unmountWindow(key: PopoutWindowKey): void; 31 | 32 | __getLocalVars(): any; 33 | } 34 | 35 | export const PopoutWindowStore: PopoutWindowStore = /* @__PURE__ */ Finder.byName("PopoutWindowStore"); 36 | -------------------------------------------------------------------------------- /packages/dium/src/modules/router.ts: -------------------------------------------------------------------------------- 1 | import {Filters, Finder} from "../api"; 2 | 3 | type ReactRouter = typeof import("react-router"); 4 | 5 | type Keys = "Redirect" | "Route" | "Router" | "Switch" | "matchPath" | "useHistory" | "useLocation" | "useParams" | "useRouteMatch" | "withRouter" | "__RouterContext"; 6 | 7 | export type Router = Pick; 8 | 9 | const mapping = { 10 | Redirect: Filters.bySource(".computedMatch", ".to"), 11 | Route: Filters.bySource(".computedMatch", ".location"), 12 | Router: Filters.byKeys("computeRootMatch"), 13 | Switch: Filters.bySource(".cloneElement"), 14 | useLocation: Filters.bySource(").location"), 15 | useParams: Filters.bySource(".params:"), 16 | withRouter: Filters.bySource("withRouter("), 17 | __RouterContext: Filters.byName("Router") 18 | }; 19 | 20 | export const Router: Pick = /* @__PURE__ */ Finder.demangle(mapping, ["withRouter"]); 21 | -------------------------------------------------------------------------------- /packages/dium/src/modules/user.ts: -------------------------------------------------------------------------------- 1 | import {Finder} from "../api"; 2 | import type {Snowflake} from "."; 3 | import type {Store} from "./flux"; 4 | 5 | /** A User. */ 6 | export interface User { 7 | id: Snowflake; 8 | username: string; 9 | globalName?: string; 10 | discriminator: string; 11 | avatar: string; 12 | email: string; 13 | phone?: any; 14 | 15 | accentColor?: any; 16 | banner?: any; 17 | bio: string; 18 | 19 | bot: boolean; 20 | desktop: boolean; 21 | mobile: boolean; 22 | system: boolean; 23 | verified: boolean; 24 | mfaEnabled: boolean; 25 | nsfwAllowed: boolean; 26 | flags: number; 27 | 28 | premiumType?: any; 29 | premiumUsageFlags: number; 30 | publicFlags: number; 31 | purchasedFlags: number; 32 | 33 | guildMemberAvatars: Record; 34 | 35 | get createdAt(): Date; 36 | get hasPremiumPerks(): boolean; 37 | get tag(): string; 38 | get usernameNormalized(): string; 39 | 40 | addGuildAvatarHash(arg1: any, arg2: any): any; 41 | getAvatarSource(arg1: any, arg2: any): any; 42 | getAvatarURL(arg1: any, arg2: any, arg3: any): string; 43 | getBannerSource(arg1: any, arg2: any): any; 44 | getBannerURL(arg1: any, arg2: any): string; 45 | removeGuildAvatarHash(arg: any): any; 46 | 47 | hasAvatarForGuild(guildId: Snowflake): boolean; 48 | hasFlag(flag: any): boolean; 49 | hasFreePremium(): boolean; 50 | hasHadSKU(sku: any): boolean; 51 | hasPremiumUsageFlag(flag: any): boolean; 52 | hasPurchasedFlag(flag: any): boolean; 53 | hasUrgentMessages(): boolean; 54 | isClaimed(): boolean; 55 | isLocalBot(): boolean; 56 | isNonUserBot(): boolean; 57 | isPhoneVerified(): boolean; 58 | isStaff(): boolean; 59 | isSystemUser(): boolean; 60 | isVerifiedBot(): boolean; 61 | 62 | toString(): string; 63 | } 64 | 65 | export const enum UserFlags { 66 | STAFF = 2**0, 67 | PARTNER = 2**1, 68 | HYPESQUAD = 2**2, 69 | BUG_HUNTER_LEVEL_1 = 2**3, 70 | MFA_SMS = 2**4, 71 | PREMIUM_PROMO_DISMISSED = 2**5, 72 | HYPESQUAD_ONLINE_HOUSE_1 = 2**6, 73 | HYPESQUAD_ONLINE_HOUSE_2 = 2**7, 74 | HYPESQUAD_ONLINE_HOUSE_3 = 2**8, 75 | PREMIUM_EARLY_SUPPORTER = 2**9, 76 | HAS_UNREAD_URGENT_MESSAGES = 2**13, 77 | BUG_HUNTER_LEVEL_2 = 2**15, 78 | VERIFIED_BOT = 2**16, 79 | VERIFIED_DEVELOPER = 2**17, 80 | CERTIFIED_MODERATOR = 2**18, 81 | BOT_HTTP_INTERACTIONS = 2**19, 82 | SPAMMER = 2**20, 83 | DISABLE_PREMIUM = 2**21, 84 | QUARANTINED = 2**44 85 | } 86 | 87 | export interface UserStore extends Store { 88 | filter(predicate: (user: User) => boolean, sorted?: boolean): User[]; 89 | findByTag(username: string, discriminator: string): User; 90 | forEach(callback: (user: User) => boolean); 91 | getCurrentUser(): User; 92 | getUser(id: Snowflake): User; 93 | getUsers(): User[]; 94 | __getLocalVars(): any; 95 | } 96 | 97 | export const UserStore: UserStore = /* @__PURE__ */ Finder.byName("UserStore"); 98 | 99 | export const enum StatusTypes { 100 | DND = "dnd", 101 | IDLE = "idle", 102 | INVISIBLE = "invisible", 103 | OFFLINE = "offline", 104 | ONLINE = "online", 105 | STREAMING = "streaming", 106 | UNKNOWN = "unknown" 107 | } 108 | 109 | export interface PresenceStoreState { 110 | statuses: Record; 111 | clientStatuses: Record; 112 | activities: Record; 113 | activityMetadata: Record; 114 | 115 | /** Maps users to guilds to presences. */ 116 | presencesForGuilds: Record>; 117 | } 118 | 119 | export interface PresenceStore extends Store { 120 | findActivity(e, t, n); 121 | getActivities(e, t); 122 | getActivityMetadata(e); 123 | getAllApplicationActivities(e); 124 | getApplicationActivity(e, t, n); 125 | getPrimaryActivity(e, t); 126 | getState(): PresenceStoreState; 127 | getStatus(user: Snowflake, t?, n?): StatusTypes; 128 | getUserIds(): Snowflake[]; 129 | isMobileOnline(user: Snowflake): boolean; 130 | setCurrentUserOnConnectionOpen(e, t); 131 | __getLocalVars(); 132 | } 133 | 134 | export const PresenceStore: PresenceStore = /* @__PURE__ */ Finder.byName("PresenceStore"); 135 | 136 | export const enum RelationshipTypes { 137 | NONE = 0, 138 | FRIEND = 1, 139 | BLOCKED = 2, 140 | PENDING_INCOMING = 3, 141 | PENDING_OUTGOING = 4, 142 | IMPLICIT = 5 143 | } 144 | 145 | export interface RelationshipStore extends Store { 146 | getFriendIDs(): Snowflake[]; 147 | getNickname(arg: any): any; 148 | getPendingCount(): number; 149 | getRelationshipCount(): number; 150 | getRelationshipType(user: Snowflake): RelationshipTypes; 151 | getRelationships(): Record; 152 | isBlocked(user: Snowflake): boolean; 153 | isFriend(user: Snowflake): boolean; 154 | __getLocalVars(); 155 | } 156 | 157 | export const RelationshipStore: RelationshipStore = /* @__PURE__ */ Finder.byName("RelationshipStore"); 158 | -------------------------------------------------------------------------------- /packages/dium/src/modules/util.ts: -------------------------------------------------------------------------------- 1 | import {Filters, Finder} from "../api"; 2 | 3 | export interface AudioConvert { 4 | amplitudeToPerceptual(amplitude: number): number; 5 | perceptualToAmplitude(perceptual: number): number; 6 | } 7 | 8 | export const AudioConvert: AudioConvert = /* @__PURE__ */ Finder.demangle({ 9 | amplitudeToPerceptual: Filters.bySource("Math.log10"), 10 | perceptualToAmplitude: Filters.bySource("Math.pow(10") 11 | }); 12 | -------------------------------------------------------------------------------- /packages/dium/src/react-internals.ts: -------------------------------------------------------------------------------- 1 | import type {Usable} from "react"; 2 | import {React, ReactDOM} from "./modules"; 3 | import type {Fiber, ReactContext, EventPriority} from "react-reconciler"; 4 | 5 | export type {Fiber} from "react-reconciler"; 6 | 7 | export interface ForwardRefExoticComponent extends React.ForwardRefExoticComponent

{ 8 | render: React.ForwardRefRenderFunction; 9 | } 10 | 11 | type CompareFn = Parameters[1]; 12 | 13 | export interface MemoExoticComponent> extends React.MemoExoticComponent { 14 | compare?: CompareFn; 15 | } 16 | 17 | /** A fiber node with React component as state node. */ 18 | export interface OwnerFiber extends Fiber { 19 | stateNode: React.Component; 20 | } 21 | 22 | export interface MutableSource { 23 | _source: Source; 24 | _getVersion: (source: Source) => any; 25 | _workInProgressVersionPrimary?: any; 26 | _workInProgressVersionSecondary?: any; 27 | } 28 | export type TransitionStatus = any; 29 | 30 | export interface Dispatcher { 31 | use(usable: Usable): T; 32 | readContext(context: ReactContext, observedBits?: number | boolean): T; 33 | useState: typeof React.useState; 34 | useReducer: typeof React.useReducer; 35 | useContext: typeof React.useContext; 36 | useRef: typeof React.useRef; 37 | useEffect: typeof React.useEffect; 38 | useEffectEvent? any>(callback: F): F; 39 | useInsertionEffect: typeof React.useInsertionEffect; 40 | useLayoutEffect: typeof React.useLayoutEffect; 41 | useCallback: typeof React.useCallback; 42 | useMemo: typeof React.useMemo; 43 | useImperativeHandle: typeof React.useImperativeHandle; 44 | useDebugValue: typeof React.useDebugValue; 45 | useDefferedValue: typeof React.useDeferredValue; 46 | useTransition: typeof React.useTransition; 47 | useSyncExternalStore: typeof React.useSyncExternalStore; 48 | useId: typeof React.useId; 49 | useCacheRefresh?(): (f: () => T, t?: T) => void; 50 | useMemoCache?(size: number): any[]; 51 | useHostTransitionStatus?(): TransitionStatus; 52 | useOptimistic?: typeof React.useOptimistic; 53 | useFormState?( 54 | action: (awaited: Awaited, p: P) => S, 55 | initialState: Awaited, 56 | permalink?: string, 57 | ): [Awaited, (p: P) => void, boolean]; 58 | useActionState?: typeof React.useActionState; 59 | } 60 | 61 | export interface AsyncDispatcher { 62 | getCacheForType(resourceType: () => T): T; 63 | } 64 | 65 | export interface BatchConfigTransition { 66 | name?: string; 67 | startTime?: number; 68 | _updatedFibers?: Set; 69 | } 70 | 71 | export interface ReactInternals { 72 | H?: Dispatcher; // ReactCurrentDispatcher for Hooks 73 | A?: AsyncDispatcher; // ReactCurrentCache for Cache 74 | T?: BatchConfigTransition; // ReactCurrentBatchConfig for Transitions 75 | S?(transition: BatchConfigTransition, mixed: any): void; // onStartTransitionFinish 76 | } 77 | 78 | export const ReactInternals: ReactInternals = (React as any)?.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; 79 | 80 | export type CrossOriginEnum = any; 81 | export type PreloadImplOptions = any; 82 | export type PreloadModuleImplOptions = any; 83 | export type PreinitStyleOptions = any; 84 | export type PreinitScriptOptions = any; 85 | export type PreinitModuleScriptOptions = any; 86 | 87 | export interface HostDispatcher { 88 | f(): boolean | void; // flushSyncWork 89 | R(form: HTMLFormElement): void; // requestFormReset 90 | D(href: string): void; // prefetchDNS 91 | C(href: string, crossOrigin?: CrossOriginEnum): void; // preconnect 92 | L(href: string, as: string, options?: PreloadImplOptions): void; // preload 93 | m(href: string, options?: PreloadModuleImplOptions): void; // preloadModule 94 | S(href: string, precedence: string, options?: PreinitStyleOptions): void; // preinitStyle 95 | X(src: string, options?: PreinitScriptOptions): void; // preinitScript 96 | M(src: string, options?: PreinitModuleScriptOptions): void; // preinitModuleScript 97 | } 98 | 99 | export interface ReactDOMInternals { 100 | d: HostDispatcher; // ReactDOMCurrentDispatcher 101 | p: EventPriority; // currrentUpdatePriority 102 | findDOMNode?(componentOrElement: React.Component): null | Element | Text; 103 | } 104 | 105 | export const ReactDOMInternals: ReactDOMInternals = (ReactDOM as any)?.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; 106 | -------------------------------------------------------------------------------- /packages/dium/src/require.ts: -------------------------------------------------------------------------------- 1 | export type Id = number | string; 2 | 3 | export type Exports = Record; 4 | 5 | export interface Module { 6 | id: Id; 7 | loaded: boolean; 8 | exports: Exports; 9 | } 10 | 11 | export type ModuleFactory = (this: Exports, module: Module, exports: Exports, require: Require) => void; 12 | 13 | export interface Require { 14 | (id: Id): any; 15 | 16 | /** Module factories. */ 17 | m: Record; 18 | 19 | /** Module exports cache. */ 20 | c: Record; 21 | 22 | /** Module execution interceptor. */ 23 | i: any[]; 24 | 25 | /** Chunk loaded. */ 26 | O(result: any, chunkIds: any, fn: any, priority: any): any; 27 | 28 | /** Get default export. */ 29 | n(module: any): any; 30 | 31 | /** Create fake namespace object. */ 32 | t(value: any, mode: any): any; 33 | 34 | /** Define property getters. */ 35 | d(exports: any, definition: any): any; 36 | 37 | /** Ensure chunk. */ 38 | e(chunkId: Id): Promise; 39 | 40 | /** Get chunk filename. */ 41 | u(chunkId: Id): any; 42 | 43 | /** Global. */ 44 | g(): typeof globalThis; 45 | 46 | /** hasOwnProperty shorthand. */ 47 | o(obj: any, prop: any): any; 48 | 49 | /** Load script. */ 50 | l(url: any, done: any, key: any, chunkId: Id): any; 51 | 52 | /** Make namespace object. */ 53 | r(exports: any): any; 54 | 55 | /** Node module decorator. */ 56 | nmd(module: any): any; 57 | 58 | /** publicPath. */ 59 | p: string; 60 | } 61 | -------------------------------------------------------------------------------- /packages/dium/src/settings-container.tsx: -------------------------------------------------------------------------------- 1 | import {React, classNames} from "./modules"; 2 | import {Flex, Button, FormSection, FormDivider, margins} from "./components"; 3 | import {confirm} from "./utils"; 4 | 5 | export interface SettingsContainerProps { 6 | name: string; 7 | children?: React.ReactNode; 8 | onReset?: () => void; 9 | } 10 | 11 | export const SettingsContainer = ({name, children, onReset}: SettingsContainerProps): React.JSX.Element => ( 12 | 13 | {children} 14 | {onReset ? ( 15 | <> 16 | 17 | 18 | 24 | 25 | 26 | ) : null} 27 | 28 | ); 29 | -------------------------------------------------------------------------------- /packages/dium/src/settings.ts: -------------------------------------------------------------------------------- 1 | import {React, Flux} from "./modules"; 2 | import * as Data from "./api/data"; 3 | 4 | export type Listener = (current: T) => void; 5 | 6 | export type Update = Partial | ((current: T) => Partial); 7 | 8 | export type Setter = (update: Update) => void; 9 | 10 | export type SettingsType> = S["defaults"]; 11 | 12 | export class SettingsStore> implements Flux.StoreLike { 13 | /** Default settings values. */ 14 | defaults: T; 15 | 16 | /** Current settings state. */ 17 | current: T; 18 | 19 | /** Settings load callback. */ 20 | onLoad?: () => void; 21 | 22 | /** Currently registered listeners. */ 23 | listeners: Set> = new Set(); 24 | 25 | /** 26 | * Creates a new settings store. 27 | * 28 | * @param defaults Default settings to use initially & revert to on reset. 29 | * @param onLoad Optional callback for when the settings are loaded. 30 | */ 31 | constructor(defaults: T, onLoad?: () => void) { 32 | this.defaults = defaults; 33 | this.onLoad = onLoad; 34 | } 35 | 36 | /** Loads settings. */ 37 | load(): void { 38 | this.current = {...this.defaults, ...Data.load("settings")}; 39 | this.onLoad?.(); 40 | this._dispatch(false); 41 | } 42 | 43 | /** 44 | * Dispatches a settings update. 45 | * 46 | * @param save Whether to save the settings. 47 | */ 48 | _dispatch(save: boolean): void { 49 | for (const listener of this.listeners) { 50 | listener(this.current); 51 | } 52 | if (save) { 53 | Data.save("settings", this.current); 54 | } 55 | } 56 | 57 | /** 58 | * Updates settings state. 59 | * 60 | * Similar to React's `setState()`. 61 | * 62 | * @param settings Partial settings or callback receiving current settings and returning partial settings. 63 | * 64 | * @example 65 | * ```js 66 | * Settings.update({myKey: "foo"}) 67 | * Settings.update((current) => ({settingA: current.settingB})) 68 | * ``` 69 | */ 70 | update = (settings: Update): void => { 71 | // TODO: copy and use comparators? 72 | Object.assign(this.current, typeof settings === "function" ? settings(this.current) : settings); 73 | this._dispatch(true); 74 | }; 75 | 76 | /** Resets all settings to their defaults. */ 77 | reset(): void { 78 | this.current = {...this.defaults}; 79 | this._dispatch(true); 80 | } 81 | 82 | /** Deletes settings using their keys. */ 83 | delete(...keys: string[]): void { 84 | for (const key of keys) { 85 | delete this.current[key]; 86 | } 87 | this._dispatch(true); 88 | } 89 | 90 | /** 91 | * Returns the current settings state. 92 | * 93 | * @example 94 | * ```js 95 | * const currentSettings = Settings.useCurrent(); 96 | * ``` 97 | */ 98 | useCurrent(): T { 99 | return Flux.useStateFromStores([this], () => this.current, undefined, () => false); 100 | } 101 | 102 | /** 103 | * Returns the current settings state mapped with a selector. 104 | * 105 | * Similar to Redux' `useSelector()`, but with optional dependencies. 106 | * 107 | * @param selector A function selecting a part of the current settings. 108 | * @param deps Dependencies of the selector. 109 | * @param compare An equality function to compare two results of the selector. Strict equality `===` by default. 110 | * 111 | * @example 112 | * ```js 113 | * const entry = Settings.useSelector((current) => current.entry); 114 | * ``` 115 | */ 116 | useSelector(selector: (current: T) => R, deps?: React.DependencyList, compare?: Flux.Comparator): R { 117 | return Flux.useStateFromStores([this], () => selector(this.current), deps, compare); 118 | } 119 | 120 | /** 121 | * Returns the current settings state & a setter function. 122 | * 123 | * Similar to React's `useState()`. 124 | * 125 | * @example 126 | * ```js 127 | * const [currentSettings, setSettings] = Settings.useState(); 128 | * ``` 129 | */ 130 | useState(): [T, Setter] { 131 | return Flux.useStateFromStores([this], () => [ 132 | this.current, 133 | this.update 134 | ]); 135 | } 136 | 137 | /** 138 | * Returns the current settings state, defaults & a setter function. 139 | * 140 | * Similar to React's `useState()`, but with another entry. 141 | * 142 | * @example 143 | * ```js 144 | * const [currentSettings, defaultSettings, setSettings] = Settings.useStateWithDefaults(); 145 | * ``` 146 | */ 147 | useStateWithDefaults(): [T, T, Setter] { 148 | return Flux.useStateFromStores([this], () => [ 149 | this.current, 150 | this.defaults, 151 | this.update 152 | ]); 153 | } 154 | 155 | /** 156 | * Adds a new settings change listener from within a component. 157 | * 158 | * @param listener Listener function to be called when settings state changes. 159 | * @param deps Dependencies of the listener function. Defaults to the listener function itself. 160 | */ 161 | useListener(listener: Listener, deps?: React.DependencyList): void { 162 | React.useEffect(() => { 163 | this.addListener(listener); 164 | return () => this.removeListener(listener); 165 | }, deps ?? [listener]); 166 | } 167 | 168 | /** Registers a new settings change listener. */ 169 | addListener(listener: Listener): Listener { 170 | this.listeners.add(listener); 171 | return listener; 172 | } 173 | 174 | /** Removes a previously added settings change listener. */ 175 | removeListener(listener: Listener): void { 176 | this.listeners.delete(listener); 177 | } 178 | 179 | /** Removes all current settings change listeners. */ 180 | removeAllListeners(): void { 181 | this.listeners.clear(); 182 | } 183 | 184 | // compatibility with discord's flux interface 185 | addReactChangeListener = this.addListener; 186 | removeReactChangeListener = this.removeListener; 187 | } 188 | 189 | /** 190 | * Creates new settings. 191 | * 192 | * For details see {@link SettingsStore}. 193 | */ 194 | export const createSettings = >(defaults: T, onLoad?: () => void): SettingsStore => new SettingsStore(defaults, onLoad); 195 | -------------------------------------------------------------------------------- /packages/dium/src/utils/general.ts: -------------------------------------------------------------------------------- 1 | import type * as BD from "betterdiscord"; 2 | 3 | export const hasOwnProperty = (object: unknown, property: PropertyKey): boolean => Object.prototype.hasOwnProperty.call(object, property); 4 | 5 | export const sleep = (duration: number): Promise => new Promise((resolve) => setTimeout(resolve, duration)); 6 | 7 | export const alert = (title: string, content: string | React.ReactNode): void => BdApi.UI.alert(title, content); 8 | 9 | export type ConfirmOptions = BD.ConfirmationModalOptions; 10 | 11 | /** Shows a confirmation modal. */ 12 | // TODO: change to promise? 13 | export const confirm = (title: string, content: string | React.ReactNode, options: ConfirmOptions = {}): string => BdApi.UI.showConfirmationModal(title, content, options); 14 | 15 | export const enum ToastType { 16 | Default = "", 17 | Info = "info", 18 | Success = "success", 19 | Warn = "warn", 20 | Warning = "warning", 21 | Danger = "danger", 22 | Error = "error" 23 | } 24 | 25 | export interface ToastOptions extends BD.ToastOptions { 26 | type?: ToastType; 27 | } 28 | 29 | /** Shows a toast notification. */ 30 | export const toast = (content: string, options: ToastOptions): void => BdApi.UI.showToast(content, options); 31 | 32 | export type MappedProxy< 33 | T extends Record, 34 | M extends Record 35 | > = { 36 | [K in keyof M | keyof T]: T[M[K] extends never ? K : M[K]]; 37 | }; 38 | 39 | /** Creates a proxy mapping additional properties to other properties on the original. */ 40 | export const mappedProxy = < 41 | T extends Record, 42 | M extends Record 43 | >(target: T, mapping: M): MappedProxy => { 44 | const map = new Map(Object.entries(mapping)); 45 | return new Proxy(target, { 46 | get(target, prop) { 47 | return target[map.get(prop as any) ?? prop]; 48 | }, 49 | set(target, prop, value) { 50 | target[map.get(prop as any) ?? prop] = value; 51 | return true; 52 | }, 53 | deleteProperty(target, prop) { 54 | delete target[map.get(prop as any) ?? prop]; 55 | map.delete(prop as any); 56 | return true; 57 | }, 58 | has(target, prop) { 59 | return map.has(prop as any) || prop in target; 60 | }, 61 | ownKeys() { 62 | return [...map.keys(), ...Object.keys(target)]; 63 | }, 64 | getOwnPropertyDescriptor(target, prop) { 65 | return Object.getOwnPropertyDescriptor(target, map.get(prop as any) ?? prop); 66 | }, 67 | defineProperty(target, prop, attributes) { 68 | Object.defineProperty(target, map.get(prop as any) ?? prop, attributes); 69 | return true; 70 | } 71 | }) as any; 72 | }; 73 | -------------------------------------------------------------------------------- /packages/dium/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./general"; 2 | export * from "./react"; 3 | -------------------------------------------------------------------------------- /packages/dium/tests/mock.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Reconciler, {Fiber} from "react-reconciler"; 3 | import {DefaultEventPriority, LegacyRoot} from "react-reconciler/constants"; 4 | 5 | (global as any).TESTING = true; 6 | 7 | (global as any).BdApi = { 8 | React, 9 | Webpack: { 10 | getModule: () => null 11 | }, 12 | Plugins: { 13 | get: () => ({}) 14 | } 15 | }; 16 | 17 | const reconciler = Reconciler({ 18 | supportsMutation: false, 19 | supportsPersistence: true, 20 | createInstance() {}, 21 | createTextInstance() {}, 22 | appendInitialChild() {}, 23 | finalizeInitialChildren: () => false, 24 | shouldSetTextContent: () => false, 25 | getRootHostContext: () => null, 26 | getChildHostContext: (parent) => parent, 27 | getPublicInstance: (instance) => instance, 28 | prepareForCommit: () => null, 29 | resetAfterCommit() {}, 30 | preparePortalMount() {}, 31 | scheduleTimeout: setTimeout, 32 | cancelTimeout: clearTimeout, 33 | noTimeout: -1, 34 | isPrimaryRenderer: true, 35 | cloneInstance() {}, 36 | createContainerChildSet() {}, 37 | appendChildToContainerChildSet() {}, 38 | finalizeContainerChildren() {}, 39 | replaceContainerChildren() {}, 40 | cloneHiddenInstance() {}, 41 | cloneHiddenTextInstance() {}, 42 | getInstanceFromNode: () => null, 43 | beforeActiveInstanceBlur() {}, 44 | afterActiveInstanceBlur() {}, 45 | prepareScopeUpdate() {}, 46 | getInstanceFromScope() {}, 47 | detachDeletedInstance() {}, 48 | supportsHydration: false, 49 | NotPendingTransition: null, 50 | HostTransitionContext: React.createContext(null) as any, 51 | setCurrentUpdatePriority() {}, 52 | getCurrentUpdatePriority: () => DefaultEventPriority, 53 | resolveUpdatePriority: () => DefaultEventPriority, 54 | resetFormInstance() {}, 55 | requestPostPaintCallback() {}, 56 | shouldAttemptEagerTransition: () => true, 57 | trackSchedulerEvent() {}, 58 | resolveEventType: () => null, 59 | resolveEventTimeStamp: () => 0, 60 | maySuspendCommit: () => false, 61 | preloadInstance: () => true, 62 | startSuspendingCommit() {}, 63 | suspendInstance() {}, 64 | waitForCommitToBeReady: () => null 65 | }); 66 | 67 | export const createFiber = (element: React.ReactElement): Fiber => { 68 | const root = reconciler.createContainer({}, LegacyRoot, null, false, false, "", console.error, null); 69 | (reconciler as any).updateContainerSync(element, root); 70 | (reconciler as any).flushSyncWork(); 71 | return root.current; 72 | }; 73 | -------------------------------------------------------------------------------- /packages/dium/tests/utils/general.ts: -------------------------------------------------------------------------------- 1 | import {describe, it} from "mocha"; 2 | import {strict as assert} from "assert"; 3 | 4 | import {mappedProxy} from "../../src/utils"; 5 | 6 | describe("Utilities", () => { 7 | describe("mappedProxy", () => { 8 | const original = { 9 | a: "foo", 10 | get b() { 11 | return [1, 2, 3]; 12 | }, 13 | c: (arg: any) => console.log(arg) 14 | }; 15 | const mapping = { 16 | name: "a", 17 | data: "b", 18 | log: "c" 19 | } as const; 20 | 21 | it("maps read", () => { 22 | const mapped = mappedProxy(original, mapping); 23 | 24 | assert.equal(mapped.a, original.a); 25 | assert.equal(mapped.name, original.a); 26 | assert.deepEqual(mapped.b, original.b); 27 | assert.deepEqual(mapped.data, original.b); 28 | assert.equal(mapped.c, original.c); 29 | assert.equal(mapped.log, original.c); 30 | }); 31 | 32 | it("maps keys", () => { 33 | const mapped = mappedProxy(original, mapping); 34 | 35 | assert("name" in mapped); 36 | assert("a" in mapped); 37 | assert.deepEqual(Object.keys(mapped).sort(), [...Object.keys(original), ...Object.keys(mapping)].sort()); 38 | }); 39 | 40 | it("maps write", () => { 41 | const cloned = {...original}; 42 | const mapped = mappedProxy(cloned, mapping); 43 | 44 | assert.doesNotThrow(() => mapped.name = "bar"); 45 | assert.equal(mapped.name, "bar"); 46 | assert.equal(mapped.a, "bar"); 47 | assert.equal(cloned.a, "bar"); 48 | }); 49 | 50 | it("maps delete", () => { 51 | const cloned = {...original}; 52 | const mapped = mappedProxy(cloned, mapping); 53 | delete mapped.log; 54 | 55 | assert.equal(mapped.log, undefined, "value remained in mapped"); 56 | assert(!("log" in mapped), "key remained in mapped"); 57 | assert.equal(cloned.c, undefined, "value remained in original"); 58 | assert(!("c" in cloned), "key remained in original"); 59 | }); 60 | 61 | it("maps descriptor get", () => { 62 | const mapped = mappedProxy(original, mapping); 63 | 64 | assert.deepEqual(Object.getOwnPropertyDescriptor(mapped, "name"), Object.getOwnPropertyDescriptor(original, "a")); 65 | assert.deepEqual(Object.getOwnPropertyDescriptor(mapped, "a"), Object.getOwnPropertyDescriptor(original, "a")); 66 | assert.deepEqual(Object.getOwnPropertyDescriptor(mapped, "data"), Object.getOwnPropertyDescriptor(original, "b")); 67 | assert.deepEqual(Object.getOwnPropertyDescriptor(mapped, "log"), Object.getOwnPropertyDescriptor(original, "c")); 68 | }); 69 | 70 | it("maps descriptor set", () => { 71 | const cloned = {...original}; 72 | const mapped = mappedProxy(cloned, mapping); 73 | Object.defineProperty(mapped, "data", { 74 | get: () => [] 75 | }); 76 | 77 | assert.deepEqual(mapped.data, []); 78 | assert.deepEqual(cloned.b, []); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /packages/dium/tests/utils/react.tsx: -------------------------------------------------------------------------------- 1 | import {describe, it} from "mocha"; 2 | import {strict as assert} from "assert"; 3 | import React from "react"; 4 | import {createFiber} from "../mock"; 5 | 6 | import {Direction, Predicate, queryFiber, queryTree, queryTreeAll} from "../../src/utils/react"; 7 | import type {Fiber} from "../../src/react-internals"; 8 | 9 | const TestComponent = ({children}: {children: React.JSX.Element}): React.JSX.Element => children; 10 | 11 | const elements = ( 12 |

13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | 22 | describe("React element tree", () => { 23 | const tree = elements; 24 | 25 | describe("queryTree", () => { 26 | it("finds a result", () => { 27 | assert(queryTree(tree, (node) => node.type === "span") instanceof Object); 28 | }); 29 | 30 | it("finds the correct node", () => { 31 | assert.equal(queryTree(tree, (node) => node.type === "span"), tree.props.children[1]); 32 | }); 33 | }); 34 | 35 | describe("queryTreeAll", () => { 36 | it("finds a result", () => { 37 | assert(queryTreeAll(tree, (node) => node.type === "span").length > 0); 38 | }); 39 | 40 | it("finds the correct nodes", () => { 41 | assert.deepEqual( 42 | queryTreeAll(tree, (node) => node.type === "span"), 43 | [tree.props.children[1], tree.props.children[0].props.children] 44 | ); 45 | }); 46 | }); 47 | }); 48 | 49 | describe("React Fiber", () => { 50 | const root = createFiber(elements); 51 | const parent = root.child; 52 | const child = parent.child.child; 53 | 54 | const deepRoot = {key: "0"} as Fiber; 55 | let deepChild = deepRoot; 56 | for (let i = 1; i < 100; i++) { 57 | const child = { 58 | key: i.toString(), 59 | return: deepChild 60 | } as Fiber; 61 | deepChild.child = child; 62 | deepChild = child; 63 | } 64 | 65 | const createTrackingPredicate = (): [Predicate, Set] => { 66 | const calledOn = new Set(); 67 | const predicate = (node: Fiber) => { 68 | calledOn.add(parseInt(node.key)); 69 | return false; 70 | }; 71 | return [predicate, calledOn]; 72 | }; 73 | 74 | describe("queryFiber", () => { 75 | it("finds a result upwards", () => { 76 | assert(queryFiber(child, (node) => node.type === "div", Direction.Up) instanceof Object); 77 | }); 78 | 79 | it("finds the correct node upwards", () => { 80 | assert.equal(queryFiber(parent, (node) => node.type === "div", Direction.Up), parent); 81 | }); 82 | 83 | it("finds a result downwards", () => { 84 | assert(queryFiber(parent, (node) => node.type === "span", Direction.Down) instanceof Object); 85 | }); 86 | 87 | it("finds the correct node downwards", () => { 88 | assert.equal(queryFiber(parent, (node) => node.type === "span", Direction.Down), child); 89 | }); 90 | 91 | it("stops after max depth upwards", () => { 92 | const depth = 30; 93 | const [predicate, calledOn] = createTrackingPredicate(); 94 | queryFiber(deepChild, predicate, Direction.Up); 95 | 96 | const expected = new Set([...Array(depth + 1).keys()].map((i) => 99 - i)); // includes call on node itself 97 | assert.deepEqual(calledOn, expected); 98 | }); 99 | 100 | it("stops after max depth downwards", () => { 101 | const depth = 30; 102 | const [predicate, calledOn] = createTrackingPredicate(); 103 | queryFiber(deepRoot, predicate, Direction.Down); 104 | 105 | const expected = new Set(Array(depth + 1).keys()); // includes call on node itself 106 | assert.deepEqual(calledOn, expected); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /packages/dium/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "jsx": "react", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true 8 | }, 9 | "ts-node": { 10 | "files": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/rollup-plugin-bd-meta/index.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import type {Plugin} from "rollup"; 3 | import {resolvePkg, readMetaFromPkg, writeMeta, Meta} from "bd-meta"; 4 | 5 | export interface Options { 6 | meta?: Partial; 7 | authorGithub?: boolean; 8 | } 9 | 10 | /** 11 | * Rollup plugin for BetterDiscord plugin meta generation. 12 | */ 13 | export function bdMeta({meta, authorGithub}: Options = {}): Plugin { 14 | const pkgFiles: Record = {}; 15 | 16 | return { 17 | name: "bd-meta", 18 | async buildStart({input}) { 19 | const inputFiles = Array.isArray(input) ? input : Object.values(input); 20 | for (const input of inputFiles) { 21 | const pkg = await resolvePkg(path.dirname(input)); 22 | pkgFiles[input] = pkg; 23 | this.addWatchFile(pkg); 24 | } 25 | }, 26 | async watchChange(id, {event}) { 27 | for (const input of Object.keys(pkgFiles)) { 28 | if (pkgFiles[input] == id) { 29 | if (event === "delete") { 30 | const pkg = await resolvePkg(path.dirname(input)); 31 | pkgFiles[input] = pkg; 32 | this.addWatchFile(pkg); 33 | } 34 | break; 35 | } 36 | } 37 | }, 38 | renderChunk: { 39 | order: "post", 40 | async handler(code, chunk) { 41 | if (chunk.isEntry) { 42 | const pkg = pkgFiles[chunk.facadeModuleId]; 43 | const combinedMeta = { 44 | ...pkg ? await readMetaFromPkg(pkg, {authorGithub}) : {}, 45 | ...meta 46 | }; 47 | if (Object.keys(combinedMeta).length > 0) { 48 | return { 49 | code: writeMeta(combinedMeta) + code, 50 | map: {mappings: ""} 51 | }; 52 | } 53 | } 54 | } 55 | } 56 | }; 57 | } 58 | 59 | export default bdMeta; 60 | -------------------------------------------------------------------------------- /packages/rollup-plugin-bd-meta/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rollup-plugin-bd-meta", 3 | "version": "0.1.0", 4 | "author": "Zerthox", 5 | "peerDependencies": { 6 | "rollup": "^4.0.0" 7 | }, 8 | "devDependencies": { 9 | "rollup": "^4.0.0" 10 | }, 11 | "dependencies": { 12 | "bd-meta": "^0.1.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/rollup-plugin-bd-wscript/index.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import {readFileSync} from "fs"; 3 | import type {Plugin} from "rollup"; 4 | 5 | const wscript = readFileSync(path.join(__dirname, "wscript.js"), "utf8") 6 | .split("\n") 7 | .filter((line) => { 8 | const trim = line.trim(); 9 | return trim.length > 0 && !trim.startsWith("//") && !trim.startsWith("/*"); 10 | }).join("\n"); 11 | 12 | /** 13 | * Rollup plugin for BetterDiscord WScript warning generation. 14 | */ 15 | export function bdWScript(): Plugin { 16 | return { 17 | name: "bd-meta", 18 | banner: `/*@cc_on @if (@_jscript)\n${wscript}\n@else @*/\n`, 19 | footer: "/*@end @*/" 20 | }; 21 | } 22 | 23 | export default bdWScript; 24 | -------------------------------------------------------------------------------- /packages/rollup-plugin-bd-wscript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rollup-plugin-bd-wscript", 3 | "version": "0.1.0", 4 | "author": "Zerthox", 5 | "peerDependencies": { 6 | "rollup": "^4.0.0" 7 | }, 8 | "devDependencies": { 9 | "rollup": "^4.0.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/rollup-plugin-bd-wscript/wscript.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var pluginName = WScript.ScriptName.split(".")[0]; 3 | var shell = WScript.CreateObject("WScript.Shell"); 4 | 5 | shell.Popup( 6 | "Do NOT run scripts from the internet with the Windows Script Host!\nMove this file to your BetterDiscord plugins folder.", 7 | 0, 8 | pluginName + ": Warning!", 9 | 0x1030 10 | ); 11 | 12 | var fso = new ActiveXObject("Scripting.FileSystemObject"); 13 | var pluginsPath = shell.expandEnvironmentStrings("%appdata%\\BetterDiscord\\plugins"); 14 | if (!fso.FolderExists(pluginsPath)) { 15 | var popup = shell.Popup( 16 | "Unable to find BetterDiscord on your computer.\nOpen the download page of BetterDiscord?", 17 | 0, 18 | pluginName + ": BetterDiscord not found", 19 | 0x34 20 | ); 21 | if (popup === 6) { 22 | shell.Exec("explorer \"https://betterdiscord.app\""); 23 | } 24 | } else if (WScript.ScriptFullName === pluginsPath + "\\" + WScript.ScriptName) { 25 | shell.Popup( 26 | "This plugin is already in the correct folder.\nNavigate to the \"Plugins\" settings tab in Discord and enable it there.", 27 | 0, 28 | pluginName, 29 | 0x40 30 | ); 31 | } else { 32 | var popup = shell.Popup( 33 | "Open the BetterDiscord plugins folder?", 34 | 0, 35 | pluginName, 36 | 0x34 37 | ); 38 | if (popup === 6) { 39 | shell.Exec("explorer " + pluginsPath); 40 | } 41 | } 42 | 43 | WScript.Quit(); 44 | -------------------------------------------------------------------------------- /packages/rollup-plugin-style-modules/index.ts: -------------------------------------------------------------------------------- 1 | import postcss from "postcss"; 2 | import postcssModules from "postcss-modules"; 3 | import camelCase from "lodash.camelcase"; 4 | import type {Plugin} from "rollup"; 5 | 6 | export type PostCSSModulesOptions = Parameters[0]; 7 | 8 | export interface Options { 9 | modules?: Omit; 10 | cleanup?: boolean; 11 | } 12 | 13 | /** 14 | * Rollup plugin for custom style handling. 15 | * 16 | * Transforms CSS modules and removes empty rules. 17 | * Exports styles as string as named `css` export and mapped classNames as `default` export. 18 | */ 19 | export function styleModules({modules, cleanup = true}: Options = {}): Plugin { 20 | const filter = (id: string) => /\.module\.(css|scss|sass)$/.test(id); 21 | 22 | return { 23 | name: "style-modules", 24 | async transform(code, id) { 25 | if (filter(id)) { 26 | // we expect stringified css string to be the default export 27 | const [, cssString] = /\s*export\s+default\s+(.+)$/.exec(code) ?? []; 28 | if (cssString) { 29 | const css = JSON.parse(cssString); 30 | 31 | let mapping: Record; 32 | const result = await postcss() 33 | .use(postcssModules({ 34 | ...modules, 35 | getJSON: (_file, json) => mapping = json 36 | })) 37 | .use(cleanup ? { 38 | postcssPlugin: "cleanup", 39 | OnceExit(root) { 40 | for (const child of root.nodes) { 41 | if (child.type === "rule") { 42 | const contents = child.nodes.filter((node) => node.type !== "comment"); 43 | if (contents.length === 0) { 44 | child.remove(); 45 | } 46 | } 47 | } 48 | } 49 | } : null) 50 | .process(css, {from: id}); 51 | 52 | const named = Object.entries(mapping).map(([key, value]) => ` ${camelCase(key)}: ${JSON.stringify(value)}`).join(",\n"); 53 | return { 54 | code: `export const css = ${JSON.stringify(result.css)};\nexport default {\n${named}\n}`, 55 | map: { 56 | mappings: "" 57 | } 58 | }; 59 | } 60 | } 61 | } 62 | }; 63 | } 64 | 65 | export default styleModules; 66 | -------------------------------------------------------------------------------- /packages/rollup-plugin-style-modules/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rollup-plugin-style-modules", 3 | "version": "0.1.0", 4 | "author": "Zerthox", 5 | "dependencies": { 6 | "lodash.camelcase": "^4.3.0", 7 | "postcss": "^8.4.21", 8 | "postcss-modules": "^6.0.0" 9 | }, 10 | "peerDependencies": { 11 | "rollup": "^4.0.0" 12 | }, 13 | "devDependencies": { 14 | "rollup": "^4.0.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "rollup"; 2 | import json from "@rollup/plugin-json"; 3 | import scss from "rollup-plugin-scss"; 4 | import typescript from "@rollup/plugin-typescript"; 5 | import cleanup from "rollup-plugin-cleanup"; 6 | 7 | export default defineConfig({ 8 | output: { 9 | format: "cjs", 10 | exports: "default", 11 | generatedCode: { 12 | constBindings: true, 13 | objectShorthand: true 14 | }, 15 | freeze: false 16 | }, 17 | plugins: [ 18 | json({ 19 | namedExports: true, 20 | preferConst: true 21 | }), 22 | scss({ 23 | output: false 24 | }), 25 | typescript(), 26 | cleanup({ 27 | comments: [/[@#]__PURE__/], 28 | maxEmptyLines: 0, 29 | extensions: ["js", "ts", "tsx"], 30 | sourcemap: false 31 | }) 32 | ], 33 | treeshake: { 34 | preset: "smallest", 35 | annotations: true, 36 | moduleSideEffects: false, 37 | propertyReadSideEffects: false 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import {readdirSync} from "fs"; 3 | import minimist from "minimist"; 4 | import chalk from "chalk"; 5 | import * as rollup from "rollup"; 6 | import styleModules from "rollup-plugin-style-modules"; 7 | import {resolvePkg, readMetaFromPkg} from "bd-meta"; 8 | import bdMeta from "rollup-plugin-bd-meta"; 9 | import bdWScript from "rollup-plugin-bd-wscript"; 10 | import rollupConfig from "../rollup.config"; 11 | import {repository} from "../package.json"; 12 | 13 | const success = (msg: string) => console.log(chalk.green(msg)); 14 | const warn = (msg: string) => console.warn(chalk.yellow(`Warn: ${msg}`)); 15 | const error = (msg: string) => console.error(chalk.red(`Error: ${msg}`)); 16 | 17 | // find sources 18 | const sourceFolder = path.resolve(__dirname, "../src"); 19 | const sourceEntries = readdirSync(sourceFolder, {withFileTypes: true}).filter((entry) => entry.isDirectory()); 20 | 21 | // parse args 22 | const args = minimist(process.argv.slice(2), {boolean: ["dev", "watch"]}); 23 | 24 | // resolve input paths 25 | let inputPaths: string[] = []; 26 | if (args._.length === 0) { 27 | inputPaths = sourceEntries.map((entry) => path.resolve(sourceFolder, entry.name)); 28 | } else { 29 | for (const name of args._) { 30 | const entry = sourceEntries.find((entry) => entry.name.toLowerCase() === name.toLowerCase()); 31 | if (entry) { 32 | inputPaths.push(path.resolve(sourceFolder, entry.name)); 33 | } else { 34 | warn(`Unknown plugin "${name}"`); 35 | } 36 | } 37 | } 38 | 39 | // check for inputs 40 | if (inputPaths.length === 0) { 41 | error("No plugin inputs"); 42 | process.exit(1); 43 | } 44 | 45 | // resolve output directory 46 | const outDir = args.dev ? path.resolve( 47 | process.platform === "win32" ? process.env.APPDATA 48 | : process.platform === "darwin" ? path.resolve(process.env.HOME, "Library/Application Support") 49 | : path.resolve(process.env.HOME, ".config"), 50 | "BetterDiscord/plugins" 51 | ) : path.resolve(__dirname, "../dist/bd"); 52 | 53 | const watchers: Record = {}; 54 | 55 | // build each input 56 | for (const inputPath of inputPaths) { 57 | const outputPath = path.resolve(outDir, `${path.basename(inputPath)}.plugin.js`); 58 | 59 | if (args.watch) { 60 | // watch for changes 61 | watch(inputPath, outputPath).then(() => console.log(`Watching for changes in "${inputPath}"`)); 62 | } else { 63 | // build once 64 | build(inputPath, outputPath); 65 | } 66 | } 67 | if (args.watch) { 68 | // keep process alive 69 | process.stdin.resume(); 70 | process.stdin.on("end", () => { 71 | for (const watcher of Object.values(watchers)) { 72 | watcher.close(); 73 | } 74 | }); 75 | } 76 | 77 | async function build(inputPath: string, outputPath: string): Promise { 78 | const meta = await readMetaFromPkg(await resolvePkg(inputPath)); 79 | const config = generateRollupConfig(meta.name, inputPath, outputPath); 80 | 81 | // bundle plugin 82 | const bundle = await rollup.rollup(config); 83 | await bundle.write(config.output); 84 | success(`Built ${meta.name} v${meta.version} to "${outputPath}"`); 85 | 86 | await bundle.close(); 87 | } 88 | 89 | async function watch(inputPath: string, outputPath: string): Promise { 90 | const pkgPath = await resolvePkg(inputPath); 91 | const meta = await readMetaFromPkg(pkgPath); 92 | const {plugins, ...config} = generateRollupConfig(meta.name, inputPath, outputPath); 93 | 94 | // start watching 95 | const watcher = rollup.watch({ 96 | ...config, 97 | plugins: [ 98 | plugins, 99 | { 100 | name: "package-watcher", 101 | buildStart() { 102 | this.addWatchFile(pkgPath); 103 | } 104 | } 105 | ] 106 | }); 107 | 108 | // close finished bundles 109 | watcher.on("event", (event) => { 110 | if (event.code === "BUNDLE_END") { 111 | success(`Built ${meta.name} v${meta.version} to "${outputPath}" [${event.duration}ms]`); 112 | event.result.close(); 113 | } 114 | }); 115 | 116 | // restart on config changes 117 | watcher.on("change", (file) => { 118 | // check for config changes 119 | if (file === pkgPath) { 120 | watchers[inputPath].close(); 121 | watch(inputPath, outputPath); 122 | } 123 | 124 | console.log(`=> Changed "${file}"`); 125 | }); 126 | 127 | watchers[inputPath] = watcher; 128 | } 129 | 130 | interface RollupConfig extends Omit { 131 | output: rollup.OutputOptions; 132 | } 133 | 134 | function generateRollupConfig(name: string, inputPath: string, outputPath: string): RollupConfig { 135 | const {output, plugins, ...rest} = rollupConfig; 136 | 137 | return { 138 | ...rest, 139 | input: path.resolve(inputPath, "index.tsx"), 140 | plugins: [ 141 | plugins, 142 | styleModules({ 143 | modules: { 144 | generateScopedName: `[local]-${name}` 145 | }, 146 | cleanup: true 147 | }), 148 | bdMeta({ 149 | meta: { 150 | website: repository, 151 | source: `${repository}/tree/master/src/${path.basename(inputPath)}` 152 | }, 153 | authorGithub: true 154 | }), 155 | bdWScript() 156 | ], 157 | output: { 158 | ...output, 159 | file: outputPath 160 | } 161 | }; 162 | } 163 | -------------------------------------------------------------------------------- /src/BetterFolders/icon.tsx: -------------------------------------------------------------------------------- 1 | import {Finder, Logger, React, Utils} from "dium"; 2 | import {Settings, FolderData} from "./settings"; 3 | import styles from "./styles.module.scss"; 4 | 5 | const folderStyles = Finder.byKeys(["folderIcon", "folderIconWrapper", "folderPreviewWrapper"]); 6 | 7 | export const renderIcon = (data: FolderData): React.JSX.Element => ( 8 |
12 | ); 13 | 14 | export interface BetterFolderIconProps { 15 | data?: FolderData; 16 | childProps: any; 17 | FolderIcon: React.FunctionComponent; 18 | } 19 | 20 | export const BetterFolderIcon = ({data, childProps, FolderIcon}: BetterFolderIconProps): React.JSX.Element => { 21 | if (FolderIcon) { 22 | const result = FolderIcon(childProps) as React.JSX.Element; 23 | if (data?.icon) { 24 | const replace = renderIcon(data); 25 | const iconWrapper = Utils.queryTree(result, (node) => node?.props?.className === folderStyles.folderIconWrapper); 26 | if (iconWrapper) { 27 | Utils.replaceElement(iconWrapper, replace); 28 | } else { 29 | Logger.error("Failed to find folderIconWrapper element"); 30 | } 31 | if (data.always) { 32 | const previewWrapper = Utils.queryTree(result, (node) => node?.props?.className === folderStyles.folderPreviewWrapper); 33 | if (previewWrapper) { 34 | Utils.replaceElement(previewWrapper, replace); 35 | } else { 36 | Logger.error("Failed to find folderPreviewWrapper element"); 37 | } 38 | } 39 | } 40 | return result; 41 | } else { 42 | return null; 43 | } 44 | }; 45 | 46 | export interface ConnectedBetterFolderIconProps { 47 | folderId: number; 48 | childProps: any; 49 | FolderIcon: React.FunctionComponent; 50 | } 51 | 52 | const compareFolderData = (a?: FolderData, b?: FolderData): boolean => a?.icon === b?.icon && a?.always === b?.always; 53 | 54 | export const ConnectedBetterFolderIcon = ({folderId, ...props}: ConnectedBetterFolderIconProps): React.JSX.Element => { 55 | const data = Settings.useSelector( 56 | (current) => current.folders[folderId], 57 | [folderId], 58 | compareFolderData 59 | ); 60 | return ; 61 | }; 62 | -------------------------------------------------------------------------------- /src/BetterFolders/index.tsx: -------------------------------------------------------------------------------- 1 | import {createPlugin, Logger, Filters, Finder, Patcher, Utils, React, Fiber} from "dium"; 2 | import {ClientActions, ExpandedGuildFolderStore} from "@dium/modules"; 3 | import {FormSwitch} from "@dium/components"; 4 | import {Settings} from "./settings"; 5 | import {ConnectedBetterFolderIcon} from "./icon"; 6 | import {folderModalPatch, FolderSettingsModal} from "./modal"; 7 | import {css} from "./styles.module.scss"; 8 | 9 | const guildStyles = Finder.byKeys(["guilds", "base"]); 10 | 11 | const getGuildsOwner = () => Utils.findOwner(Utils.getFiber(document.getElementsByClassName(guildStyles.guilds)?.[0])); 12 | 13 | const triggerRerender = async (guildsFiber: Fiber) => { 14 | if (await Utils.forceFullRerender(guildsFiber)) { 15 | Logger.log("Rerendered guilds"); 16 | } else { 17 | Logger.warn("Unable to rerender guilds"); 18 | } 19 | }; 20 | 21 | export default createPlugin({ 22 | start() { 23 | let FolderIcon = null; 24 | const guildsOwner = getGuildsOwner(); 25 | 26 | // patch folder icon wrapper 27 | // icon is in same module, not exported 28 | const FolderIconWrapper = Finder.findWithKey>(Filters.bySource("folderIconWrapper")); 29 | Patcher.after(...FolderIconWrapper, ({args: [props], result}) => { 30 | const icon = Utils.queryTree(result, (node) => node?.props?.folderNode) as React.ReactElement>; 31 | if (!icon) { 32 | return Logger.error("Unable to find FolderIcon component"); 33 | } 34 | 35 | // save icon component 36 | if (!FolderIcon) { 37 | Logger.log("Found FolderIcon component"); 38 | FolderIcon = icon.type; 39 | } 40 | 41 | // replace icon with own component 42 | const replace = ; 47 | Utils.replaceElement(icon, replace); 48 | }, {name: "FolderIconWrapper"}); 49 | triggerRerender(guildsOwner); 50 | 51 | // patch folder expand 52 | Patcher.after(ClientActions, "toggleGuildFolderExpand", ({original, args: [folderId]}) => { 53 | if (Settings.current.closeOnOpen) { 54 | for (const id of ExpandedGuildFolderStore.getExpandedFolders()) { 55 | if (id !== folderId) { 56 | original(id); 57 | } 58 | } 59 | } 60 | }); 61 | 62 | // patch folder settings render 63 | Finder.waitFor(Filters.bySource(".folderName", ".onClose"), {entries: true}).then((FolderSettingsModal: FolderSettingsModal) => { 64 | if (FolderSettingsModal) { 65 | Patcher.after( 66 | FolderSettingsModal.prototype, 67 | "render", 68 | folderModalPatch, 69 | {name: "GuildFolderSettingsModal"} 70 | ); 71 | } 72 | }); 73 | }, 74 | stop() { 75 | triggerRerender(getGuildsOwner()); 76 | }, 77 | styles: css, 78 | Settings, 79 | SettingsPanel: () => { 80 | const [{closeOnOpen}, setSettings] = Settings.useState(); 81 | 82 | return ( 83 | { 88 | if (checked) { 89 | // close all folders except one 90 | for (const id of Array.from(ExpandedGuildFolderStore.getExpandedFolders()).slice(1)) { 91 | ClientActions.toggleGuildFolderExpand(id); 92 | } 93 | } 94 | setSettings({closeOnOpen: checked}); 95 | }} 96 | >Close on open 97 | ); 98 | } 99 | }); 100 | -------------------------------------------------------------------------------- /src/BetterFolders/modal.tsx: -------------------------------------------------------------------------------- 1 | import {React, Logger, PatchDataWithResult, Utils} from "dium"; 2 | import {SortedGuildStore, GuildsTreeFolder} from "@dium/modules"; 3 | import {RadioGroup, FormItem} from "@dium/components"; 4 | import {BetterFolderUploader} from "./uploader"; 5 | import {Settings} from "./settings"; 6 | 7 | export interface FolderSettingsModalProps { 8 | folderId: number; 9 | folderName: string; 10 | folderColor: number; 11 | onClose: () => void; 12 | transitionState: number; 13 | } 14 | 15 | export interface FolderSettingsModalState { 16 | name: string; 17 | color: number; 18 | } 19 | 20 | export type FolderSettingsModal = typeof React.Component; 21 | 22 | const enum IconType { 23 | Default = "default", 24 | Custom = "custom" 25 | } 26 | 27 | interface PatchedFolderSettingsModalState extends FolderSettingsModalState { 28 | iconType: IconType; 29 | icon?: string; 30 | always?: boolean; 31 | } 32 | 33 | type PatchedModal = React.Component; 34 | 35 | export const folderModalPatch = ({context, result}: PatchDataWithResult): void => { 36 | const {folderId} = context.props; 37 | const {state} = context; 38 | 39 | // find form 40 | const form = Utils.queryTree(result, (node) => node?.type === "form"); 41 | if (!form) { 42 | Logger.warn("Unable to find form"); 43 | return; 44 | } 45 | 46 | // add custom state 47 | if (!state.iconType) { 48 | const {icon = null, always = false} = Settings.current.folders[folderId] ?? {}; 49 | Object.assign(state, { 50 | iconType: icon ? IconType.Custom : IconType.Default, 51 | icon, 52 | always 53 | }); 54 | } 55 | 56 | // render icon select 57 | const {children} = form.props; 58 | const {className} = children[0].props; 59 | children.push( 60 | 61 | context.setState({iconType: value})} 68 | /> 69 | 70 | ); 71 | 72 | if (state.iconType === IconType.Custom) { 73 | // render custom icon options 74 | const tree = SortedGuildStore.getGuildsTree(); 75 | children.push( 76 | 77 | context.setState({icon, always})} 82 | /> 83 | 84 | ); 85 | } 86 | 87 | // override submit onclick 88 | const button = Utils.queryTree(result, (node) => node?.props?.type === "submit"); 89 | const original = button.props.onClick; 90 | button.props.onClick = (...args: any[]) => { 91 | original(...args); 92 | 93 | // update folders if necessary 94 | const {folders} = Settings.current; 95 | if (state.iconType === IconType.Custom && state.icon) { 96 | folders[folderId] = {icon: state.icon, always: state.always}; 97 | Settings.update({folders}); 98 | } else if ((state.iconType === IconType.Default || !state.icon) && folders[folderId]) { 99 | delete folders[folderId]; 100 | Settings.update({folders}); 101 | } 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /src/BetterFolders/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BetterFolders", 3 | "author": "Zerthox", 4 | "version": "3.6.2", 5 | "description": "Adds new functionality to server folders. Custom Folder Icons. Close other folders on open.", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/BetterFolders/settings.ts: -------------------------------------------------------------------------------- 1 | import {createSettings} from "dium"; 2 | 3 | export interface FolderData { 4 | icon: string; 5 | always: boolean; 6 | } 7 | 8 | export const Settings = createSettings({ 9 | closeOnOpen: false, 10 | folders: {} as Record 11 | }); 12 | -------------------------------------------------------------------------------- /src/BetterFolders/styles.module.scss: -------------------------------------------------------------------------------- 1 | .customIcon { 2 | box-sizing: border-box; 3 | border-radius: var(--radius-lg); 4 | width: var(--guildbar-folder-size); 5 | height: var(--guildbar-folder-size); 6 | padding: var(--custom-folder-preview-padding); 7 | background-size: contain; 8 | background-position: center; 9 | background-repeat: no-repeat; 10 | } 11 | -------------------------------------------------------------------------------- /src/BetterFolders/uploader.tsx: -------------------------------------------------------------------------------- 1 | import {React} from "dium"; 2 | import {GuildsTreeFolder} from "@dium/modules"; 3 | import {Flex, Button, FormSwitch, FormText, ImageInput, margins} from "@dium/components"; 4 | import {FolderData} from "./settings"; 5 | import {renderIcon} from "./icon"; 6 | 7 | export interface BetterFolderUploaderProps extends FolderData { 8 | folderNode: GuildsTreeFolder; 9 | onChange(data: FolderData): void; 10 | } 11 | 12 | export const BetterFolderUploader = ({icon, always, onChange}: BetterFolderUploaderProps): React.JSX.Element => ( 13 | <> 14 | 15 | 19 | Preview: 20 | {renderIcon({icon, always: true})} 21 | 22 | onChange({icon, always: checked})} 27 | >Always display icon 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /src/BetterVolume/experiment.tsx: -------------------------------------------------------------------------------- 1 | // legacy code for disabling audio experiment 2 | 3 | import {Logger, React, Utils, getMeta} from "dium"; 4 | import {ExperimentStore, ExperimentTreatment} from "@dium/modules"; 5 | import {Text} from "@dium/components"; 6 | import {Settings} from "./settings"; 7 | 8 | const AUDIO_EXPERIMENT = "2022-09_remote_audio_settings"; 9 | 10 | let initialAudioBucket = ExperimentTreatment.NOT_ELIGIBLE; 11 | 12 | export const hasExperiment = (): boolean => initialAudioBucket > ExperimentTreatment.CONTROL; 13 | 14 | const setAudioBucket = (bucket: number): void => { 15 | if (hasExperiment()) { 16 | Logger.log("Changing experiment bucket to", bucket); 17 | const audioExperiment = ExperimentStore.getUserExperimentDescriptor(AUDIO_EXPERIMENT); 18 | audioExperiment.bucket = bucket; 19 | } 20 | }; 21 | 22 | // update on settings change 23 | Settings.addListener(({disableExperiment}) => setAudioBucket(disableExperiment ? ExperimentTreatment.CONTROL : initialAudioBucket)); 24 | 25 | const onLoadExperiments = (): void => { 26 | // initialize bucket 27 | initialAudioBucket = ExperimentStore.getUserExperimentBucket(AUDIO_EXPERIMENT); 28 | Logger.log("Initial experiment bucket", initialAudioBucket); 29 | 30 | if (hasExperiment()) { 31 | const {disableExperiment} = Settings.current; 32 | Logger.log("Experiment setting:", disableExperiment); 33 | // check if we have to disable 34 | if (disableExperiment) { 35 | // simply setting this should be fine, seems to be only changed on connect etc. 36 | setAudioBucket(0); 37 | } else if (disableExperiment === null) { 38 | // initial value means we set to false and ask the user 39 | Settings.update({disableExperiment: false}); 40 | Utils.confirm(getMeta().name, ( 41 | 42 | Your client has an experiment interfering with volumes greater than 200% enabled. 43 | Do you wish to disable it now and on future restarts? 44 | 45 | ), { 46 | onConfirm: () => Settings.update({disableExperiment: true}) 47 | }); 48 | } 49 | } 50 | }; 51 | 52 | export const handleExperiment = (): void => { 53 | if (ExperimentStore.hasLoadedExperiments) { 54 | Logger.log("Experiments already loaded"); 55 | onLoadExperiments(); 56 | } else { 57 | Logger.log("Waiting for experiments load"); 58 | const listener = () => { 59 | if (ExperimentStore.hasLoadedExperiments) { 60 | Logger.log("Experiments loaded after wait"); 61 | ExperimentStore.removeChangeListener(listener); 62 | onLoadExperiments(); 63 | } 64 | }; 65 | ExperimentStore.addChangeListener(listener); 66 | } 67 | }; 68 | 69 | export const resetExperiment = (): void => { 70 | // reset experiment to initial bucket 71 | if (Settings.current.disableExperiment) { 72 | setAudioBucket(initialAudioBucket); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/BetterVolume/index.tsx: -------------------------------------------------------------------------------- 1 | import {createPlugin, Finder, Filters, Patcher, React} from "dium"; 2 | import {Snowflake, MediaEngineStore, MediaEngineContext, AudioConvert, MediaEngineActions} from "@dium/modules"; 3 | import {Settings} from "./settings"; 4 | import {css} from "./styles.module.scss"; 5 | import {MenuItem} from "@dium/components"; 6 | import {NumberInput} from "./input"; 7 | import {handleVolumeSync, resetVolumeSync} from "./sync"; 8 | 9 | type UseUserVolumeItem = (userId: Snowflake, context: MediaEngineContext) => React.JSX.Element; 10 | 11 | const useUserVolumeItemFilter = Filters.bySource("user-volume"); 12 | 13 | export default createPlugin({ 14 | start() { 15 | // handle volume override sync 16 | handleVolumeSync(); 17 | 18 | // add number input to user volume item 19 | Finder.waitFor(useUserVolumeItemFilter, {resolve: false}).then((result: Record) => { 20 | const useUserVolumeItem = Finder.resolveKey(result, useUserVolumeItemFilter); 21 | Patcher.after(...useUserVolumeItem, ({args: [userId, context], result}) => { 22 | // check for original render 23 | if (result) { 24 | // we can read this directly, the original has a hook to ensure updates 25 | const volume = MediaEngineStore.getLocalVolume(userId, context); 26 | 27 | return ( 28 | <> 29 | {result} 30 | ( 33 | MediaEngineActions.setLocalVolume( 39 | userId, 40 | AudioConvert.perceptualToAmplitude(value), 41 | context 42 | )} 43 | /> 44 | )} 45 | /> 46 | 47 | ); 48 | } 49 | }, {name: "useUserVolumeItem"}); 50 | }); 51 | }, 52 | stop() { 53 | resetVolumeSync(); 54 | }, 55 | styles: css, 56 | Settings 57 | }); 58 | -------------------------------------------------------------------------------- /src/BetterVolume/input.tsx: -------------------------------------------------------------------------------- 1 | import {React} from "dium"; 2 | import styles from "./styles.module.scss"; 3 | 4 | const limit = (input: number, min: number, max: number): number => Math.min(Math.max(input, min), max); 5 | 6 | export interface NumberInputProps { 7 | value: number; 8 | min: number; 9 | max: number; 10 | fallback: number; 11 | onChange(value: number): void; 12 | } 13 | 14 | export const NumberInput = ({value, min, max, fallback, onChange}: NumberInputProps): React.JSX.Element => { 15 | const [isEmpty, setEmpty] = React.useState(false); 16 | 17 | return ( 18 |
19 | { 26 | const value = limit(parseFloat(target.value), min, max); 27 | const isNaN = Number.isNaN(value); 28 | setEmpty(isNaN); 29 | if (!isNaN) { 30 | onChange(value); 31 | } 32 | }} 33 | onBlur={() => { 34 | if (isEmpty) { 35 | setEmpty(false); 36 | onChange(fallback); 37 | } 38 | }} 39 | /> 40 | % 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/BetterVolume/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BetterVolume", 3 | "author": "Zerthox", 4 | "version": "3.1.1", 5 | "description": "Set user volume values manually instead of using a slider. Allows setting volumes higher than 200%.", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/BetterVolume/settings.ts: -------------------------------------------------------------------------------- 1 | import {createSettings} from "dium"; 2 | import {MediaEngineContext, Snowflake} from "@dium/modules"; 3 | 4 | export interface Settings { 5 | volumeOverrides: Record>; 6 | 7 | /** @deprecated legacy */ 8 | disableExperiment: null | boolean; 9 | } 10 | 11 | export const Settings = createSettings({ 12 | volumeOverrides: {}, 13 | disableExperiment: null 14 | }); 15 | 16 | export const hasOverride = (userId: Snowflake, context: MediaEngineContext): boolean => context in (Settings.current.volumeOverrides[userId] ?? {}); 17 | 18 | export const updateVolumeOverride = (userId: Snowflake, volume: number, context: MediaEngineContext): boolean => { 19 | const isNew = !hasOverride(userId, context); 20 | Settings.update(({volumeOverrides}) => { 21 | volumeOverrides[userId] = {[context]: volume, ...volumeOverrides[userId]}; 22 | return {volumeOverrides}; 23 | }); 24 | return isNew; 25 | }; 26 | 27 | export const tryResetVolumeOverride = (userId: Snowflake, context: MediaEngineContext): boolean => { 28 | if (hasOverride(userId, context)) { 29 | Settings.update(({volumeOverrides}) => { 30 | delete volumeOverrides[userId][context]; 31 | if (Object.keys(volumeOverrides[userId]).length === 0) { 32 | delete volumeOverrides[userId]; 33 | } 34 | return {volumeOverrides}; 35 | }); 36 | return true; 37 | } 38 | return false; 39 | }; 40 | -------------------------------------------------------------------------------- /src/BetterVolume/styles.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 0 8px; 3 | padding: 3px 6px; 4 | background: var(--background-primary); 5 | border-radius: 3px; 6 | display: flex; 7 | } 8 | .input { 9 | margin-right: 2px; 10 | flex-grow: 1; 11 | background: transparent; 12 | border: none; 13 | color: var(--interactive-normal); 14 | font-weight: 500; 15 | &:hover::-webkit-inner-spin-button { 16 | appearance: auto; 17 | } 18 | } 19 | 20 | .unit { 21 | /* unused */ 22 | } 23 | -------------------------------------------------------------------------------- /src/BetterVolume/sync.ts: -------------------------------------------------------------------------------- 1 | import {Finder, Filters, Flux, Logger} from "dium"; 2 | import {Snowflake, Dispatcher, MediaEngineContext, AudioConvert} from "@dium/modules"; 3 | import {Settings, updateVolumeOverride as updateVolumeOverride, tryResetVolumeOverride} from "./settings"; 4 | 5 | const enum ActionType { 6 | POST_CONNECTION_OPEN = "POST_CONNECTION_OPEN", 7 | AUDIO_SET_LOCAL_VOLUME = "AUDIO_SET_LOCAL_VOLUME", 8 | USER_SETTINGS_PROTO_UPDATE = "USER_SETTINGS_PROTO_UPDATE" 9 | } 10 | 11 | const MAX_VOLUME_PERC = 200; 12 | 13 | const MAX_VOLUME_AMP = AudioConvert.perceptualToAmplitude(MAX_VOLUME_PERC); 14 | 15 | export const dispatchVolumeOverrides = (): void => { 16 | Logger.log("Dispatching volume overrides"); 17 | for (const [userId, contexts] of Object.entries(Settings.current.volumeOverrides)) { 18 | for (const [context, volume] of Object.entries(contexts)) { 19 | Dispatcher.dispatch({ 20 | type: ActionType.AUDIO_SET_LOCAL_VOLUME, 21 | userId, 22 | context, 23 | volume 24 | }); 25 | } 26 | } 27 | }; 28 | 29 | interface SetVolumeAction extends Flux.Action { 30 | type: ActionType.AUDIO_SET_LOCAL_VOLUME; 31 | userId: Snowflake; 32 | volume: number; 33 | context: MediaEngineContext; 34 | } 35 | 36 | const findAudioSettingsManager = (): AudioSettingsManager => { 37 | const hasSetVolume = Filters.byKeys(ActionType.AUDIO_SET_LOCAL_VOLUME); 38 | return Finder.find((exported) => exported.actions && hasSetVolume(exported.actions)); 39 | }; 40 | 41 | const handleAudioSettingsManager = (AudioSettingsManager: AudioSettingsManager): void => { 42 | originalHandler = AudioSettingsManager.actions[ActionType.AUDIO_SET_LOCAL_VOLUME]; 43 | const swapped = trySwapHandler(ActionType.AUDIO_SET_LOCAL_VOLUME, originalHandler, wrappedSettingsManagerHandler); 44 | if (swapped) { 45 | Logger.log(`Replaced AudioSettingsManager ${ActionType.AUDIO_SET_LOCAL_VOLUME} handler`); 46 | } else { 47 | Logger.warn(`AudioSettingsManager ${ActionType.AUDIO_SET_LOCAL_VOLUME} handler not present`); 48 | } 49 | }; 50 | 51 | const postConnectionOpenHandler = (_action: Flux.Action): void => { 52 | Logger.log(`Received ${ActionType.POST_CONNECTION_OPEN}`); 53 | 54 | dispatchVolumeOverrides(); 55 | 56 | const AudioSettingsManager = findAudioSettingsManager(); 57 | if (AudioSettingsManager) { 58 | handleAudioSettingsManager(AudioSettingsManager); 59 | } else { 60 | Logger.warn("Failed to find AudioSettingsManager"); 61 | } 62 | }; 63 | 64 | interface AudioSettingsManager { 65 | actions: Record & { 66 | AUDIO_SET_LOCAL_VOLUME: Flux.ActionHandler; 67 | }; 68 | initializedCount: number; 69 | stores: Map; 70 | } 71 | 72 | let originalHandler = null; 73 | 74 | const wrappedSettingsManagerHandler: Flux.ActionHandler = (action) => { 75 | const {userId, volume, context} = action; 76 | const isOverCap = volume > MAX_VOLUME_AMP; 77 | if (isOverCap) { 78 | const isNew = updateVolumeOverride(userId, volume, context); 79 | if (isNew) { 80 | Logger.log(`New volume override ${AudioConvert.amplitudeToPerceptual(volume)} for user ${userId} context ${context}`); 81 | originalHandler({...action, volume: MAX_VOLUME_AMP}); 82 | } 83 | } else { 84 | const wasRemoved = tryResetVolumeOverride(userId, context); 85 | if (wasRemoved) { 86 | Logger.log(`Removed volume override for user ${userId} context ${context}`); 87 | } 88 | originalHandler(action); 89 | } 90 | }; 91 | 92 | const trySwapHandler = (action: Flux.Action["type"], prev: Flux.ActionHandler, next: Flux.ActionHandler): boolean => { 93 | const isPresent = Dispatcher._subscriptions[action].has(prev); 94 | if (isPresent) { 95 | Dispatcher.unsubscribe(action, prev); 96 | Dispatcher.subscribe(action, next); 97 | } 98 | return isPresent; 99 | }; 100 | 101 | export const handleVolumeSync = (): void => { 102 | Dispatcher.subscribe(ActionType.POST_CONNECTION_OPEN, postConnectionOpenHandler); 103 | Logger.log(`Subscribed to ${ActionType.POST_CONNECTION_OPEN} events`); 104 | 105 | Dispatcher.subscribe(ActionType.USER_SETTINGS_PROTO_UPDATE, dispatchVolumeOverrides); 106 | Logger.log(`Subscribed to ${ActionType.USER_SETTINGS_PROTO_UPDATE} events`); 107 | 108 | const AudioSettingsManager = findAudioSettingsManager(); 109 | if (AudioSettingsManager) { 110 | dispatchVolumeOverrides(); 111 | handleAudioSettingsManager(AudioSettingsManager); 112 | } else { 113 | Logger.log(`AudioSettingsManager not found, waiting for ${ActionType.POST_CONNECTION_OPEN}`); 114 | } 115 | }; 116 | 117 | export const resetVolumeSync = (): void => { 118 | Dispatcher.unsubscribe(ActionType.POST_CONNECTION_OPEN, postConnectionOpenHandler); 119 | Logger.log(`Unsubscribed from ${ActionType.POST_CONNECTION_OPEN} events`); 120 | 121 | Dispatcher.unsubscribe(ActionType.USER_SETTINGS_PROTO_UPDATE, dispatchVolumeOverrides); 122 | Logger.log(`Unsubscribed from ${ActionType.USER_SETTINGS_PROTO_UPDATE} events`); 123 | 124 | const swapped = trySwapHandler(ActionType.AUDIO_SET_LOCAL_VOLUME, wrappedSettingsManagerHandler, originalHandler); 125 | if (swapped) { 126 | Logger.log(`Reset ${ActionType.AUDIO_SET_LOCAL_VOLUME} handler`); 127 | } 128 | }; 129 | -------------------------------------------------------------------------------- /src/CollapseEmbeds/hider.tsx: -------------------------------------------------------------------------------- 1 | import {React} from "dium"; 2 | import {classNames} from "@dium/modules"; 3 | import {Flex, Clickable, Text, IconArrow} from "@dium/components"; 4 | import {getCollapsedState, updateCollapsedState} from "./settings"; 5 | import styles from "./styles.module.scss"; 6 | 7 | export const enum AccessoryType { 8 | Embed = "embed", 9 | MediaItem = "mediaItem", 10 | MediaItemSingle = "mediaItemSingle", 11 | Attachment = "attachment" 12 | } 13 | 14 | export interface HiderProps { 15 | placeholders: string[]; 16 | type: AccessoryType; 17 | children: React.ReactNode; 18 | id?: string; 19 | } 20 | 21 | export const Hider = ({placeholders, type, children, id}: HiderProps): React.JSX.Element => { 22 | const [shown, setShown] = React.useState(() => getCollapsedState(id)); 23 | 24 | // refresh saved when id changes 25 | React.useEffect(() => updateCollapsedState(id, shown), [id]); 26 | 27 | const toggleShown = React.useCallback(() => { 28 | setShown(!shown); 29 | updateCollapsedState(id, !shown); 30 | }, [id, shown]); 31 | 32 | return ( 33 | 41 | {shown ? children : placeholders.filter(Boolean).map((placeholder, i) => ( 42 | {placeholder} 43 | ))} 44 | 48 | 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/CollapseEmbeds/index.tsx: -------------------------------------------------------------------------------- 1 | import {createPlugin, Filters, Finder, PatchDataWithResult, Patcher, React, Utils} from "dium"; 2 | import {Message, Attachment} from "@dium/modules"; 3 | import {MessageFooter, Embed, MediaItemProps} from "@dium/components"; 4 | import {Settings, cleanupOldEntries} from "./settings"; 5 | import {SettingsPanel} from "./settings-panel"; 6 | import {Hider, AccessoryType} from "./hider"; 7 | import {css} from "./styles.module.scss"; 8 | 9 | interface AttachmentsProps extends Record { 10 | attachments: AttachmentProps[]; 11 | } 12 | 13 | interface AttachmentProps extends Record { 14 | attachment: Attachment; 15 | message: Message; 16 | className: string; 17 | canRemoveAttachment: boolean; 18 | inlineMedia: boolean; 19 | autoPlayGif: boolean; 20 | } 21 | 22 | interface MediaModule { 23 | MediaItem: React.FunctionComponent; 24 | } 25 | 26 | const MediaModule: MediaModule = Finder.demangle({ 27 | MediaItem: Filters.bySource("getObscureReason", "isSingleMosaicItem") 28 | }, null, true); 29 | 30 | export default createPlugin({ 31 | start() { 32 | // Run cleanup on start 33 | cleanupOldEntries(); 34 | Patcher.after(Embed.prototype as InstanceType, "render", ({result, context}) => { 35 | const {embed} = context.props; 36 | const placeholder = embed.provider?.name ?? embed.author?.name ?? embed.rawTitle ?? new URL(embed.url).hostname; 37 | return ( 38 | {result} 43 | ); 44 | }, {name: "Embed render"}); 45 | 46 | Patcher.after(MediaModule, "MediaItem", ({args: [props], result}) => { 47 | const attachment = props.item.originalItem; 48 | const placeholder = attachment.filename ?? new URL(attachment.url).hostname; 49 | return ( 50 | {result as React.ReactNode} 55 | ); 56 | }, {name: "MediaItem render"}); 57 | 58 | Patcher.after(MessageFooter.prototype, "renderAttachments", ({result}: PatchDataWithResult<() => React.JSX.Element>) => { 59 | for (const element of Utils.queryTreeAll(result, (node) => node?.props?.attachments)) { 60 | Utils.hookFunctionComponent(element, (result, {attachments}) => { 61 | const placeholders = attachments.map(({attachment}) => attachment.filename ?? new URL(attachment.url).hostname); 62 | const id = attachments[0]?.attachment?.url; 63 | return ( 64 | {result} 69 | ); 70 | }); 71 | } 72 | }, {name: "MessageFooter renderAttachments"}); 73 | }, 74 | stop() { 75 | // Run cleanup on stop 76 | cleanupOldEntries(); 77 | }, 78 | styles: css, 79 | Settings, 80 | SettingsPanel 81 | }); 82 | -------------------------------------------------------------------------------- /src/CollapseEmbeds/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CollapseEmbeds", 3 | "author": "Zerthox", 4 | "version": "2.0.0", 5 | "description": "Adds a button to collapse embeds & attachments.", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/CollapseEmbeds/settings-panel.tsx: -------------------------------------------------------------------------------- 1 | import {React} from "dium"; 2 | import {FormItem, FormSwitch, FormText, FormTextTypes, TextInput} from "@dium/components"; 3 | import {Settings, DAYS_TO_MILLIS, cleanupOldEntries} from "./settings"; 4 | 5 | export function SettingsPanel(): React.JSX.Element { 6 | const [{hideByDefault, saveStates, saveDuration}, setSettings] = Settings.useState(); 7 | 8 | const [{text, valid}, setDurationState] = React.useState({ 9 | text: (saveDuration / DAYS_TO_MILLIS).toString(), 10 | valid: true 11 | }); 12 | 13 | return ( 14 | <> 15 | setSettings({hideByDefault: checked})} 20 | >Collapse by default 21 | setSettings({saveStates: checked})} 26 | >Save collapsed states 27 | 32 | { 38 | const duration = Number.parseFloat(text) * DAYS_TO_MILLIS; 39 | const valid = !Number.isNaN(duration) && duration >= 0; 40 | if (valid) { 41 | setSettings({saveDuration: duration}); 42 | cleanupOldEntries(); 43 | } 44 | setDurationState({text, valid}); 45 | }} 46 | /> 47 | How long to keep embed & attachment states after not seeing them. 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/CollapseEmbeds/settings.ts: -------------------------------------------------------------------------------- 1 | import {createSettings, Logger} from "dium"; 2 | 3 | export const DAYS_TO_MILLIS = 24 * 60 * 60 * 1000; 4 | 5 | export interface CollapsedState { 6 | hideByDefault: boolean; 7 | saveStates: boolean; 8 | saveDuration: number; 9 | collapsedStates: { 10 | [id: string]: { 11 | shown: boolean; 12 | lastSeen: number; 13 | }; 14 | }; 15 | } 16 | 17 | export const Settings = createSettings({ 18 | hideByDefault: false, 19 | saveStates: true, 20 | saveDuration: 30 * DAYS_TO_MILLIS, 21 | collapsedStates: {} 22 | }); 23 | 24 | export function getCollapsedState(id: string | undefined): boolean { 25 | const {hideByDefault, saveStates, collapsedStates} = Settings.current; 26 | if (saveStates && id) { 27 | return collapsedStates[id]?.shown ?? !hideByDefault; 28 | } else { 29 | return !hideByDefault; 30 | } 31 | } 32 | 33 | export function updateCollapsedState(id: string | undefined, shown: boolean): void { 34 | const {saveStates, collapsedStates} = Settings.current; 35 | if (saveStates && id) { 36 | collapsedStates[id] = { 37 | shown, 38 | lastSeen: Date.now() 39 | }; 40 | Settings.update({collapsedStates}); 41 | } 42 | } 43 | 44 | export function cleanupOldEntries(): void { 45 | const {saveDuration, collapsedStates} = Settings.current; 46 | const oldestAllowed = Date.now() - saveDuration; 47 | const entries = Object.entries(collapsedStates); 48 | 49 | let count = 0; 50 | for (const [id, state] of Object.entries(collapsedStates)) { 51 | if (state.lastSeen < oldestAllowed) { 52 | delete collapsedStates[id]; 53 | count++; 54 | } 55 | } 56 | 57 | Settings.update({collapsedStates}); 58 | Logger.log(`Cleaned ${count} out of ${entries.length} entries`); 59 | } 60 | -------------------------------------------------------------------------------- /src/CollapseEmbeds/styles.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | &.embed { 3 | justify-self: stretch; 4 | & > article { 5 | flex-grow: 1; 6 | flex-shrink: 0; 7 | } 8 | } 9 | &.mediaItem.expanded { 10 | position: relative; 11 | & > .hideButton { 12 | position: absolute; 13 | right: 2px; 14 | bottom: 2px; 15 | z-index: 1; 16 | } 17 | } 18 | } 19 | .placeholder + .placeholder { 20 | margin-left: 4px; 21 | } 22 | .hideButton { 23 | margin-bottom: -4px; 24 | align-self: flex-end; 25 | color: var(--interactive-normal); 26 | cursor: pointer; 27 | visibility: hidden; 28 | padding: 4px; 29 | margin: -4px; 30 | &:hover { 31 | color: var(--interactive-hover); 32 | } 33 | .expanded > & { 34 | margin-bottom: -6px; 35 | } 36 | &:hover, 37 | :hover + &, 38 | .collapsed > & { 39 | visibility: visible; 40 | } 41 | } 42 | .icon { 43 | margin: -2px; 44 | transition: transform .2s ease-out; 45 | &.open { 46 | transform: rotate(180deg); 47 | } 48 | } 49 | 50 | .attachment, .mediaItemSingle { 51 | /* unused */ 52 | } 53 | -------------------------------------------------------------------------------- /src/DevTools/index.tsx: -------------------------------------------------------------------------------- 1 | import * as dium from "dium"; 2 | import * as Modules from "@dium/modules"; 3 | import * as Components from "@dium/components"; 4 | import * as DevFinder from "./finder"; 5 | 6 | const {Logger} = dium; 7 | 8 | // add extensions 9 | const diumGlobal = { 10 | ...dium, 11 | Finder: {...dium.Finder, dev: DevFinder}, 12 | Modules, 13 | Components 14 | }; 15 | 16 | declare global { 17 | interface Window { 18 | dium?: typeof diumGlobal; 19 | } 20 | } 21 | 22 | const checkForMissing = (type: string, toCheck: Record) => { 23 | const missing = Object.entries(toCheck) 24 | .filter(([, value]) => value === undefined || value === null) 25 | .map(([key]) => key); 26 | if (missing.length > 0) { 27 | Logger.warn(`Missing ${type}: ${missing.join(", ")}`); 28 | } else { 29 | Logger.log(`All ${type} found`); 30 | } 31 | }; 32 | 33 | export default dium.createPlugin({ 34 | start() { 35 | window.dium = diumGlobal; 36 | 37 | checkForMissing("modules", Modules); 38 | checkForMissing("components", Components); 39 | }, 40 | stop() { 41 | delete window.dium; 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /src/DevTools/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DevTools", 3 | "author": "Zerthox", 4 | "version": "0.5.3", 5 | "description": "Utilities for development.", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Emulator/index.tsx: -------------------------------------------------------------------------------- 1 | import {createPlugin, createSettings, Logger, Finder, Patcher, Utils, React} from "dium"; 2 | import {Platforms} from "@dium/modules"; 3 | 4 | const {PlatformTypes} = Platforms; 5 | const OverlayBridgeStore = Finder.byName("OverlayBridgeStore"); 6 | 7 | const RadioGroup = Finder.byName("RadioGroup"); 8 | 9 | const Settings = createSettings({ 10 | platform: null // TODO: get platform 11 | }); 12 | 13 | const notify = (message: string, options: Utils.ToastOptions) => { 14 | Logger.log(message); 15 | Utils.toast(message, options); 16 | }; 17 | 18 | const triggerRerender = async () => { 19 | // TODO: force update root & trigger helmet rerender 20 | }; 21 | 22 | const changePlatform = async (platform: any) => { 23 | Settings.update({platform}); 24 | await triggerRerender(); 25 | const platformName = Platforms.isWindows() ? "Windows" : Platforms.isOSX() ? "MacOS" : Platforms.isLinux() ? "Linux" : "Browser"; 26 | notify(`Emulating ${platformName}`, {type: Utils.ToastType.Info, timeout: 5000}); 27 | }; 28 | 29 | export default createPlugin({ 30 | start() { 31 | // patch platform specific getters 32 | for (const platform of ["Windows", "OSX", "Linux", "Web"] as const) { 33 | Patcher.instead(Platforms, `is${platform}`, () => Settings.current.platform === PlatformTypes[platform.toUpperCase()]); 34 | } 35 | 36 | // patch overlay requirement 37 | Patcher.instead(OverlayBridgeStore, "isSupported", () => Platforms.isWindows()); 38 | }, 39 | async stop() { 40 | await triggerRerender(); 41 | notify("Stopped emulating", {type: Utils.ToastType.Info, timeout: 5000}); 42 | }, 43 | Settings, 44 | SettingsPanel: () => { 45 | const {platform} = Settings.useCurrent(); 46 | 47 | return ( 48 | changePlatform(value)} 51 | options={[ 52 | {value: PlatformTypes.WINDOWS, name: "Windows"}, 53 | {value: PlatformTypes.OSX, name: "MacOS"}, 54 | {value: PlatformTypes.LINUX, name: "Linux"}, 55 | {value: PlatformTypes.WEB, name: "Browser"} 56 | ]} 57 | /> 58 | ); 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /src/Emulator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Emulator", 3 | "author": "Zerthox", 4 | "version": "2.0.0", 5 | "description": "Emulate Windows, MacOS, Linux or Browser on any platform.\nWARNING: Emulating a different platform may cause unwanted side effects. Use at own risk.", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/NoReplyMention/index.tsx: -------------------------------------------------------------------------------- 1 | import {createPlugin, Finder, Filters, Patcher} from "dium"; 2 | 3 | const ReplyActions = Finder.demangle({ 4 | createPendingReply: Filters.bySource("shouldMention", "CREATE_PENDING_REPLY"), 5 | deletePendingReply: Filters.bySource("DELETE_PENDING_REPLY") 6 | }, null, true); 7 | 8 | export default createPlugin({ 9 | start() { 10 | Patcher.before(ReplyActions, "createPendingReply", ({args: [options]}) => { 11 | options.shouldMention = false; 12 | }, {name: "createPendingReply"}); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /src/NoReplyMention/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NoReplyMention", 3 | "author": "Zerthox", 4 | "version": "0.2.3", 5 | "description": "Suppresses reply mentions.", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/OnlineFriendCount/context-menu.tsx: -------------------------------------------------------------------------------- 1 | import {React} from "dium"; 2 | import {Settings, counterLabels} from "./settings"; 3 | import {Menu, MenuGroup, MenuCheckboxItem} from "@dium/components"; 4 | 5 | export const CountContextMenu = (props: React.ComponentProps): React.JSX.Element => { 6 | const [settings, setSettings] = Settings.useState(); 7 | 8 | return ( 9 | 10 | 11 | {Object.entries(counterLabels).map(([id, {label, long}]) => ( 12 | setSettings({[id]: !settings[id]})} 18 | /> 19 | ))} 20 | 21 | 22 | setSettings({interval: !settings.interval})} 27 | /> 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/OnlineFriendCount/counter.tsx: -------------------------------------------------------------------------------- 1 | import {Finder, React, Flux} from "dium"; 2 | import {GuildStore, PresenceStore, RelationshipStore, RelationshipTypes, StatusTypes, classNames} from "@dium/modules"; 3 | import {Link} from "@dium/components"; 4 | import {Settings, CounterType, counterLabels} from "./settings"; 5 | import {CountContextMenu} from "./context-menu"; 6 | import styles from "./styles.module.scss"; 7 | 8 | const listStyles = Finder.byKeys(["listItem", "iconBadge"]); 9 | 10 | interface ItemProps { 11 | children?: React.ReactNode; 12 | className?: string; 13 | link?: string; 14 | } 15 | 16 | const Item = ({children, className, link}: ItemProps) => ( 17 |
{link ? ( 18 | {children} 22 | ) : ( 23 |
{children}
24 | )}
25 | ); 26 | 27 | const CounterItem = ({type, count}: Counter) => ( 28 | {count} {counterLabels[type].label} 32 | ); 33 | 34 | const countFilteredRelationships = (filter: (relationship: {id: string; type: RelationshipTypes}) => boolean): number => ( 35 | Object.entries(RelationshipStore.getRelationships()).filter(([id, type]) => filter({id, type})).length 36 | ); 37 | 38 | interface Counter { 39 | type: CounterType; 40 | count: number; 41 | } 42 | 43 | const useCounters = (): Counter[] => { 44 | const guilds = Flux.useStateFromStores([GuildStore], () => GuildStore.getGuildCount(), []); 45 | const friendsOnline = Flux.useStateFromStores([PresenceStore, RelationshipStore], () => countFilteredRelationships( 46 | ({id, type}) => type === RelationshipTypes.FRIEND && PresenceStore.getStatus(id) !== StatusTypes.OFFLINE 47 | ), []); 48 | const relationships = Flux.useStateFromStores([RelationshipStore], () => ({ 49 | friends: countFilteredRelationships(({type}) => type === RelationshipTypes.FRIEND), 50 | pending: countFilteredRelationships(({type}) => type === RelationshipTypes.PENDING_INCOMING || type === RelationshipTypes.PENDING_OUTGOING), 51 | blocked: countFilteredRelationships(({type}) => type === RelationshipTypes.BLOCKED) 52 | }), []); 53 | 54 | return Object.entries({guilds, friendsOnline, ...relationships}) 55 | .map(([type, count]) => ({type, count} as Counter)); 56 | }; 57 | 58 | export const CountersContainer = (): React.JSX.Element => { 59 | const {interval, ...settings} = Settings.useCurrent(); 60 | const counters = useCounters().filter(({type}) => settings[type]); 61 | const [current, setCurrent] = React.useState(0); 62 | 63 | const callback = React.useRef<() => void>(null); 64 | React.useEffect(() => { 65 | callback.current = () => setCurrent((current + 1) % counters.length); 66 | }, [current, counters.length]); 67 | 68 | React.useEffect(() => { 69 | // check interval setting & at least 2 counters 70 | if (interval && counters.length > 1) { 71 | setCurrent(0); 72 | const id = setInterval(() => callback.current(), 5000); 73 | return () => clearInterval(id); 74 | } 75 | }, [interval, counters.length]); 76 | 77 | return ( 78 |
BdApi.ContextMenu.open(event, CountContextMenu)} 81 | > 82 | {counters.length > 0 ? ( 83 | interval ? ( 84 | 85 | ) : counters.map((counter) => ) 86 | ) : ( 87 | - 88 | )} 89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/OnlineFriendCount/index.tsx: -------------------------------------------------------------------------------- 1 | import {createPlugin, Logger, Finder, Patcher, Utils, React} from "dium"; 2 | import {GuildsNav} from "@dium/components"; 3 | import {Settings} from "./settings"; 4 | import {CountersContainer} from "./counter"; 5 | import {css} from "./styles.module.scss"; 6 | 7 | const guildStyles = Finder.byKeys(["guilds", "base"]); 8 | 9 | const triggerRerender = async () => { 10 | const node = document.getElementsByClassName(guildStyles.guilds)?.[0]; 11 | const fiber = Utils.getFiber(node); 12 | if (await Utils.forceFullRerender(fiber)) { 13 | Logger.log("Rerendered guilds"); 14 | } else { 15 | Logger.warn("Unable to rerender guilds"); 16 | } 17 | }; 18 | 19 | export default createPlugin({ 20 | start() { 21 | // patch guilds nav 22 | Patcher.after(GuildsNav, "type", ({result}) => { 23 | const guildsParent = Utils.queryTree(result, (node) => node?.props?.className?.split(" ").includes(guildStyles.guilds)); 24 | if (!guildsParent) { 25 | return Logger.error("Unable to find guilds parent"); 26 | } 27 | 28 | // chain patch 29 | Utils.hookFunctionComponent(guildsParent, (result) => { 30 | const themeParent = Utils.queryTree(result, (node) => typeof node?.props?.children === "function"); 31 | if (!themeParent) { 32 | return Logger.error("Unable to find theme parent"); 33 | } 34 | 35 | // chain patch again 36 | Utils.hookFunctionComponent(themeParent, (result) => { 37 | // find home button wrapper & parent 38 | const [scroller, index] = Utils.queryTreeForParent(result, (child) => child?.props?.lurkingGuildIds); 39 | if (!scroller) { 40 | return Logger.error("Unable to find home button wrapper"); 41 | } 42 | 43 | // insert after home button wrapper 44 | scroller.props.children.splice(index + 1, 0, ); 45 | }); 46 | }); 47 | }, {name: "GuildsNav"}); 48 | 49 | triggerRerender(); 50 | }, 51 | stop() { 52 | triggerRerender(); 53 | }, 54 | styles: css, 55 | Settings 56 | }); 57 | -------------------------------------------------------------------------------- /src/OnlineFriendCount/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OnlineFriendCount", 3 | "author": "Zerthox", 4 | "version": "3.2.3", 5 | "description": "Adds the old online friend count and similar counters back to server list. Because nostalgia.", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/OnlineFriendCount/settings.ts: -------------------------------------------------------------------------------- 1 | import {createSettings} from "dium"; 2 | 3 | export const Settings = createSettings({ 4 | guilds: false, 5 | friends: false, 6 | friendsOnline: true, 7 | pending: false, 8 | blocked: false, 9 | interval: false 10 | }); 11 | 12 | export type CounterType = "guilds" | "friends" | "friendsOnline" | "pending" | "blocked"; 13 | 14 | export const counterLabels: Record = { 15 | guilds: { 16 | label: "Servers" 17 | }, 18 | friends: { 19 | label: "Friends" 20 | }, 21 | friendsOnline: { 22 | label: "Online", 23 | long: "Online Friends" 24 | }, 25 | pending: { 26 | label: "Pendings", 27 | long: "Pending Friend Requests" 28 | }, 29 | blocked: { 30 | label: "Blocked", 31 | long: "Blocked Users" 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/OnlineFriendCount/styles.module.scss: -------------------------------------------------------------------------------- 1 | .item { 2 | color: var(--channels-default); 3 | text-align: center; 4 | text-transform: uppercase; 5 | font-size: 10px; 6 | font-weight: 500; 7 | line-height: 1.3; 8 | width: 70px; 9 | word-wrap: normal; 10 | white-space: nowrap; 11 | } 12 | .link { 13 | cursor: pointer; 14 | &:hover { 15 | color: var(--interactive-hover); 16 | } 17 | } 18 | 19 | .container, 20 | .counter, 21 | .guilds, 22 | .friends, 23 | .friendsOnline, 24 | .pending, 25 | .blocked { 26 | /* unused */ 27 | } 28 | -------------------------------------------------------------------------------- /src/SVGInspect/index.tsx: -------------------------------------------------------------------------------- 1 | import {createPlugin, Finder, React} from "dium"; 2 | 3 | const excludes = [".bodyWaveGradient", ".circleOverlay"]; 4 | 5 | const components = Finder.all.bySource([ 6 | (source) => /\.(?:createElement|jsxs?)\)?\("svg",/.test(source) && !excludes.some((fragment) => source.includes(fragment)) 7 | ], {entries: true}); 8 | 9 | interface ErrorBoundaryProps { 10 | children?: React.ReactNode; 11 | } 12 | 13 | interface ErrorBoundaryState { 14 | hasError: boolean; 15 | } 16 | 17 | class ErrorBoundary extends React.Component { 18 | constructor(props: ErrorBoundaryProps) { 19 | super(props); 20 | this.state = {hasError: false}; 21 | } 22 | render() { 23 | return !this.state.hasError ? this.props.children : null; 24 | } 25 | static getDerivedStateFromError() { 26 | return {hasError: true}; 27 | } 28 | } 29 | 30 | export default createPlugin({ 31 | SettingsPanel: () => ( 32 | <> 33 | {components.map((SVG, i) => ( 34 |
35 | 36 | 37 | 38 |
39 | ))} 40 | 41 | ) 42 | }); 43 | -------------------------------------------------------------------------------- /src/SVGInspect/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SVGInspect", 3 | "author": "Zerthox", 4 | "version": "0.1.4", 5 | "description": "View all SVG components bundled in Discord", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/VoiceEvents/index.tsx: -------------------------------------------------------------------------------- 1 | import {createPlugin, Logger, Patcher, Utils, React} from "dium"; 2 | import { 3 | Dispatcher, 4 | SelectedChannelStore, 5 | UserStore, 6 | VoiceState, 7 | VoiceStateStore, 8 | MediaEngineStore, 9 | Snowflake 10 | } from "@dium/modules"; 11 | import {MenuItem} from "@dium/components"; 12 | import {Settings} from "./settings"; 13 | import {SettingsPanel} from "./settings-panel"; 14 | import {findDefaultVoice, notify} from "./voice"; 15 | 16 | const selfMuteHandler = () => { 17 | const userId = UserStore.getCurrentUser().id; 18 | const channelId = SelectedChannelStore.getVoiceChannelId(); 19 | notify(MediaEngineStore.isSelfMute() ? "mute" : "unmute", userId, channelId); 20 | }; 21 | 22 | const selfDeafHandler = () => { 23 | const userId = UserStore.getCurrentUser().id; 24 | const channelId = SelectedChannelStore.getVoiceChannelId(); 25 | notify(MediaEngineStore.isSelfDeaf() ? "deafen" : "undeafen", userId, channelId); 26 | }; 27 | 28 | interface VoiceStateUpdatesAction { 29 | type: "VOICE_STATE_UPDATES"; 30 | voiceStates: VoiceState[]; 31 | } 32 | 33 | let prevStates: Record = {}; 34 | const saveStates = () => { 35 | prevStates = {...VoiceStateStore.getVoiceStatesForChannel(SelectedChannelStore.getVoiceChannelId())}; 36 | }; 37 | 38 | const voiceStateHandler = (action: VoiceStateUpdatesAction) => { 39 | for (const {userId, channelId} of action.voiceStates) { 40 | try { 41 | const prev = prevStates[userId]; 42 | 43 | if (userId === UserStore.getCurrentUser().id) { 44 | // user is self 45 | if (!channelId) { 46 | // no channel is leave 47 | notify("leaveSelf", userId, prev.channelId); 48 | saveStates(); 49 | } else if (!prev) { 50 | // no previous state is join 51 | notify("joinSelf", userId, channelId); 52 | saveStates(); 53 | } else if (channelId !== prev.channelId) { 54 | // previous state in different channel is move 55 | notify("moveSelf", userId, channelId); 56 | saveStates(); 57 | } 58 | } else { 59 | // check for current channel 60 | const selectedChannelId = SelectedChannelStore.getVoiceChannelId(); 61 | if (!selectedChannelId) { 62 | // user is not in voice 63 | return; 64 | } 65 | 66 | if (!prev && channelId === selectedChannelId) { 67 | // no previous state & same channel is join 68 | notify("join", userId, channelId); 69 | saveStates(); 70 | } else if (prev && !VoiceStateStore.getVoiceStatesForChannel(selectedChannelId)[userId]) { 71 | // previous state & no current state is leave 72 | notify("leave", userId, selectedChannelId); 73 | saveStates(); 74 | } 75 | } 76 | } catch (error) { 77 | Logger.error("Error processing voice state change, see details below"); 78 | console.error(error); 79 | } 80 | } 81 | }; 82 | 83 | export default createPlugin({ 84 | start() { 85 | // initialize default voice 86 | const voice = findDefaultVoice()?.voiceURI; 87 | Settings.defaults.voice = voice; 88 | if (!Settings.current.voice) { 89 | Settings.update({voice}); 90 | } 91 | 92 | // save initial voice states 93 | saveStates(); 94 | 95 | // listen for updates 96 | Dispatcher.subscribe("VOICE_STATE_UPDATES", voiceStateHandler); 97 | Logger.log("Subscribed to voice state actions"); 98 | 99 | Dispatcher.subscribe("AUDIO_TOGGLE_SELF_MUTE", selfMuteHandler); 100 | Logger.log("Subscribed to self mute actions"); 101 | 102 | Dispatcher.subscribe("AUDIO_TOGGLE_SELF_DEAF", selfDeafHandler); 103 | Logger.log("Subscribed to self deaf actions"); 104 | 105 | // patch channel context menu 106 | Patcher.contextMenu("channel-context", (result) => { 107 | const [parent, index] = Utils.queryTreeForParent(result, (child) => child?.props?.id === "hide-voice-names"); 108 | if (parent) { 109 | parent.props.children.splice(index + 1, 0, ( 110 | speechSynthesis.cancel()} 115 | /> 116 | )); 117 | } 118 | }); 119 | }, 120 | stop() { 121 | // reset 122 | prevStates = {}; 123 | 124 | Dispatcher.unsubscribe("VOICE_STATE_UPDATES", voiceStateHandler); 125 | Logger.log("Unsubscribed from voice state actions"); 126 | 127 | Dispatcher.unsubscribe("AUDIO_TOGGLE_SELF_MUTE", selfMuteHandler); 128 | Logger.log("Unsubscribed from self mute actions"); 129 | 130 | Dispatcher.unsubscribe("AUDIO_TOGGLE_SELF_DEAF", selfDeafHandler); 131 | Logger.log("Unsubscribed from self deaf actions"); 132 | }, 133 | Settings, 134 | SettingsPanel 135 | }); 136 | -------------------------------------------------------------------------------- /src/VoiceEvents/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "VoiceEvents", 3 | "author": "Zerthox", 4 | "version": "2.6.3", 5 | "description": "Adds TTS Event Notifications to your selected Voice Channel. TeamSpeak feeling.", 6 | "dependencies": { 7 | "dium": "*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/VoiceEvents/settings.tsx: -------------------------------------------------------------------------------- 1 | import {createSettings, SettingsType} from "dium"; 2 | 3 | export const Settings = createSettings({ 4 | voice: null as string, // set later 5 | volume: 100, 6 | speed: 1, 7 | filterNames: true, 8 | filterBots: false, 9 | filterStages: true, 10 | notifs: { 11 | mute: { 12 | enabled: true, 13 | message: "Muted" 14 | }, 15 | unmute: { 16 | enabled: true, 17 | message: "Unmuted" 18 | }, 19 | deafen: { 20 | enabled: true, 21 | message: "Deafened" 22 | }, 23 | undeafen: { 24 | enabled: true, 25 | message: "Undeafened" 26 | }, 27 | join: { 28 | enabled: true, 29 | message: "$user joined $channel" 30 | }, 31 | leave: { 32 | enabled: true, 33 | message: "$user left $channel" 34 | }, 35 | joinSelf: { 36 | enabled: true, 37 | message: "You joined $channel" 38 | }, 39 | moveSelf: { 40 | enabled: true, 41 | message: "You were moved to $channel" 42 | }, 43 | leaveSelf: { 44 | enabled: true, 45 | message: "You left $channel" 46 | } 47 | }, 48 | unknownChannel: "The call" 49 | }); 50 | 51 | export type NotificationType = keyof SettingsType["notifs"]; 52 | -------------------------------------------------------------------------------- /src/VoiceEvents/voice.tsx: -------------------------------------------------------------------------------- 1 | import {Logger, Utils, React, getMeta} from "dium"; 2 | import {ChannelStore, UserStore, GuildMemberStore} from "@dium/modules"; 3 | import {Text} from "@dium/components"; 4 | import {Settings, NotificationType} from "./settings"; 5 | 6 | export const findDefaultVoice = (): SpeechSynthesisVoice => { 7 | const voices = speechSynthesis.getVoices(); 8 | if (voices.length === 0) { 9 | Logger.error("No speech synthesis voices available"); 10 | Utils.alert( 11 | getMeta().name, 12 | 13 | Electron does not have any Speech Synthesis Voices available on your system. 14 |
15 | The plugin will be unable to function properly. 16 |
17 | ); 18 | return null; 19 | } else { 20 | return voices.find((voice) => voice.lang === "en-US") ?? voices[0]; 21 | } 22 | }; 23 | 24 | export const findCurrentVoice = (): SpeechSynthesisVoice => { 25 | const uri = Settings.current.voice; 26 | const voice = speechSynthesis.getVoices().find((voice) => voice.voiceURI === uri); 27 | if (voice) { 28 | return voice; 29 | } else { 30 | Logger.warn(`Voice "${uri}" not found, reverting to default`); 31 | const defaultVoice = findDefaultVoice(); 32 | Settings.update({voice: defaultVoice.voiceURI}); 33 | return defaultVoice; 34 | } 35 | }; 36 | 37 | export const speak = (message: string): void => { 38 | const {volume, speed} = Settings.current; 39 | 40 | const utterance = new SpeechSynthesisUtterance(message); 41 | utterance.voice = findCurrentVoice(); 42 | utterance.volume = volume / 100; 43 | utterance.rate = speed; 44 | 45 | speechSynthesis.speak(utterance); 46 | }; 47 | 48 | const processName = (name: string) => { 49 | return Settings.current.filterNames ? name.split("").map((char) => /[a-zA-Z0-9]/.test(char) ? char : " ").join("") : name; 50 | }; 51 | 52 | export const notify = (type: NotificationType, userId: string, channelId: string): void => { 53 | const settings = Settings.current; 54 | const notif = settings.notifs[type]; 55 | 56 | // check for enabled 57 | if (!notif.enabled) { 58 | return; 59 | } 60 | 61 | const user = UserStore.getUser(userId); 62 | const channel = ChannelStore.getChannel(channelId); 63 | 64 | // check for filters 65 | if ( 66 | settings.filterBots && user?.bot 67 | || settings.filterStages && channel?.isGuildStageVoice() 68 | ) { 69 | return; 70 | } 71 | 72 | // resolve names 73 | const displayName = user.globalName ?? user.username; 74 | const nick = GuildMemberStore.getMember(channel?.getGuildId(), userId)?.nick ?? displayName; 75 | const channelName = (!channel || channel.isDM() || channel.isGroupDM()) ? settings.unknownChannel : channel.name; 76 | 77 | // speak message 78 | const message = notif.message 79 | .replaceAll("$username", processName(user.username)) 80 | .replaceAll("$displayname", processName(user.username)) 81 | .replaceAll("$user", processName(nick)) 82 | .replaceAll("$channel", processName(channelName)); 83 | speak(message); 84 | }; 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "jsx": "react", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "paths": { 9 | "@dium/*": ["./packages/dium/src/*"] 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------