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 `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`.

View file

@ -105,7 +105,6 @@ export function CopyIdIcon(props: IconProps) {
);
}
/**
* 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
*/
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;
}
});

View file

@ -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 },

View file

@ -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)."
>

View file

@ -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>
</>
);
}

View file

@ -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",
});

View file

@ -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