feat(plugin): NoFollowForwards (#1179)

* make plugin

* merge with forwardAnywhere

* [autofix.ci] apply automated fixes

* update desc and name

* run markdown formatter on .rules

* remove type only rule

* use mouse event over keyboard event

* tighten lookahead assertion

* fix lint

* properly mark restart required

* tighten lookahead even more

* Update index.ts

* Update index.ts

* Update index.ts

* add sadan as dev

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
sadan4 2026-06-15 20:27:31 -04:00 • committed by GitHub
parent 43b1794d13
commit 621dfb74de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 206 additions and 151 deletions

223
.rules
View file

@ -28,7 +28,6 @@
- Never boxed primitives (`Number`, `String`, `Boolean`, `Object`, `Function`, `{}`). - Never boxed primitives (`Number`, `String`, `Boolean`, `Object`, `Function`, `{}`).
- Never `namespace`. Modules only. - Never `namespace`. Modules only.
- Never `../../` when `@alias` exists. - Never `../../` when `@alias` exists.
- `import type` for type-only imports, including from `"react"`. Plain `import { ReactNode }` is violation.
- `satisfies T` over `as T` when value already conforms. `as` only for genuine narrowing. - `satisfies T` over `as T` when value already conforms. `as` only for genuine narrowing.
## Plugin Policy ## Plugin Policy
@ -45,15 +44,15 @@ Does NOT get merged:
## Plugin Structure ## Plugin Structure
- Every NEW source file starts with SPDX header: - Every NEW source file starts with SPDX header:
``` ```
/* /*
* Vencord, a Discord client mod * Vencord, a Discord client mod
* Copyright (c) <year> Vendicated and contributors * Copyright (c) <year> Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
``` ```
Header says `Vencord, a Discord client mod` even on new Equicord-only files. Equicord is a Vencord fork, upstream attribution is kept across the whole tree. Don't change it to "Equicord". Header says `Vencord, a Discord client mod` even on new Equicord-only files. Equicord is a Vencord fork, upstream attribution is kept across the whole tree. Don't change it to "Equicord".
Older files carry the full GPL preamble. Leave existing headers alone, don't rewrite them. Older files carry the full GPL preamble. Leave existing headers alone, don't rewrite them.
- Folder per plugin. `src/plugins/<name>/index.tsx` or `src/equicordplugins/<name>/index.tsx`. Single-file plugins still get a folder. - Folder per plugin. `src/plugins/<name>/index.tsx` or `src/equicordplugins/<name>/index.tsx`. Single-file plugins still get a folder.
- Folder suffixes the build filters on: `.desktop`, `.web`, `.dev`, `.discordDesktop`, `.vesktop`, `.equibop`. - Folder suffixes the build filters on: `.desktop`, `.web`, `.dev`, `.discordDesktop`, `.vesktop`, `.equibop`.
- `export default definePlugin({ ... })`. Never named. - `export default definePlugin({ ... })`. Never named.
@ -79,22 +78,22 @@ Imperative `add*`/`remove*` only when: multiple items of same kind, conditional,
Imperative usage MUST list the API in `dependencies`: Imperative usage MUST list the API in `dependencies`:
| API | Dependency | | API | Dependency |
|---|---| | -------------------- | ------------------------- |
| ContextMenu | `ContextMenuAPI` | | ContextMenu | `ContextMenuAPI` |
| MessageEvents | `MessageEventsAPI` | | MessageEvents | `MessageEventsAPI` |
| ChatButtons | `ChatInputButtonAPI` | | ChatButtons | `ChatInputButtonAPI` |
| Badges | `BadgeAPI` | | Badges | `BadgeAPI` |
| MemberListDecorators | `MemberListDecoratorsAPI` | | MemberListDecorators | `MemberListDecoratorsAPI` |
| MessageAccessories | `MessageAccessoriesAPI` | | MessageAccessories | `MessageAccessoriesAPI` |
| MessageDecorations | `MessageDecorationsAPI` | | MessageDecorations | `MessageDecorationsAPI` |
| MessagePopover | `MessagePopoverAPI` | | MessagePopover | `MessagePopoverAPI` |
| NicknameIcons | `NicknameIconsAPI` | | NicknameIcons | `NicknameIconsAPI` |
| HeaderBar | `HeaderBarAPI` | | HeaderBar | `HeaderBarAPI` |
| UserArea | `UserAreaAPI` | | UserArea | `UserAreaAPI` |
| ProfileCollections | `ProfileCollectionsAPI` | | ProfileCollections | `ProfileCollectionsAPI` |
| ProfileSections | `ProfileSectionsAPI` | | ProfileSections | `ProfileSectionsAPI` |
| AudioPlayer | `AudioPlayerAPI` | | AudioPlayer | `AudioPlayerAPI` |
Declarative fields auto-enable these. Imperative doesn't. Declarative fields auto-enable these. Imperative doesn't.
@ -167,33 +166,33 @@ Declarative fields auto-enable these. Imperative doesn't.
- Prefer multiple small patches over one with broad find and many replacements. - Prefer multiple small patches over one with broad find and many replacements.
- In DEV, failing replacements auto-diff 200 chars around the match site. Read it before retrying. - In DEV, failing replacements auto-diff 200 chars around the match site. Read it before retrying.
- Never reference build flags literally inside `replace`. They only exist in Equicord's bundle. Interpolate outside: - Never reference build flags literally inside `replace`. They only exist in Equicord's bundle. Interpolate outside:
```js ```js
// BAD // BAD
replace: "IS_WEB?foo:bar" replace: "IS_WEB?foo:bar";
// GOOD // GOOD
replace: IS_WEB ? "foo" : "bar" replace: IS_WEB ? "foo" : "bar";
// OK // OK
replace: `${IS_WEB}?foo:bar` replace: `${IS_WEB}?foo:bar`;
``` ```
- Keep `replace` small: wrap a single function via patch + `$self`, push logic to plugin TS. A 3-line `$self.handleClick(arguments[0])` beats a 30-line render rebuild. - Keep `replace` small: wrap a single function via patch + `$self`, push logic to plugin TS. A 3-line `$self.handleClick(arguments[0])` beats a 30-line render rebuild.
### Examples ### Examples
```js ```js
// clean replacement // clean replacement
find: "#{intl::EXAMPLE_KEY_A}),icon:" find: "#{intl::EXAMPLE_KEY_A}),icon:";
match: /#{intl::EXAMPLE_KEY_A}\)/ match: /#{intl::EXAMPLE_KEY_A}\)/;
replace: "$self.exampleFn(arguments[0]))" replace: "$self.exampleFn(arguments[0]))";
// appending // appending
find: "#{intl::EXAMPLE_KEY_B}" find: "#{intl::EXAMPLE_KEY_B}";
match: /#{intl::EXAMPLE_KEY_B}.{0,100}(?=])/ match: /#{intl::EXAMPLE_KEY_B}.{0,100}(?=])/;
replace: "$&,$self.exampleRender(arguments[0].guild)" replace: "$&,$self.exampleRender(arguments[0].guild)";
// wrapping with lookbehind // wrapping with lookbehind
find: "#{intl::EXAMPLE_KEY_C}" find: "#{intl::EXAMPLE_KEY_C}";
match: /(?<="aria-atomic":!0,children:)\i/ match: /(?<="aria-atomic":!0,children:)\i/;
replace: "$self.exampleWrap({ children: $& })" replace: "$self.exampleWrap({ children: $& })";
``` ```
## Webpack access ## Webpack access
@ -245,9 +244,11 @@ replace: "$self.exampleWrap({ children: $& })"
- Subprocess: `spawn` with argv arrays. Never `exec` a renderer-derived string. Never `shell: true` with untrusted input. - Subprocess: `spawn` with argv arrays. Never `exec` a renderer-derived string. Never `shell: true` with untrusted input.
- `shell.openExternal`: only with https allowlist. - `shell.openExternal`: only with https allowlist.
- Renderer side: - Renderer side:
```ts ```ts
const Native = VencordNative.pluginHelpers.MyPlugin as PluginNative<typeof import("./native")>; const Native = VencordNative.pluginHelpers.MyPlugin as PluginNative<
``` typeof import("./native")
>;
```
- `pluginHelpers` key matches `definePlugin.name` exactly (case-sensitive). - `pluginHelpers` key matches `definePlugin.name` exactly (case-sensitive).
- Renderer must never import `fs`, `path`, `net`, `http`, `https`, `child_process`, `electron`. Go through `native.ts`. - Renderer must never import `fs`, `path`, `net`, `http`, `https`, `child_process`, `electron`. Go through `native.ts`.
- File pickers: `saveFile`/`chooseFile` from `@utils/web`. Don't re-implement via `dialog.show*` over IPC. - File pickers: `saveFile`/`chooseFile` from `@utils/web`. Don't re-implement via `dialog.show*` over IPC.
@ -260,18 +261,18 @@ replace: "$self.exampleWrap({ children: $& })"
- Long-lived component timers use `useFixedTimer`/`useTimer` from `@utils/react`. - Long-lived component timers use `useFixedTimer`/`useTimer` from `@utils/react`.
- Every `add*` in `start()` pairs with `remove*` in `stop()`. - Every `add*` in `start()` pairs with `remove*` in `stop()`.
- Idle timer pattern: - Idle timer pattern:
```ts ```ts
let intervalId: number | undefined; let intervalId: number | undefined;
start() { intervalId = setInterval(tick, 30_000); } start() { intervalId = setInterval(tick, 30_000); }
stop() { if (intervalId !== undefined) { clearInterval(intervalId); intervalId = undefined; } } stop() { if (intervalId !== undefined) { clearInterval(intervalId); intervalId = undefined; } }
``` ```
## Storage ## Storage
- Persistent state: `DataStore` from `@api/DataStore` (idb-backed). Never `localStorage`/`sessionStorage`/`indexedDB`. - Persistent state: `DataStore` from `@api/DataStore` (idb-backed). Never `localStorage`/`sessionStorage`/`indexedDB`.
- Plain settings: `definePluginSettings`. Internal hidden state: `withPrivateSettings<T>()`. - Plain settings: `definePluginSettings`. Internal hidden state: `withPrivateSettings<T>()`.
- DataStore API: `get`, `set`, `setMany`, `getMany`, `update`, `del`, `delMany`, `clear`, `keys`, `values`, `entries`, `createStore` (custom namespace). - DataStore API: `get`, `set`, `setMany`, `getMany`, `update`, `del`, `delMany`, `clear`, `keys`, `values`, `entries`, `createStore` (custom namespace).
- Namespace keys with plugin name: `` await DataStore.get(`MyPlugin_key`) `` or `createStore("MyPlugin", "store")`. - Namespace keys with plugin name: ``await DataStore.get(`MyPlugin_key`)`` or `createStore("MyPlugin", "store")`.
- Never store tokens, message content, or other user data in keys readable by other plugins. - Never store tokens, message content, or other user data in keys readable by other plugins.
## Network ## Network
@ -315,57 +316,57 @@ replace: "$self.exampleWrap({ children: $& })"
Pick one per event. Never two for the same thing. Pick one per event. Never two for the same thing.
| Need | Use | | Need | Use |
|---|---| | --------------------------------------- | ---------------------------------------------------------------------------------- |
| Transient feedback (<2s) | `showToast(msg, Toasts.Type.SUCCESS)` from `@webpack/common` | | Transient feedback (<2s) | `showToast(msg, Toasts.Type.SUCCESS)` from `@webpack/common` |
| Background happened, user may act later | `showNotification({ title, body })` from `@api/Notifications` | | Background happened, user may act later | `showNotification({ title, body })` from `@api/Notifications` |
| Urgent inline top-bar | `Notices.showNotice(msg, btnText, cb)` from `@api/Notices` (sparingly — blocks UI) | | Urgent inline top-bar | `Notices.showNotice(msg, btnText, cb)` from `@api/Notices` (sparingly — blocks UI) |
| Modal confirm | `Alerts.show({ title, body, onConfirm })` from `@webpack/common` | | Modal confirm | `Alerts.show({ title, body, onConfirm })` from `@webpack/common` |
## Use what exists ## Use what exists
Search before writing. Common needs: Search before writing. Common needs:
| Need | Use | | Need | Use |
|---|---| | ------------------- | ------------------------------------------------------------------ |
| Sleep | `sleep(ms)` from `@utils/misc` | | Sleep | `sleep(ms)` from `@utils/misc` |
| Debounce / throttle | `lodash.debounce` / `lodash.throttle` | | Debounce / throttle | `lodash.debounce` / `lodash.throttle` |
| Date format | `moment` or `formatDuration`/`formatDurationMs` from `@utils/text` | | Date format | `moment` or `formatDuration`/`formatDurationMs` from `@utils/text` |
| Markdown parse | `Parser.parse(text)` | | Markdown parse | `Parser.parse(text)` |
| Snowflake → date | `SnowflakeUtils.extractTimestamp(id)` | | Snowflake → date | `SnowflakeUtils.extractTimestamp(id)` |
| URL parse | `parseUrl` from `@utils/misc` | | URL parse | `parseUrl` from `@utils/misc` |
| Regex escape | `escapeRegExp` from `@utils/text` | | Regex escape | `escapeRegExp` from `@utils/text` |
| Join lists | `humanFriendlyJoin([...])` from `@utils/text` | | Join lists | `humanFriendlyJoin([...])` from `@utils/text` |
| Class concat | `classes(a, b, ...)` from `@utils/misc` | | Class concat | `classes(a, b, ...)` from `@utils/misc` |
| Margins | `Margins.top8` etc, not inline `style` | | Margins | `Margins.top8` etc, not inline `style` |
| Deep clone | `lodash.cloneDeep` | | Deep clone | `lodash.cloneDeep` |
| Clamp / range | `lodash.clamp`, `makeRange` from `@utils/types` | | Clamp / range | `lodash.clamp`, `makeRange` from `@utils/types` |
| Open URL | `VencordNative.native.openExternal(url)` or `MaskedLink` | | Open URL | `VencordNative.native.openExternal(url)` or `MaskedLink` |
| Copy clipboard | `copyWithToast` from `@utils/discord` | | Copy clipboard | `copyWithToast` from `@utils/discord` |
| Fetch uncached user | `fetchUserProfile(id)` from `@utils/discord` | | Fetch uncached user | `fetchUserProfile(id)` from `@utils/discord` |
| Open user profile | `openUserProfile(id)` | | Open user profile | `openUserProfile(id)` |
| Send chat msg | `sendMessage(channelId, { content })` from `@utils/discord` | | Send chat msg | `sendMessage(channelId, { content })` from `@utils/discord` |
## Discord components ## Discord components
Reuse from `@webpack/common`. Don't recreate. Reuse from `@webpack/common`. Don't recreate.
| Need | Component | | Need | Component |
|---|---| | ------------------ | --------------------------------------------------- |
| Button | `Button` | | Button | `Button` |
| Text input | `TextInput` | | Text input | `TextInput` |
| Multiline | `TextArea` | | Multiline | `TextArea` |
| Dropdown | `Select` / `SearchableSelect` | | Dropdown | `Select` / `SearchableSelect` |
| Checkbox | `Checkbox` | | Checkbox | `Checkbox` |
| Slider | `Slider` | | Slider | `Slider` |
| Tooltip | `Tooltip` (function-child API) | | Tooltip | `Tooltip` (function-child API) |
| Context menu items | `Menu.*` | | Context menu items | `Menu.*` |
| Modal | `openModal(p => <ModalRoot {...p}>...</ModalRoot>)` | | Modal | `openModal(p => <ModalRoot {...p}>...</ModalRoot>)` |
| Scroller | `ScrollerThin` / `ScrollerAuto` | | Scroller | `ScrollerThin` / `ScrollerAuto` |
| User pill | `UserSummaryItem` | | User pill | `UserSummaryItem` |
| Avatar | `Avatar` | | Avatar | `Avatar` |
| Timestamp | `Timestamp` | | Timestamp | `Timestamp` |
| External link | `MaskedLink` | | External link | `MaskedLink` |
## Never hardcode ## Never hardcode
@ -398,29 +399,31 @@ Reuse from `@webpack/common`. Don't recreate.
Replaced at build time. Reference only in plugin TS, never literally inside `replace`. Replaced at build time. Reference only in plugin TS, never literally inside `replace`.
| Constant | Meaning | | Constant | Meaning |
|---|---| | --------------------- | ----------------------------------------------- |
| `IS_WEB` | Web build (extension/userscript/standalone web) | | `IS_WEB` | Web build (extension/userscript/standalone web) |
| `IS_EXTENSION` | Browser extension | | `IS_EXTENSION` | Browser extension |
| `IS_USERSCRIPT` | Userscript | | `IS_USERSCRIPT` | Userscript |
| `IS_STANDALONE` | Standalone (no updater) | | `IS_STANDALONE` | Standalone (no updater) |
| `IS_UPDATER_DISABLED` | Updater off | | `IS_UPDATER_DISABLED` | Updater off |
| `IS_DEV` | Dev build (auto-diff, extra logs) | | `IS_DEV` | Dev build (auto-diff, extra logs) |
| `IS_REPORTER` | Auto-reporter | | `IS_REPORTER` | Auto-reporter |
| `IS_COMPANION_TEST` | Companion test | | `IS_COMPANION_TEST` | Companion test |
| `IS_ANTI_CRASH_TEST` | Anti-crash test | | `IS_ANTI_CRASH_TEST` | Anti-crash test |
| `IS_DISCORD_DESKTOP` | Discord desktop injection | | `IS_DISCORD_DESKTOP` | Discord desktop injection |
| `IS_VESKTOP` | Vesktop | | `IS_VESKTOP` | Vesktop |
| `IS_EQUIBOP` | Equibop | | `IS_EQUIBOP` | Equibop |
| `VERSION` | Equicord version string | | `VERSION` | Equicord version string |
| `BUILD_TIMESTAMP` | Build epoch ms | | `BUILD_TIMESTAMP` | Build epoch ms |
## Reference ## Reference
### `@vencord/discord-types` ### `@vencord/discord-types`
`Channel`, `Guild`, `GuildMember`, `User`, `Role`, `Message`, `Command`, `FluxEvents`, store types. `Channel`, `Guild`, `GuildMember`, `User`, `Role`, `Message`, `Command`, `FluxEvents`, store types.
### `@api/*` ### `@api/*`
`Settings`, `Commands`, `DataStore`, `ContextMenu`, `Badges`, `Notices`, `Notifications`, `MessageEvents`, `MessageUpdater`, `MessageAccessories`, `MessageDecorations`, `MessagePopover`, `MemberListDecorators`, `ServerList`, `ChatButtons`, `HeaderBar`, `UserArea`, `NicknameIcons`, `ProfileCollections`, `ProfileSections`, `AudioPlayer`, `UserSettings`, `UserSettingDefinitions`, `Styles`, `PluginManager`, `SettingsSync`. `Settings`, `Commands`, `DataStore`, `ContextMenu`, `Badges`, `Notices`, `Notifications`, `MessageEvents`, `MessageUpdater`, `MessageAccessories`, `MessageDecorations`, `MessagePopover`, `MemberListDecorators`, `ServerList`, `ChatButtons`, `HeaderBar`, `UserArea`, `NicknameIcons`, `ProfileCollections`, `ProfileSections`, `AudioPlayer`, `UserSettings`, `UserSettingDefinitions`, `Styles`, `PluginManager`, `SettingsSync`.
`@api/Commands` re-exports `sendBotMessage`, `findOption`, `ApplicationCommandInputType`, `ApplicationCommandOptionType`, `ApplicationCommandType`. `@api/Commands` re-exports `sendBotMessage`, `findOption`, `ApplicationCommandInputType`, `ApplicationCommandOptionType`, `ApplicationCommandType`.
@ -430,6 +433,7 @@ Replaced at build time. Reference only in plugin TS, never literally inside `rep
Deprecated: `@api/Themes`. `classNameFactory` re-export from `@api/Styles`. Deprecated: `@api/Themes`. `classNameFactory` re-export from `@api/Styles`.
### `@utils/*` ### `@utils/*`
- `Logger`. `new Logger(name)`. - `Logger`. `new Logger(name)`.
- `css`. `classNameFactory`, `classNameToSelector`. - `css`. `classNameFactory`, `classNameToSelector`.
- `misc`. `classes`, `sleep`, `isObject`, `isObjectEmpty`, `pluralise`, `parseUrl`, `identity`, `interpolateIfDefined`, `tryOrElse`, `isPluginDev`, `removeFromArray`. - `misc`. `classes`, `sleep`, `isObject`, `isObjectEmpty`, `pluralise`, `parseUrl`, `identity`, `interpolateIfDefined`, `tryOrElse`, `isPluginDev`, `removeFromArray`.
@ -447,6 +451,7 @@ Deprecated: `@api/Themes`. `classNameFactory` re-export from `@api/Styles`.
- `types`. `OptionType`, `StartAt`, `ReporterTestable`, `definePlugin`. - `types`. `OptionType`, `StartAt`, `ReporterTestable`, `definePlugin`.
### `@components/*` ### `@components/*`
`Badge`, `BaseText`, `Button`, `Card`, `CheckedTextInput`, `CodeBlock`, `Divider`, `ErrorBoundary`, `ErrorCard`, `Flex`, `FormSwitch`, `Grid`, `Heading`, `Heart`, `Icons`, `Link`, `Notice`, `Paragraph`, `Span`, `Switch`, `margins` (`Margins`), `settings` subtree. `Badge`, `BaseText`, `Button`, `Card`, `CheckedTextInput`, `CodeBlock`, `Divider`, `ErrorBoundary`, `ErrorCard`, `Flex`, `FormSwitch`, `Grid`, `Heading`, `Heart`, `Icons`, `Link`, `Notice`, `Paragraph`, `Span`, `Switch`, `margins` (`Margins`), `settings` subtree.
`ErrorBoundary.wrap(Component, { noop?, fallback?, onError?, message? })`. `ErrorBoundary.wrap(Component, { noop?, fallback?, onError?, message? })`.
@ -470,9 +475,11 @@ Actions. `MessageActions`, `ChannelActionCreators`, `UserProfileActions`, `Invit
Menu. `Menu`, `ContextMenuApi` (`openContextMenu`, `closeContextMenu`, `openContextMenuLazy`). Menu. `Menu`, `ContextMenuApi` (`openContextMenu`, `closeContextMenu`, `openContextMenuLazy`).
### Lazy webpack imports ### Lazy webpack imports
From `@webpack`. `findByPropsLazy`, `findByCodeLazy`, `findComponentLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy`, `findStoreLazy`, `findCssClassesLazy`, `findLazy`, `proxyLazyWebpack`, `LazyComponentWebpack`, `mapMangledModuleLazy`, `extractAndLoadChunksLazy`, `waitFor`. From `@webpack`. `findByPropsLazy`, `findByCodeLazy`, `findComponentLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy`, `findStoreLazy`, `findCssClassesLazy`, `findLazy`, `proxyLazyWebpack`, `LazyComponentWebpack`, `mapMangledModuleLazy`, `extractAndLoadChunksLazy`, `waitFor`.
`waitForStore` / `waitForComponent` live in `@webpack/common/internal.tsx`. Plugins don't call them directly. `waitForStore` / `waitForComponent` live in `@webpack/common/internal.tsx`. Plugins don't call them directly.
### `IconUtils` ### `IconUtils`
`getUserAvatarURL(user, canAnimate?, size?, format?)`, `getUserBannerURL({ id, banner, canAnimate?, size })`, `getGuildIconURL({ id, icon, size?, canAnimate? })`, `getGuildBannerURL(guild, canAnimate?)`, `getGuildSplashURL`, `getGuildDiscoverySplashURL`, `getGuildHomeHeaderURL`, `getChannelIconURL({ id, icon })`, `getResourceChannelIconURL`, `getEmojiURL({ id, animated, size })`, `getApplicationIconURL`, `getGameAssetURL`, `getVideoFilterAssetURL`, `getAvatarDecorationURL`, `getGuildMemberAvatarURL`, `getGuildMemberAvatarURLSimple`, `getGuildMemberBannerURL`, `getDefaultAvatarURL(id)`, `hasAnimatedGuildIcon`, `isAnimatedIconHash`. `getUserAvatarURL(user, canAnimate?, size?, format?)`, `getUserBannerURL({ id, banner, canAnimate?, size })`, `getGuildIconURL({ id, icon, size?, canAnimate? })`, `getGuildBannerURL(guild, canAnimate?)`, `getGuildSplashURL`, `getGuildDiscoverySplashURL`, `getGuildHomeHeaderURL`, `getChannelIconURL({ id, icon })`, `getResourceChannelIconURL`, `getEmojiURL({ id, animated, size })`, `getApplicationIconURL`, `getGameAssetURL`, `getVideoFilterAssetURL`, `getAvatarDecorationURL`, `getGuildMemberAvatarURL`, `getGuildMemberAvatarURLSimple`, `getGuildMemberBannerURL`, `getDefaultAvatarURL(id)`, `hasAnimatedGuildIcon`, `isAnimatedIconHash`.

View file

@ -105,7 +105,6 @@ export function CopyIdIcon(props: IconProps) {
); );
} }
/** /**
* Discord's open external icon, as seen in the user profile connections * Discord's open external icon, as seen in the user profile connections
*/ */

View file

@ -0,0 +1,13 @@
# Better Forwards
# Dont Follow Forwards
Don't follow forwarded messages after sending them.
## Before
![before](https://github.com/user-attachments/assets/13187c7c-37ea-41cc-bfbc-98c9f517c69b)
## After
![after](https://github.com/user-attachments/assets/1b3972d9-fa8e-47c5-a39f-94f4963d94fa)

View file

@ -4,11 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings, migratePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { sendMessage } from "@utils/discord"; import { sendMessage } from "@utils/discord";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { Channel, Message } from "@vencord/discord-types"; import { Channel, Message } from "@vencord/discord-types";
import { MouseEvent } from "react";
let ignore = false;
// Taken From Signature :) // Taken From Signature :)
const settings = definePluginSettings({ const settings = definePluginSettings({
@ -19,14 +22,22 @@ const settings = definePluginSettings({
{ label: ">", value: ">", default: true }, { label: ">", value: ">", default: true },
{ label: "-#", value: "-#" } { label: "-#", value: "-#" }
] ]
},
dontFollowForwards: {
description: "After forwarding a single message, don't jump to it. Hold shift to ignore this behavior",
displayName: "Don't Follow Forwards",
type: OptionType.BOOLEAN,
default: false,
restartNeeded: true,
} }
}); });
migratePluginSettings("BetterForwards", "ForwardAnywhere");
export default definePlugin({ export default definePlugin({
name: "ForwardAnywhere", name: "BetterForwards",
description: "If a forward fails send it as a normal message also allows nsfw forwards", description: "If a forward fails, send it as a normal message. Also allows nsfw forwards. See settings for various other improvements to forwarding.",
tags: ["Chat", "Utility"], tags: ["Chat", "Utility"],
authors: [Devs.thororen], authors: [Devs.thororen, Devs.sadan],
settings, settings,
patches: [ patches: [
{ {
@ -43,6 +54,22 @@ export default definePlugin({
replace: "if ($1) return $self.sendForward($1,$2);", replace: "if ($1) return $self.sendForward($1,$2);",
} }
}, },
{
find: "#{intl::MESSAGE_FORWARD_MESSAGE_PLACEHOLDER}",
predicate: () => settings.store.dontFollowForwards,
replacement: [
{
match: /(?<=transitionToDestination:)(1===\i\.length)(?=,|\})/,
replace: "$self.shouldTransition($1)"
},
{
// there are two useCallbacks with clearDraft in this module
// we need to anchor to the one that is used as an onClick handler
match: /((\i)=\i\.useCallback\(\()(\)=>\{)(null!=\i&&\i\.\i\.clearDraft)(?=.{1500,2000}onClick:\2)/,
replace: (_, beforeParen, _1, beforeBody, body) => `${beforeParen}vencordArg1${beforeBody}$self.setShift(vencordArg1);${body}`
}
]
}
], ],
sendForward(channels: Channel[], message: Message) { sendForward(channels: Channel[], message: Message) {
@ -60,5 +87,13 @@ export default definePlugin({
}); });
} }
}); });
},
shouldTransition(origCond: boolean): boolean {
return ignore ? origCond : false;
},
setShift(event: MouseEvent | undefined) {
ignore = !!event?.shiftKey;
} }
}); });

View file

@ -383,7 +383,7 @@ function SettingTextInput(props: {
const { description, name, onChange, placeholder, value } = props; const { description, name, onChange, placeholder, value } = props;
return ( return (
<SettingsSection name={name} description={description ?? ""}> <SettingsSection id={name} name={name} description={description ?? ""}>
<TextInput <TextInput
value={value} value={value}
onChange={onChange} onChange={onChange}
@ -401,7 +401,7 @@ function SettingGroup(props: {
const { children, description, name } = props; const { children, description, name } = props;
return ( return (
<SettingsSection name={name} description={description ?? ""}> <SettingsSection id={name} name={name} description={description ?? ""}>
<div className={cl("group")}> <div className={cl("group")}>
{children} {children}
</div> </div>
@ -418,7 +418,7 @@ function SettingSwitch(props: {
const { checked, description, name, onChange } = props; const { checked, description, name, onChange } = props;
return ( return (
<SettingsSection tag="label" name={name} description={description} inlineSetting> <SettingsSection id={name} tag="label" name={name} description={description} inlineSetting>
<Switch checked={checked} onChange={onChange} /> <Switch checked={checked} onChange={onChange} />
</SettingsSection> </SettingsSection>
); );
@ -469,7 +469,7 @@ function FallbackOrderSettings() {
}; };
return ( return (
<SettingsSection name="Fallback Order" description="Drag hosts to reorder fallback attempts. The selected host is tried first, then this order is used."> <SettingsSection id="fallback-order" name="Fallback Order" description="Drag hosts to reorder fallback attempts. The selected host is tried first, then this order is used.">
<div className={cl("fallback-order-list")}> <div className={cl("fallback-order-list")}>
{order.map((service, index) => ( {order.map((service, index) => (
<div <div
@ -567,7 +567,7 @@ export function SettingsComponent() {
return ( return (
<> <>
<SettingsSection name="Upload Service" description="Choose where FileUpload sends new files."> <SettingsSection id="upload-service" name="Upload Service" description="Choose where FileUpload sends new files.">
<ServicePicker <ServicePicker
value={store.serviceType as ServiceType} value={store.serviceType as ServiceType}
onChange={service => { onChange={service => {
@ -636,7 +636,7 @@ export function SettingsComponent() {
onChange={v => (store as { encryptingHostKey?: string; }).encryptingHostKey = v} onChange={v => (store as { encryptingHostKey?: string; }).encryptingHostKey = v}
placeholder="Your Encrypting.host API key" placeholder="Your Encrypting.host API key"
/> />
<SettingsSection name="URL Style" description="How Encrypting.host should format returned links."> <SettingsSection id="url-style" name="URL Style" description="How Encrypting.host should format returned links.">
<Select <Select
options={encryptingHostUrlStyleOptions} options={encryptingHostUrlStyleOptions}
isSelected={v => v === (store as { encryptingHostUrlStyle?: string; }).encryptingHostUrlStyle} isSelected={v => v === (store as { encryptingHostUrlStyle?: string; }).encryptingHostUrlStyle}
@ -648,7 +648,7 @@ export function SettingsComponent() {
placeholder="Select URL style" placeholder="Select URL style"
/> />
</SettingsSection> </SettingsSection>
<SettingsSection name="Domains JSON" description={"JSON array of domains to use, for example [\"offensive\"]."}> <SettingsSection id="domains-json" name="Domains JSON" description={"JSON array of domains to use, for example [\"offensive\"]."}>
<TextArea <TextArea
value={(store as { encryptingHostDomains?: string; }).encryptingHostDomains || ""} value={(store as { encryptingHostDomains?: string; }).encryptingHostDomains || ""}
rows={3} rows={3}
@ -760,7 +760,7 @@ export function SettingsComponent() {
)} )}
{isLitterbox && ( {isLitterbox && (
<SettingsSection name="Litterbox Expiry" description="How long uploads are retained"> <SettingsSection id="litterbox-expiry" name="Litterbox Expiry" description="How long uploads are retained">
<Select <Select
options={litterboxOptions} options={litterboxOptions}
isSelected={v => v === store.litterboxExpiry} isSelected={v => v === store.litterboxExpiry}
@ -813,6 +813,7 @@ export function SettingsComponent() {
{isShareX && ( {isShareX && (
<SettingGroup name="ShareX Custom Uploader" description="Paste, import, or validate a ShareX custom uploader config."> <SettingGroup name="ShareX Custom Uploader" description="Paste, import, or validate a ShareX custom uploader config.">
<SettingsSection <SettingsSection
id="sharex-custom-uploader-config"
name="ShareX Custom Uploader Config" name="ShareX Custom Uploader Config"
description="Paste your ShareX custom uploader JSON (.sxcu/.json). DestinationType must include FileUploader or ImageUploader." description="Paste your ShareX custom uploader JSON (.sxcu/.json). DestinationType must include FileUploader or ImageUploader."
> >
@ -823,7 +824,7 @@ export function SettingsComponent() {
onChange={v => store.sharexConfig = v} onChange={v => store.sharexConfig = v}
/> />
</SettingsSection> </SettingsSection>
<SettingsSection name="ShareX Config Actions" description="Import from file or validate pasted config"> <SettingsSection id="sharex-config-actions" name="ShareX Config Actions" description="Import from file or validate pasted config">
<div className={cl("actions")}> <div className={cl("actions")}>
<Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button> <Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button>
<Button size="small" onClick={validateShareXConfig}>Validate</Button> <Button size="small" onClick={validateShareXConfig}>Validate</Button>
@ -869,7 +870,7 @@ export function SettingsComponent() {
onChange={v => store.webdavDirectory = v} onChange={v => store.webdavDirectory = v}
placeholder="Leave empty for root directory" placeholder="Leave empty for root directory"
/> />
<SettingsSection name="Server Type" description="Select your WebDAV server type. Nextcloud and ownCloud will create a public share link. Generic returns the raw file URL."> <SettingsSection id="server-type" name="Server Type" description="Select your WebDAV server type. Nextcloud and ownCloud will create a public share link. Generic returns the raw file URL.">
<Select <Select
options={[ options={[
{ label: "Nextcloud", value: "nextcloud", default: true }, { label: "Nextcloud", value: "nextcloud", default: true },
@ -886,7 +887,7 @@ export function SettingsComponent() {
/> />
</SettingsSection> </SettingsSection>
{store.webdavServerType !== "generic" && ( {store.webdavServerType !== "generic" && (
<SettingsSection name="Share Link Format" description="How to return the public share link. Share Page links to a web page; Direct Download links straight to the file; Markdown Link wraps the share page in a clickable filename."> <SettingsSection id="share-link-format" name="Share Link Format" description="How to return the public share link. Share Page links to a web page; Direct Download links straight to the file; Markdown Link wraps the share page in a clickable filename.">
<Select <Select
options={[ options={[
{ label: "Share Page", value: "share-page", default: true }, { label: "Share Page", value: "share-page", default: true },
@ -925,7 +926,7 @@ export function SettingsComponent() {
/> />
{store.embedProxyEnabled && ( {store.embedProxyEnabled && (
<SettingsSection name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links"> <SettingsSection id="embed-proxy-service" name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links">
<Select <Select
options={embedProxyOptions} options={embedProxyOptions}
isSelected={v => v === store.embedProxyService} isSelected={v => v === store.embedProxyService}
@ -1022,11 +1023,11 @@ export function SettingsComponent() {
placeholder="https://your-cors-proxy.example.com" placeholder="https://your-cors-proxy.example.com"
/> />
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy"> <SettingsSection id="default-cors-proxy-source" name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a> <a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
</SettingsSection> </SettingsSection>
<SettingsSection name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback"> <SettingsSection id="upload-timeout" name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback">
<Select <Select
options={[ options={[
{ label: "30 seconds", value: 30000 }, { label: "30 seconds", value: 30000 },

View file

@ -43,7 +43,7 @@ function LyricsProviderSettings() {
return ( return (
<> <>
<SettingsSection name="Lyrics Provider" description="Where lyrics are fetched from."> <SettingsSection id="lyrics-provider" name="Lyrics Provider" description="Where lyrics are fetched from.">
<Select <Select
options={[ options={[
{ value: Provider.Lrclib, label: "LRCLIB", default: true }, { value: Provider.Lrclib, label: "LRCLIB", default: true },
@ -58,6 +58,7 @@ function LyricsProviderSettings() {
{store.lyricsProvider === Provider.Spotify && ( {store.lyricsProvider === Provider.Spotify && (
<SettingsSection <SettingsSection
id="spotify-lyrics-provider"
name="Spotify Lyrics API Base URL" name="Spotify Lyrics API Base URL"
description="Custom instance base URL (for example: http://localhost:8080)." description="Custom instance base URL (for example: http://localhost:8080)."
> >

View file

@ -18,7 +18,7 @@ type SettingsKey = keyof SettingsStore;
function SwitchSetting({ name, description, settingsKey }: { name: string; description: string; settingsKey: SettingsKey; }) { function SwitchSetting({ name, description, settingsKey }: { name: string; description: string; settingsKey: SettingsKey; }) {
const [value, setValue] = useState(settings.store[settingsKey] ?? false); const [value, setValue] = useState(settings.store[settingsKey] ?? false);
return ( return (
<SettingsSection tag="label" name={name} description={description} inlineSetting> <SettingsSection id={name} tag="label" name={name} description={description} inlineSetting>
<Switch checked={!!value} onChange={v => { setValue(v); (settings.store[settingsKey] as boolean) = v; onServiceChange?.(); }} /> <Switch checked={!!value} onChange={v => { setValue(v); (settings.store[settingsKey] as boolean) = v; onServiceChange?.(); }} />
</SettingsSection> </SettingsSection>
); );
@ -27,7 +27,7 @@ function SwitchSetting({ name, description, settingsKey }: { name: string; descr
function TextSetting({ name, description, settingsKey, placeholder }: { name: string; description: string; settingsKey: SettingsKey; placeholder?: string; }) { function TextSetting({ name, description, settingsKey, placeholder }: { name: string; description: string; settingsKey: SettingsKey; placeholder?: string; }) {
const [value, setValue] = useState(settings.store[settingsKey] ?? ""); const [value, setValue] = useState(settings.store[settingsKey] ?? "");
return ( return (
<SettingsSection name={name} description={description}> <SettingsSection id={name} name={name} description={description}>
<TextInput <TextInput
type="text" type="text"
value={String(value)} value={String(value)}
@ -41,7 +41,7 @@ function TextSetting({ name, description, settingsKey, placeholder }: { name: st
function SelectSetting({ name, description, settingsKey, options }: { name: string; description: string; settingsKey: SettingsKey; options: { label: string; value: string; }[]; }) { function SelectSetting({ name, description, settingsKey, options }: { name: string; description: string; settingsKey: SettingsKey; options: { label: string; value: string; }[]; }) {
const [value, setValue] = useState(settings.store[settingsKey] ?? options[0]?.value); const [value, setValue] = useState(settings.store[settingsKey] ?? options[0]?.value);
return ( return (
<SettingsSection name={name} description={description}> <SettingsSection id={name} name={name} description={description}>
<Select <Select
options={options} options={options}
isSelected={v => v === value} isSelected={v => v === value}
@ -57,7 +57,7 @@ function SelectSetting({ name, description, settingsKey, options }: { name: stri
function AudioBookShelfSettings() { function AudioBookShelfSettings() {
return ( return (
<> <>
<SettingsSection name="" description="Display your currently playing audiobooks as Discord Rich Presence. Requires your AudioBookShelf server URL, username, and password." /> <SettingsSection id="audiobookshelf-settings" name="" description="Display your currently playing audiobooks as Discord Rich Presence. Requires your AudioBookShelf server URL, username, and password." />
<TextSetting name="Server URL" description="AudioBookShelf server URL." settingsKey="abs_serverUrl" placeholder="https://abs.example.com" /> <TextSetting name="Server URL" description="AudioBookShelf server URL." settingsKey="abs_serverUrl" placeholder="https://abs.example.com" />
<TextSetting name="Username" description="AudioBookShelf username." settingsKey="abs_username" /> <TextSetting name="Username" description="AudioBookShelf username." settingsKey="abs_username" />
<TextSetting name="Password" description="AudioBookShelf password." settingsKey="abs_password" /> <TextSetting name="Password" description="AudioBookShelf password." settingsKey="abs_password" />
@ -67,7 +67,7 @@ function AudioBookShelfSettings() {
function TosuSettings() { function TosuSettings() {
return ( return (
<SettingsSection name="" description="Connects to tosu via WebSocket on port 24050. No configuration needed, just make sure tosu is running alongside osu!." /> <SettingsSection id="tosu-settings" name="" description="Connects to tosu via WebSocket on port 24050. No configuration needed, just make sure tosu is running alongside osu!." />
); );
} }
@ -83,7 +83,7 @@ const nameFormatOptions = [
function StatsFmSettings() { function StatsFmSettings() {
return ( return (
<> <>
<SettingsSection name="" description="Show what you're currently listening to via stats.fm. Requires your listening history to be public." /> <SettingsSection id="statsfm-settings" name="" description="Show what you're currently listening to via stats.fm. Requires your listening history to be public." />
<TextSetting name="Username" description="Stats.fm username." settingsKey="sfm_username" placeholder="stats.fm username" /> <TextSetting name="Username" description="Stats.fm username." settingsKey="sfm_username" placeholder="stats.fm username" />
<TextSetting name="Custom Status Text" description="Custom status text." settingsKey="sfm_statusName" placeholder="Stats.fm" /> <TextSetting name="Custom Status Text" description="Custom status text." settingsKey="sfm_statusName" placeholder="Stats.fm" />
<SelectSetting name="Name Format" description="Name format." settingsKey="sfm_nameFormat" options={nameFormatOptions} /> <SelectSetting name="Name Format" description="Name format." settingsKey="sfm_nameFormat" options={nameFormatOptions} />
@ -105,7 +105,7 @@ function StatsFmSettings() {
function JellyfinSettings() { function JellyfinSettings() {
return ( return (
<> <>
<SettingsSection name="" description="Show what you're playing on Jellyfin. To get your API key: open your Jellyfin web UI, press F12 to open Developer Tools, go to the Network tab, look for requests to your server, and find the X-MediaBrowser-Token header (Ctrl+F to search). Your user ID can be found in your profile page URL." /> <SettingsSection id="jellyfin-settings" name="" description="Show what you're playing on Jellyfin. To get your API key: open your Jellyfin web UI, press F12 to open Developer Tools, go to the Network tab, look for requests to your server, and find the X-MediaBrowser-Token header (Ctrl+F to search). Your user ID can be found in your profile page URL." />
<TextSetting name="Server URL" description="Jellyfin server URL." settingsKey="jf_serverUrl" placeholder="https://jellyfin.example.com" /> <TextSetting name="Server URL" description="Jellyfin server URL." settingsKey="jf_serverUrl" placeholder="https://jellyfin.example.com" />
<TextSetting name="API Key" description="Jellyfin API key." settingsKey="jf_apiKey" placeholder="X-MediaBrowser-Token" /> <TextSetting name="API Key" description="Jellyfin API key." settingsKey="jf_apiKey" placeholder="X-MediaBrowser-Token" />
<TextSetting name="User ID" description="Jellyfin user ID." settingsKey="jf_userId" placeholder="User ID from profile URL" /> <TextSetting name="User ID" description="Jellyfin user ID." settingsKey="jf_userId" placeholder="User ID from profile URL" />
@ -141,7 +141,7 @@ function JellyfinSettings() {
function ListenBrainzSettings() { function ListenBrainzSettings() {
return ( return (
<> <>
<SettingsSection name="" description="Show what you're currently listening to via ListenBrainz. The MusicBrainz API requires a meaningful user agent string (an email usually works)." /> <SettingsSection id="listenbrainz-settings" name="" description="Show what you're currently listening to via ListenBrainz. The MusicBrainz API requires a meaningful user agent string (an email usually works)." />
<TextSetting name="Username" description="ListenBrainz username." settingsKey="lb_username" placeholder="ListenBrainz username" /> <TextSetting name="Username" description="ListenBrainz username." settingsKey="lb_username" placeholder="ListenBrainz username" />
<TextSetting name="MusicBrainz Contact" description="MusicBrainz contact for user agent." settingsKey="lb_mbContact" placeholder="your@email.com" /> <TextSetting name="MusicBrainz Contact" description="MusicBrainz contact for user agent." settingsKey="lb_mbContact" placeholder="your@email.com" />
<TextSetting name="Custom Status Text" description="Custom status text." settingsKey="lb_statusName" placeholder="some music" /> <TextSetting name="Custom Status Text" description="Custom status text." settingsKey="lb_statusName" placeholder="some music" />
@ -165,16 +165,16 @@ function GensokyoRadioSettings() {
const [value, setValue] = useState(settings.store.gr_refreshInterval ?? 15); const [value, setValue] = useState(settings.store.gr_refreshInterval ?? 15);
return ( return (
<> <>
<SettingsSection name="" description="Discord rich presence for Gensokyo Radio. Just enable it and listen!" /> <SettingsSection id="gensokyoradio-settings" name="" description="Discord rich presence for Gensokyo Radio. Just enable it and listen!" />
<SettingsSection name="Refresh Interval" description="Refresh interval in seconds."> <SettingsSection id="refresh-interval" name="Refresh Interval" description="Refresh interval in seconds.">
<Slider <Slider
markers={[1, 2, 2.5, 3, 5, 10, 15]} markers={[1, 2, 2.5, 3, 5, 10, 15]}
initialValue={value} initialValue={value}
onValueChange={v => { setValue(v); settings.store.gr_refreshInterval = v; }} onValueChange={v => { setValue(v); settings.store.gr_refreshInterval = v; }}
onValueRender={v => `${v}s`} onValueRender={v => `${v}s`}
stickToMarkers stickToMarkers
/> />
</SettingsSection> </SettingsSection>
</> </>
); );
} }

View file

@ -68,7 +68,7 @@ async function rdbRequest<T = unknown>(path: string, options: RequestInit = {}):
return data as T; return data as T;
} }
export async function getReviews(id: string, { limit, offset = 0, fetchVotes = false }: { limit?: number; offset?: number; fetchVotes?: boolean } = {}): Promise<UserReviewsData> { export async function getReviews(id: string, { limit, offset = 0, fetchVotes = false }: { limit?: number; offset?: number; fetchVotes?: boolean; } = {}): Promise<UserReviewsData> {
let flags = 0; let flags = 0;
if (!settings.store.showWarning) flags |= WarningFlag; if (!settings.store.showWarning) flags |= WarningFlag;
@ -140,7 +140,7 @@ export async function getReviewVotes(id: string): Promise<ReviewVote[]> {
return res?.votes ?? []; return res?.votes ?? [];
} }
export async function addReview(review: { userid: string; comment: string }): Promise<UserReviewsData | null> { export async function addReview(review): Promise<UserReviewsData | null> {
const token = await getToken(); const token = await getToken();
if (!token) { if (!token) {
@ -186,7 +186,7 @@ export async function voteReview(id: number, isUpvote: boolean) {
return false; return false;
} }
const data = await rdbRequest<{ message?: string }>(`/reviews/${id}/vote`, { const data = await rdbRequest<{ message?: string; }>(`/reviews/${id}/vote`, {
method: "POST", method: "POST",
body: JSON.stringify({ isUpvote }) body: JSON.stringify({ isUpvote })
}); });
@ -204,7 +204,7 @@ export async function deleteReviewVote(id: number) {
return false; return false;
} }
const data = await rdbRequest<{ message?: string }>(`/reviews/${id}/vote`, { const data = await rdbRequest<{ message?: string; }>(`/reviews/${id}/vote`, {
method: "DELETE", method: "DELETE",
}); });

View file

@ -32,7 +32,6 @@ import { Message } from "@vencord/discord-types";
import { ChannelStore, GuildRoleStore, Menu, Modal, openModal, UserProfileStore } from "@webpack/common"; import { ChannelStore, GuildRoleStore, Menu, Modal, openModal, UserProfileStore } from "@webpack/common";
import { MouseEventHandler } from "react"; import { MouseEventHandler } from "react";
const CopyRawIcon: IconComponent = ({ height = 20, width = 20, className }) => { const CopyRawIcon: IconComponent = ({ height = 20, width = 20, className }) => {
return ( return (
<svg <svg