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:
parent
43b1794d13
commit
621dfb74de
9 changed files with 206 additions and 151 deletions
223
.rules
223
.rules
|
|
@ -28,7 +28,6 @@
|
|||
- Never boxed primitives (`Number`, `String`, `Boolean`, `Object`, `Function`, `{}`).
|
||||
- Never `namespace`. Modules only.
|
||||
- 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.
|
||||
|
||||
## Plugin Policy
|
||||
|
|
@ -45,15 +44,15 @@ Does NOT get merged:
|
|||
## Plugin Structure
|
||||
|
||||
- Every NEW source file starts with SPDX header:
|
||||
```
|
||||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) <year> Vendicated and contributors
|
||||
* 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".
|
||||
Older files carry the full GPL preamble. Leave existing headers alone, don't rewrite them.
|
||||
```
|
||||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) <year> Vendicated and contributors
|
||||
* 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".
|
||||
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 suffixes the build filters on: `.desktop`, `.web`, `.dev`, `.discordDesktop`, `.vesktop`, `.equibop`.
|
||||
- `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`:
|
||||
|
||||
| API | Dependency |
|
||||
|---|---|
|
||||
| ContextMenu | `ContextMenuAPI` |
|
||||
| MessageEvents | `MessageEventsAPI` |
|
||||
| ChatButtons | `ChatInputButtonAPI` |
|
||||
| Badges | `BadgeAPI` |
|
||||
| API | Dependency |
|
||||
| -------------------- | ------------------------- |
|
||||
| ContextMenu | `ContextMenuAPI` |
|
||||
| MessageEvents | `MessageEventsAPI` |
|
||||
| ChatButtons | `ChatInputButtonAPI` |
|
||||
| Badges | `BadgeAPI` |
|
||||
| MemberListDecorators | `MemberListDecoratorsAPI` |
|
||||
| MessageAccessories | `MessageAccessoriesAPI` |
|
||||
| MessageDecorations | `MessageDecorationsAPI` |
|
||||
| MessagePopover | `MessagePopoverAPI` |
|
||||
| NicknameIcons | `NicknameIconsAPI` |
|
||||
| HeaderBar | `HeaderBarAPI` |
|
||||
| UserArea | `UserAreaAPI` |
|
||||
| ProfileCollections | `ProfileCollectionsAPI` |
|
||||
| ProfileSections | `ProfileSectionsAPI` |
|
||||
| AudioPlayer | `AudioPlayerAPI` |
|
||||
| MessageAccessories | `MessageAccessoriesAPI` |
|
||||
| MessageDecorations | `MessageDecorationsAPI` |
|
||||
| MessagePopover | `MessagePopoverAPI` |
|
||||
| NicknameIcons | `NicknameIconsAPI` |
|
||||
| HeaderBar | `HeaderBarAPI` |
|
||||
| UserArea | `UserAreaAPI` |
|
||||
| ProfileCollections | `ProfileCollectionsAPI` |
|
||||
| ProfileSections | `ProfileSectionsAPI` |
|
||||
| AudioPlayer | `AudioPlayerAPI` |
|
||||
|
||||
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.
|
||||
- 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:
|
||||
```js
|
||||
// BAD
|
||||
replace: "IS_WEB?foo:bar"
|
||||
// GOOD
|
||||
replace: IS_WEB ? "foo" : "bar"
|
||||
// OK
|
||||
replace: `${IS_WEB}?foo:bar`
|
||||
```
|
||||
```js
|
||||
// BAD
|
||||
replace: "IS_WEB?foo:bar";
|
||||
// GOOD
|
||||
replace: IS_WEB ? "foo" : "bar";
|
||||
// OK
|
||||
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.
|
||||
|
||||
### Examples
|
||||
|
||||
```js
|
||||
// clean replacement
|
||||
find: "#{intl::EXAMPLE_KEY_A}),icon:"
|
||||
match: /#{intl::EXAMPLE_KEY_A}\)/
|
||||
replace: "$self.exampleFn(arguments[0]))"
|
||||
find: "#{intl::EXAMPLE_KEY_A}),icon:";
|
||||
match: /#{intl::EXAMPLE_KEY_A}\)/;
|
||||
replace: "$self.exampleFn(arguments[0]))";
|
||||
|
||||
// appending
|
||||
find: "#{intl::EXAMPLE_KEY_B}"
|
||||
match: /#{intl::EXAMPLE_KEY_B}.{0,100}(?=])/
|
||||
replace: "$&,$self.exampleRender(arguments[0].guild)"
|
||||
find: "#{intl::EXAMPLE_KEY_B}";
|
||||
match: /#{intl::EXAMPLE_KEY_B}.{0,100}(?=])/;
|
||||
replace: "$&,$self.exampleRender(arguments[0].guild)";
|
||||
|
||||
// wrapping with lookbehind
|
||||
find: "#{intl::EXAMPLE_KEY_C}"
|
||||
match: /(?<="aria-atomic":!0,children:)\i/
|
||||
replace: "$self.exampleWrap({ children: $& })"
|
||||
find: "#{intl::EXAMPLE_KEY_C}";
|
||||
match: /(?<="aria-atomic":!0,children:)\i/;
|
||||
replace: "$self.exampleWrap({ children: $& })";
|
||||
```
|
||||
|
||||
## 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.
|
||||
- `shell.openExternal`: only with https allowlist.
|
||||
- Renderer side:
|
||||
```ts
|
||||
const Native = VencordNative.pluginHelpers.MyPlugin as PluginNative<typeof import("./native")>;
|
||||
```
|
||||
```ts
|
||||
const Native = VencordNative.pluginHelpers.MyPlugin as PluginNative<
|
||||
typeof import("./native")
|
||||
>;
|
||||
```
|
||||
- `pluginHelpers` key matches `definePlugin.name` exactly (case-sensitive).
|
||||
- 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.
|
||||
|
|
@ -260,18 +261,18 @@ replace: "$self.exampleWrap({ children: $& })"
|
|||
- Long-lived component timers use `useFixedTimer`/`useTimer` from `@utils/react`.
|
||||
- Every `add*` in `start()` pairs with `remove*` in `stop()`.
|
||||
- Idle timer pattern:
|
||||
```ts
|
||||
let intervalId: number | undefined;
|
||||
start() { intervalId = setInterval(tick, 30_000); }
|
||||
stop() { if (intervalId !== undefined) { clearInterval(intervalId); intervalId = undefined; } }
|
||||
```
|
||||
```ts
|
||||
let intervalId: number | undefined;
|
||||
start() { intervalId = setInterval(tick, 30_000); }
|
||||
stop() { if (intervalId !== undefined) { clearInterval(intervalId); intervalId = undefined; } }
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
- Persistent state: `DataStore` from `@api/DataStore` (idb-backed). Never `localStorage`/`sessionStorage`/`indexedDB`.
|
||||
- Plain settings: `definePluginSettings`. Internal hidden state: `withPrivateSettings<T>()`.
|
||||
- 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.
|
||||
|
||||
## Network
|
||||
|
|
@ -315,57 +316,57 @@ replace: "$self.exampleWrap({ children: $& })"
|
|||
|
||||
Pick one per event. Never two for the same thing.
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Transient feedback (<2s) | `showToast(msg, Toasts.Type.SUCCESS)` from `@webpack/common` |
|
||||
| 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) |
|
||||
| Modal confirm | `Alerts.show({ title, body, onConfirm })` from `@webpack/common` |
|
||||
| Need | Use |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Transient feedback (<2s) | `showToast(msg, Toasts.Type.SUCCESS)` from `@webpack/common` |
|
||||
| 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) |
|
||||
| Modal confirm | `Alerts.show({ title, body, onConfirm })` from `@webpack/common` |
|
||||
|
||||
## Use what exists
|
||||
|
||||
Search before writing. Common needs:
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Sleep | `sleep(ms)` from `@utils/misc` |
|
||||
| Debounce / throttle | `lodash.debounce` / `lodash.throttle` |
|
||||
| Date format | `moment` or `formatDuration`/`formatDurationMs` from `@utils/text` |
|
||||
| Markdown parse | `Parser.parse(text)` |
|
||||
| Snowflake → date | `SnowflakeUtils.extractTimestamp(id)` |
|
||||
| URL parse | `parseUrl` from `@utils/misc` |
|
||||
| Regex escape | `escapeRegExp` from `@utils/text` |
|
||||
| Join lists | `humanFriendlyJoin([...])` from `@utils/text` |
|
||||
| Class concat | `classes(a, b, ...)` from `@utils/misc` |
|
||||
| Margins | `Margins.top8` etc, not inline `style` |
|
||||
| Deep clone | `lodash.cloneDeep` |
|
||||
| Clamp / range | `lodash.clamp`, `makeRange` from `@utils/types` |
|
||||
| Open URL | `VencordNative.native.openExternal(url)` or `MaskedLink` |
|
||||
| Copy clipboard | `copyWithToast` from `@utils/discord` |
|
||||
| Fetch uncached user | `fetchUserProfile(id)` from `@utils/discord` |
|
||||
| Open user profile | `openUserProfile(id)` |
|
||||
| Send chat msg | `sendMessage(channelId, { content })` from `@utils/discord` |
|
||||
| Need | Use |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| Sleep | `sleep(ms)` from `@utils/misc` |
|
||||
| Debounce / throttle | `lodash.debounce` / `lodash.throttle` |
|
||||
| Date format | `moment` or `formatDuration`/`formatDurationMs` from `@utils/text` |
|
||||
| Markdown parse | `Parser.parse(text)` |
|
||||
| Snowflake → date | `SnowflakeUtils.extractTimestamp(id)` |
|
||||
| URL parse | `parseUrl` from `@utils/misc` |
|
||||
| Regex escape | `escapeRegExp` from `@utils/text` |
|
||||
| Join lists | `humanFriendlyJoin([...])` from `@utils/text` |
|
||||
| Class concat | `classes(a, b, ...)` from `@utils/misc` |
|
||||
| Margins | `Margins.top8` etc, not inline `style` |
|
||||
| Deep clone | `lodash.cloneDeep` |
|
||||
| Clamp / range | `lodash.clamp`, `makeRange` from `@utils/types` |
|
||||
| Open URL | `VencordNative.native.openExternal(url)` or `MaskedLink` |
|
||||
| Copy clipboard | `copyWithToast` from `@utils/discord` |
|
||||
| Fetch uncached user | `fetchUserProfile(id)` from `@utils/discord` |
|
||||
| Open user profile | `openUserProfile(id)` |
|
||||
| Send chat msg | `sendMessage(channelId, { content })` from `@utils/discord` |
|
||||
|
||||
## Discord components
|
||||
|
||||
Reuse from `@webpack/common`. Don't recreate.
|
||||
|
||||
| Need | Component |
|
||||
|---|---|
|
||||
| Button | `Button` |
|
||||
| Text input | `TextInput` |
|
||||
| Multiline | `TextArea` |
|
||||
| Dropdown | `Select` / `SearchableSelect` |
|
||||
| Checkbox | `Checkbox` |
|
||||
| Slider | `Slider` |
|
||||
| Tooltip | `Tooltip` (function-child API) |
|
||||
| Context menu items | `Menu.*` |
|
||||
| Modal | `openModal(p => <ModalRoot {...p}>...</ModalRoot>)` |
|
||||
| Scroller | `ScrollerThin` / `ScrollerAuto` |
|
||||
| User pill | `UserSummaryItem` |
|
||||
| Avatar | `Avatar` |
|
||||
| Timestamp | `Timestamp` |
|
||||
| External link | `MaskedLink` |
|
||||
| Need | Component |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| Button | `Button` |
|
||||
| Text input | `TextInput` |
|
||||
| Multiline | `TextArea` |
|
||||
| Dropdown | `Select` / `SearchableSelect` |
|
||||
| Checkbox | `Checkbox` |
|
||||
| Slider | `Slider` |
|
||||
| Tooltip | `Tooltip` (function-child API) |
|
||||
| Context menu items | `Menu.*` |
|
||||
| Modal | `openModal(p => <ModalRoot {...p}>...</ModalRoot>)` |
|
||||
| Scroller | `ScrollerThin` / `ScrollerAuto` |
|
||||
| User pill | `UserSummaryItem` |
|
||||
| Avatar | `Avatar` |
|
||||
| Timestamp | `Timestamp` |
|
||||
| External link | `MaskedLink` |
|
||||
|
||||
## 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`.
|
||||
|
||||
| Constant | Meaning |
|
||||
|---|---|
|
||||
| `IS_WEB` | Web build (extension/userscript/standalone web) |
|
||||
| `IS_EXTENSION` | Browser extension |
|
||||
| `IS_USERSCRIPT` | Userscript |
|
||||
| `IS_STANDALONE` | Standalone (no updater) |
|
||||
| `IS_UPDATER_DISABLED` | Updater off |
|
||||
| `IS_DEV` | Dev build (auto-diff, extra logs) |
|
||||
| `IS_REPORTER` | Auto-reporter |
|
||||
| `IS_COMPANION_TEST` | Companion test |
|
||||
| `IS_ANTI_CRASH_TEST` | Anti-crash test |
|
||||
| `IS_DISCORD_DESKTOP` | Discord desktop injection |
|
||||
| `IS_VESKTOP` | Vesktop |
|
||||
| `IS_EQUIBOP` | Equibop |
|
||||
| `VERSION` | Equicord version string |
|
||||
| `BUILD_TIMESTAMP` | Build epoch ms |
|
||||
| Constant | Meaning |
|
||||
| --------------------- | ----------------------------------------------- |
|
||||
| `IS_WEB` | Web build (extension/userscript/standalone web) |
|
||||
| `IS_EXTENSION` | Browser extension |
|
||||
| `IS_USERSCRIPT` | Userscript |
|
||||
| `IS_STANDALONE` | Standalone (no updater) |
|
||||
| `IS_UPDATER_DISABLED` | Updater off |
|
||||
| `IS_DEV` | Dev build (auto-diff, extra logs) |
|
||||
| `IS_REPORTER` | Auto-reporter |
|
||||
| `IS_COMPANION_TEST` | Companion test |
|
||||
| `IS_ANTI_CRASH_TEST` | Anti-crash test |
|
||||
| `IS_DISCORD_DESKTOP` | Discord desktop injection |
|
||||
| `IS_VESKTOP` | Vesktop |
|
||||
| `IS_EQUIBOP` | Equibop |
|
||||
| `VERSION` | Equicord version string |
|
||||
| `BUILD_TIMESTAMP` | Build epoch ms |
|
||||
|
||||
## Reference
|
||||
|
||||
### `@vencord/discord-types`
|
||||
|
||||
`Channel`, `Guild`, `GuildMember`, `User`, `Role`, `Message`, `Command`, `FluxEvents`, store types.
|
||||
|
||||
### `@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`.
|
||||
|
||||
`@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`.
|
||||
|
||||
### `@utils/*`
|
||||
|
||||
- `Logger`. `new Logger(name)`.
|
||||
- `css`. `classNameFactory`, `classNameToSelector`.
|
||||
- `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`.
|
||||
|
||||
### `@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.
|
||||
|
||||
`ErrorBoundary.wrap(Component, { noop?, fallback?, onError?, message? })`.
|
||||
|
|
@ -470,9 +475,11 @@ Actions. `MessageActions`, `ChannelActionCreators`, `UserProfileActions`, `Invit
|
|||
Menu. `Menu`, `ContextMenuApi` (`openContextMenu`, `closeContextMenu`, `openContextMenuLazy`).
|
||||
|
||||
### Lazy webpack imports
|
||||
|
||||
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.
|
||||
|
||||
### `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`.
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ export function CopyIdIcon(props: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Discord's open external icon, as seen in the user profile connections
|
||||
*/
|
||||
|
|
|
|||
13
src/equicordplugins/betterForwards/README.md
Normal file
13
src/equicordplugins/betterForwards/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Better Forwards
|
||||
|
||||
# Dont Follow Forwards
|
||||
|
||||
Don't follow forwarded messages after sending them.
|
||||
|
||||
## Before
|
||||
|
||||

|
||||
|
||||
## After
|
||||
|
||||

|
||||
|
|
@ -4,11 +4,14 @@
|
|||
* 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 { sendMessage } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Channel, Message } from "@vencord/discord-types";
|
||||
import { MouseEvent } from "react";
|
||||
|
||||
let ignore = false;
|
||||
|
||||
// Taken From Signature :)
|
||||
const settings = definePluginSettings({
|
||||
|
|
@ -19,14 +22,22 @@ const settings = definePluginSettings({
|
|||
{ label: ">", value: ">", default: true },
|
||||
{ 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({
|
||||
name: "ForwardAnywhere",
|
||||
description: "If a forward fails send it as a normal message also allows nsfw forwards",
|
||||
name: "BetterForwards",
|
||||
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"],
|
||||
authors: [Devs.thororen],
|
||||
authors: [Devs.thororen, Devs.sadan],
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
|
|
@ -43,6 +54,22 @@ export default definePlugin({
|
|||
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) {
|
||||
|
|
@ -60,5 +87,13 @@ export default definePlugin({
|
|||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
shouldTransition(origCond: boolean): boolean {
|
||||
return ignore ? origCond : false;
|
||||
},
|
||||
|
||||
setShift(event: MouseEvent | undefined) {
|
||||
ignore = !!event?.shiftKey;
|
||||
}
|
||||
});
|
||||
|
|
@ -383,7 +383,7 @@ function SettingTextInput(props: {
|
|||
const { description, name, onChange, placeholder, value } = props;
|
||||
|
||||
return (
|
||||
<SettingsSection name={name} description={description ?? ""}>
|
||||
<SettingsSection id={name} name={name} description={description ?? ""}>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
|
@ -401,7 +401,7 @@ function SettingGroup(props: {
|
|||
const { children, description, name } = props;
|
||||
|
||||
return (
|
||||
<SettingsSection name={name} description={description ?? ""}>
|
||||
<SettingsSection id={name} name={name} description={description ?? ""}>
|
||||
<div className={cl("group")}>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -418,7 +418,7 @@ function SettingSwitch(props: {
|
|||
const { checked, description, name, onChange } = props;
|
||||
|
||||
return (
|
||||
<SettingsSection tag="label" name={name} description={description} inlineSetting>
|
||||
<SettingsSection id={name} tag="label" name={name} description={description} inlineSetting>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</SettingsSection>
|
||||
);
|
||||
|
|
@ -469,7 +469,7 @@ function FallbackOrderSettings() {
|
|||
};
|
||||
|
||||
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")}>
|
||||
{order.map((service, index) => (
|
||||
<div
|
||||
|
|
@ -567,7 +567,7 @@ export function SettingsComponent() {
|
|||
|
||||
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
|
||||
value={store.serviceType as ServiceType}
|
||||
onChange={service => {
|
||||
|
|
@ -636,7 +636,7 @@ export function SettingsComponent() {
|
|||
onChange={v => (store as { encryptingHostKey?: string; }).encryptingHostKey = v}
|
||||
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
|
||||
options={encryptingHostUrlStyleOptions}
|
||||
isSelected={v => v === (store as { encryptingHostUrlStyle?: string; }).encryptingHostUrlStyle}
|
||||
|
|
@ -648,7 +648,7 @@ export function SettingsComponent() {
|
|||
placeholder="Select URL style"
|
||||
/>
|
||||
</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
|
||||
value={(store as { encryptingHostDomains?: string; }).encryptingHostDomains || ""}
|
||||
rows={3}
|
||||
|
|
@ -760,7 +760,7 @@ export function SettingsComponent() {
|
|||
)}
|
||||
|
||||
{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
|
||||
options={litterboxOptions}
|
||||
isSelected={v => v === store.litterboxExpiry}
|
||||
|
|
@ -813,6 +813,7 @@ export function SettingsComponent() {
|
|||
{isShareX && (
|
||||
<SettingGroup name="ShareX Custom Uploader" description="Paste, import, or validate a ShareX custom uploader config.">
|
||||
<SettingsSection
|
||||
id="sharex-custom-uploader-config"
|
||||
name="ShareX Custom Uploader Config"
|
||||
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}
|
||||
/>
|
||||
</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")}>
|
||||
<Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button>
|
||||
<Button size="small" onClick={validateShareXConfig}>Validate</Button>
|
||||
|
|
@ -869,7 +870,7 @@ export function SettingsComponent() {
|
|||
onChange={v => store.webdavDirectory = v}
|
||||
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
|
||||
options={[
|
||||
{ label: "Nextcloud", value: "nextcloud", default: true },
|
||||
|
|
@ -886,7 +887,7 @@ export function SettingsComponent() {
|
|||
/>
|
||||
</SettingsSection>
|
||||
{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
|
||||
options={[
|
||||
{ label: "Share Page", value: "share-page", default: true },
|
||||
|
|
@ -925,7 +926,7 @@ export function SettingsComponent() {
|
|||
/>
|
||||
|
||||
{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
|
||||
options={embedProxyOptions}
|
||||
isSelected={v => v === store.embedProxyService}
|
||||
|
|
@ -1022,11 +1023,11 @@ export function SettingsComponent() {
|
|||
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>
|
||||
</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
|
||||
options={[
|
||||
{ label: "30 seconds", value: 30000 },
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function LyricsProviderSettings() {
|
|||
|
||||
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
|
||||
options={[
|
||||
{ value: Provider.Lrclib, label: "LRCLIB", default: true },
|
||||
|
|
@ -58,6 +58,7 @@ function LyricsProviderSettings() {
|
|||
|
||||
{store.lyricsProvider === Provider.Spotify && (
|
||||
<SettingsSection
|
||||
id="spotify-lyrics-provider"
|
||||
name="Spotify Lyrics API Base URL"
|
||||
description="Custom instance base URL (for example: http://localhost:8080)."
|
||||
>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ type SettingsKey = keyof SettingsStore;
|
|||
function SwitchSetting({ name, description, settingsKey }: { name: string; description: string; settingsKey: SettingsKey; }) {
|
||||
const [value, setValue] = useState(settings.store[settingsKey] ?? false);
|
||||
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?.(); }} />
|
||||
</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; }) {
|
||||
const [value, setValue] = useState(settings.store[settingsKey] ?? "");
|
||||
return (
|
||||
<SettingsSection name={name} description={description}>
|
||||
<SettingsSection id={name} name={name} description={description}>
|
||||
<TextInput
|
||||
type="text"
|
||||
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; }[]; }) {
|
||||
const [value, setValue] = useState(settings.store[settingsKey] ?? options[0]?.value);
|
||||
return (
|
||||
<SettingsSection name={name} description={description}>
|
||||
<SettingsSection id={name} name={name} description={description}>
|
||||
<Select
|
||||
options={options}
|
||||
isSelected={v => v === value}
|
||||
|
|
@ -57,7 +57,7 @@ function SelectSetting({ name, description, settingsKey, options }: { name: stri
|
|||
function AudioBookShelfSettings() {
|
||||
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="Username" description="AudioBookShelf username." settingsKey="abs_username" />
|
||||
<TextSetting name="Password" description="AudioBookShelf password." settingsKey="abs_password" />
|
||||
|
|
@ -67,7 +67,7 @@ function AudioBookShelfSettings() {
|
|||
|
||||
function TosuSettings() {
|
||||
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() {
|
||||
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="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} />
|
||||
|
|
@ -105,7 +105,7 @@ function StatsFmSettings() {
|
|||
function JellyfinSettings() {
|
||||
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="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" />
|
||||
|
|
@ -141,7 +141,7 @@ function JellyfinSettings() {
|
|||
function ListenBrainzSettings() {
|
||||
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="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" />
|
||||
|
|
@ -165,16 +165,16 @@ function GensokyoRadioSettings() {
|
|||
const [value, setValue] = useState(settings.store.gr_refreshInterval ?? 15);
|
||||
return (
|
||||
<>
|
||||
<SettingsSection name="" description="Discord rich presence for Gensokyo Radio. Just enable it and listen!" />
|
||||
<SettingsSection name="Refresh Interval" description="Refresh interval in seconds.">
|
||||
<Slider
|
||||
markers={[1, 2, 2.5, 3, 5, 10, 15]}
|
||||
initialValue={value}
|
||||
onValueChange={v => { setValue(v); settings.store.gr_refreshInterval = v; }}
|
||||
onValueRender={v => `${v}s`}
|
||||
stickToMarkers
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection id="gensokyoradio-settings" name="" description="Discord rich presence for Gensokyo Radio. Just enable it and listen!" />
|
||||
<SettingsSection id="refresh-interval" name="Refresh Interval" description="Refresh interval in seconds.">
|
||||
<Slider
|
||||
markers={[1, 2, 2.5, 3, 5, 10, 15]}
|
||||
initialValue={value}
|
||||
onValueChange={v => { setValue(v); settings.store.gr_refreshInterval = v; }}
|
||||
onValueRender={v => `${v}s`}
|
||||
stickToMarkers
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ async function rdbRequest<T = unknown>(path: string, options: RequestInit = {}):
|
|||
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;
|
||||
if (!settings.store.showWarning) flags |= WarningFlag;
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ export async function getReviewVotes(id: string): Promise<ReviewVote[]> {
|
|||
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();
|
||||
if (!token) {
|
||||
|
|
@ -186,7 +186,7 @@ export async function voteReview(id: number, isUpvote: boolean) {
|
|||
return false;
|
||||
}
|
||||
|
||||
const data = await rdbRequest<{ message?: string }>(`/reviews/${id}/vote`, {
|
||||
const data = await rdbRequest<{ message?: string; }>(`/reviews/${id}/vote`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ isUpvote })
|
||||
});
|
||||
|
|
@ -204,7 +204,7 @@ export async function deleteReviewVote(id: number) {
|
|||
return false;
|
||||
}
|
||||
|
||||
const data = await rdbRequest<{ message?: string }>(`/reviews/${id}/vote`, {
|
||||
const data = await rdbRequest<{ message?: string; }>(`/reviews/${id}/vote`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import { Message } from "@vencord/discord-types";
|
|||
import { ChannelStore, GuildRoleStore, Menu, Modal, openModal, UserProfileStore } from "@webpack/common";
|
||||
import { MouseEventHandler } from "react";
|
||||
|
||||
|
||||
const CopyRawIcon: IconComponent = ({ height = 20, width = 20, className }) => {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue