Merge branch 'dev'

This commit is contained in:
thororen1234 2026-02-18 22:49:10 -05:00
commit 22592b1435
No known key found for this signature in database
22 changed files with 935 additions and 478 deletions

View file

@ -1,12 +1,22 @@
have_fun: false
memory_config:
disabled: false
code_review:
disable: false
comment_severity_threshold: LOW
comment_severity_threshold: MEDIUM
max_review_comments: -1
pull_request_opened:
help: false
summary: false
code_review: false
include_drafts: false
ignore_patterns:
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "dist/**"
- "browser/**"
- "packages/**"
- "patches/**"
- "misc/**"
- "scripts/**"
- ".vscode/**"
- ".github/**"
- "*.map"

View file

@ -1,134 +1,382 @@
title: Equicord Review Guidelines
# Equicord Code Review Style Guide
rules:
critical:
licenseHeader: NEVER edit existing license headers, new files use 2026
checkDeprecated: always verify components/functions arent deprecated before using
noAnyTypes: NEVER use any for Discord objects, import proper types from @vencord/discord-types
minimalChanges: fix with smallest possible change, no refactoring unless explicitly asked
noOverengineering: solve the problem at hand only, no patterns for patterns sake
humanText: write natural human text in errors, descriptions, messages. No dashes or robotic formatting. Write like "Module not found" not "Module - not found". Keep it simple and conversational
code:
deleteNotComment: DELETE dead code, dont comment it, git preserves history
noHardcoded: no hardcoded values, unused imports, magic numbers
useClassNameFactory: import classNameFactory from @utils/css (NOT @api/Styles which is deprecated), ALWAYS use cl() for class names, never hardcode strings like "vc-plugin-name-class"
useClasses: when combining multiple class names use classes() from @utils/misc, not template strings like `${a} ${b}`
useExisting: use utilities from @utils/, @api/, @components/
logger: use Logger from @utils/Logger, not console.log
descriptions: capital first letter, end with period
minimal: less code with same functionality is ALWAYS better
noComments: NEVER add comments unless explicitly asked
preserveComments: keep existing comments, they contain important context
performance: use Map/Set, .some/.find, no spread in loops, Promise.all
simple: KISS over clever, flat over nested, explicit over implicit
typescript:
prefer: optional chaining (?.), nullish coalescing (??), const, arrow functions
use: destructuring, template literals, object shorthand, array methods
style: early returns, trust inference, inline single-use variables
react:
errorBoundary: wrap complex components with ErrorBoundary.wrap(Component, { noop: true })
nullNotUndefined: return null for conditional rendering not undefined
forbidden: React.cloneElement, React.isValidElement, React.memo(), React.lazy, React.Children
cleanup: always return cleanup functions in useEffect
forbidden:
DOM: direct DOM manipulation (use patches)
cssOnly: CSS-only plugins
emptyCatch: empty catch blocks
hardcodedVars: hardcoded minified vars in patches (e,t,n)
settingsArrays: NEVER use settings.use() with arrays, mutate then reassign
antiPatterns{bad ⟹ good}:
"value !== null && value !== undefined" ⟹ "value"
"array && array.length > 0" ⟹ "array.length"
"settings?.store?.value" ⟹ "settings.store.value"
"value || defaultValue" ⟹ "value ?? defaultValue"
You are reviewing PRs for **Equicord**, a Discord client mod built on Vencord. It uses TypeScript, React, and webpack patching to inject into Discord. The codebase has extensive built-in utilities that contributors MUST use. Be direct, actionable, no pleasantries. Use natural, human language. Be blunt when code is bad.
patching:
core:
surgical: minimum code touched, stability over cleverness, one patch per concern
iterate: try multiple approaches, keep simplifying until you find the cleanest solution
minimize: remove any regex character that doesnt change the match result
balanced: not overengineered, not overlooked, just clean and future-proof
find:
preferIntl: use #{intl::KEY} in find when possible, most stable anchor
makeUnique: if find matches multiple modules, add nearby stable string like "),icon:" or function name
combine: "#{intl::PIN_MESSAGE}),icon:" is better than generic strings
match:
shortestPath: match only what you need to replace, let find do the targeting
intlInMatch: can use #{intl::KEY} in match too, gets canonicalized to hash
simpleWins: /#{intl::KEY}\)/ beats /label:\i\.pinned\?.{0,60}#{intl::KEY}\)/
useAmpersand: $& keeps original, append/prepend to it
boundedGaps: .{0,50} not .+? or .*?
captureOnlyIfNeeded: only use capture groups if reusing in replace
NEVER:
hardcodedMinified: e,t,n,r,i,o,s,l,c,u,$_,xx,eD,eH,eW ⟹ use \i instead
minifiedChains: \i\.\i alone ⟹ surround with stable strings
unboundedGaps: .+? or .*? ⟹ use .{0,N}
genericPatterns: /className:\i/ alone ⟹ add anchor
rawIntlHash: .aA4Vce ⟹ #{intl::KEY_NAME}
examples:
clean: |
find: "#{intl::PIN_MESSAGE}),icon:"
match: /#{intl::PIN_MESSAGE}\)/
replace: "$self.getPinLabel(arguments[0]))"
append: |
find: "#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}"
match: /#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}.{0,100}(?=])/
replace: "$&,$self.renderTooltip(arguments[0].guild)"
wrap: |
find: "#{intl::THREE_USERS_TYPING}"
match: /(?<="aria-atomic":!0,children:)\i/
replace: "$self.renderTypingUsers({ children: $& })"
---
intl:
syntax: "#{intl::KEY}" or "#{intl::HASH::raw}" if key unknown
workflow: hash in code → reverse to get key → use #{intl::KEY} or fallback to #{intl::HASH::raw}
## Plugin Acceptance (Instant Reject)
pluginInterop:
checkEnabled: isPluginEnabled(name) from @api/PluginManager takes a string, checks required/isDependency/enabled
import: import the plugin directly from its path to access .name and functions
avoid: dont use Vencord.Plugins.plugins or plugin.started, dont use "as unknown as" casting
example: |
import { isPluginEnabled } from "@api/PluginManager";
import otherPlugin from "@equicordplugins/otherPlugin";
if (!isPluginEnabled(otherPlugin.name)) return null;
otherPlugin.someFunction();
If a plugin breaks ANY of these, reject the entire PR. These are non-negotiable:
reference:
types: Channel, Guild, GuildMember, User, Role, Message from @vencord/discord-types
components: Paragraph, BaseText, Flex, Button, ErrorCard from @components/
settings: use definePluginSettings from @api/Settings, not inline settings object
utilities:
clipboard: copyToClipboard from @utils/clipboard
discord: insertTextIntoChatInputBox, getCurrentChannel, getCurrentGuild, getIntlMessage, openUserProfile, openPrivateChannel, sendMessage, copyWithToast, getUniqueUsername, fetchUserProfile from @utils/discord
css: classNameFactory, classNameToSelector from @utils/css
misc: classes, sleep, isObject, isObjectEmpty, pluralise, parseUrl, identity from @utils/misc
text: formatDuration, formatDurationMs, humanFriendlyJoin, makeCodeblock, toInlineCode, escapeRegExp from @utils/text
modal: openModal, closeModal, ModalRoot, ModalHeader, ModalContent, ModalCloseButton from @utils/modal
margins: Margins from @utils/margins (Margins.top8, Margins.bottom16, etc.)
guards: isTruthy, isNonNullish from @utils/guards
web: saveFile, chooseFile from @utils/web
webpackCommon:
icons: IconUtils from @webpack/common, NEVER hardcode cdn.discordapp.com URLs
getUserAvatarURL: IconUtils.getUserAvatarURL(user, canAnimate?, size?)
getDefaultAvatarURL: IconUtils.getDefaultAvatarURL(id)
getUserBannerURL: IconUtils.getUserBannerURL({ id, banner, canAnimate?, size })
getGuildIconURL: IconUtils.getGuildIconURL({ id, icon, size?, canAnimate? })
getGuildBannerURL: IconUtils.getGuildBannerURL(guild, canAnimate?)
getChannelIconURL: IconUtils.getChannelIconURL({ id, icon })
getEmojiURL: IconUtils.getEmojiURL({ id, animated, size })
getApplicationIconURL: IconUtils.getApplicationIconURL(data)
getGameAssetURL: IconUtils.getGameAssetURL(data)
stores: UserStore, GuildStore, ChannelStore, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, PresenceStore, RelationshipStore, MessageStore, EmojiStore, ThemeStore, PermissionStore, VoiceStateStore from @webpack/common
actions: RestAPI, FluxDispatcher, MessageActions, NavigationRouter, ChannelRouter, ChannelActionCreators, SettingsRouter from @webpack/common
utils: Constants (Constants.Endpoints), SnowflakeUtils, Parser, PermissionsBits, moment, lodash, ColorUtils, ImageUtils, DateUtils, UsernameUtils, DisplayProfileUtils from @webpack/common
components: Tooltip, TextInput, TextArea, Select, Slider, Avatar, Menu, Popout, ScrollerThin, Timestamp from @webpack/common
toasts: Toasts, showToast from @webpack/common
imports:
lazy: findByPropsLazy, findStoreLazy, findComponentByCodeLazy, findExportedComponentLazy from @webpack
common: useState, useEffect, useCallback, useStateFromStores from @webpack/common
neverHardcode:
cdnURLs: NEVER use "cdn.discordapp.com/avatars/", "cdn.discordapp.com/banners/", "cdn.discordapp.com/emojis/", "cdn.discordapp.com/icons/", "cdn.discordapp.com/channel-icons/" ⟹ use IconUtils
apiEndpoints: NEVER hardcode "/api/v9", "/users/@me" ⟹ use Constants.Endpoints or RestAPI
console: NEVER use console.log/warn/error ⟹ use Logger from @utils/Logger
classConcat: NEVER use `${a} ${b}` for class names ⟹ use classes(a, b) from @utils/misc
defaultAvatars: NEVER hardcode "/assets/*.png" for default avatars ⟹ use IconUtils.getDefaultAvatarURL(id)
1. **No simple slash-command plugins** (e.g. `/cat`). Should be a [user-installable Discord app](https://discord.com/developers/docs/change-log#userinstallable-apps-preview) instead.
2. **No simple text replacement plugins.** The built-in TextReplace plugin already covers this.
3. **No raw DOM manipulation.** Always use patches and React.
4. **No FakeDeafen or FakeMute.**
5. **No StereoMic-related plugins.**
6. **No UI-only hide/redesign plugins.** Use CSS for that. (Negotiable in rare cases.)
7. **No plugins targeting specific third-party Discord bots.** Official Discord apps are fine.
8. **No selfbot or API abuse.** Auto-replies, animated statuses, message pruning, Nitro snipers, etc.
9. **No untrusted third-party APIs.** Well-known services (Google, GitHub) are acceptable.
10. **No plugins requiring users to provide their own API keys.**
11. **No new dependencies** unless strictly necessary and well justified.
---
## Plugin Structure
Default export via `definePlugin` from `@utils/types`. Non-negotiable.
```typescript
import definePlugin from "@utils/types";
import { EquicordDevs } from "@utils/constants";
export default definePlugin({
name: "PluginName", // PascalCase, matches directory name
description: "Does something.", // Capital first, period at end
authors: [EquicordDevs.Name], // EquicordDevs for new, Devs for upstream
});
```
**Settings** must use `definePluginSettings` from `@api/Settings`. Reject inline `options` objects.
```typescript
// GOOD
const settings = definePluginSettings({
myOption: { type: OptionType.BOOLEAN, description: "Enables the feature.", default: true }
});
export default definePlugin({ settings, /* ... */ });
// BAD
export default definePlugin({ options: { myOption: { /* ... */ } } });
```
**Prefer declarative APIs** over manual registration:
- `flux: { EVENT_NAME(data) {} }` not manual FluxDispatcher subscribe
- `contextMenus: { "nav-id": fn }` not manual addContextMenuPatch
- `chatBarButton: { render, icon }` not addChatBarButton
- `messagePopoverButton: { render, icon }` not addMessagePopoverButton
- `managedStyle` for CSS not manual enableStyle/disableStyle
**Reject deprecated fields:** `renderChatBarButton`, `renderMessagePopoverButton`, `options`
**Lifecycle balance:** anything registered in `start()` must be cleaned up in `stop()`.
---
## Forbidden Patterns
Flag these and suggest the fix:
| Bad | Good | Reason |
|-----|------|--------|
| `value !== null && value !== undefined` | `value` or `isNonNullish(value)` | Verbose |
| `array && array.length > 0` | `array.length` | Redundant check |
| `settings?.store?.value` | `settings.store.value` | Store is always defined |
| `value \|\| defaultValue` | `value ?? defaultValue` | `\|\|` falsifies `0`, `""`, `false` |
| `` `${classA} ${classB}` `` | `classes(classA, classB)` | Handles null/false gracefully |
| `"vc-plugin-class"` hardcoded | `cl("class")` via `classNameFactory` | Consistent, typo-proof |
| `console.log/warn/error` | Remove it | No logging in plugin code |
| `new Logger(...)` / `logger.log(...)` | Remove it | No logging in plugin code |
| `cdn.discordapp.com/avatars/...` | `IconUtils.getUserAvatarURL(user)` | Handles animated, sizing, CDN |
| `cdn.discordapp.com/icons/...` | `IconUtils.getGuildIconURL(...)` | Same |
| `cdn.discordapp.com/banners/...` | `IconUtils.getUserBannerURL(...)` | Same |
| `cdn.discordapp.com/emojis/...` | `IconUtils.getEmojiURL(...)` | Same |
| `/assets/*.png` default avatars | `IconUtils.getDefaultAvatarURL(id)` | Same |
| `/api/v9/...` or `/users/@me` | `Constants.Endpoints.*` or `RestAPI` | Endpoints change |
| `@api/Styles` for classNameFactory | `@utils/css` | `@api/Styles` is deprecated |
| `any` for Discord objects | Import from `@vencord/discord-types` | Type safety |
| `as unknown as` casting | Find the correct type | Unsafe |
| `React.memo()` | Remove it | Not needed |
| `React.cloneElement` / `React.isValidElement` / `React.Children` | Find another approach | Forbidden |
| `React.lazy(() => import(...))` | `LazyComponent` from `@utils/lazyReact` | Framework-integrated |
| Empty `catch {}` | Handle the error (toast, fallback, rethrow) | Silent failures |
| CSS-only plugins | Must have actual logic/patches | Not allowed |
| Commented-out dead code | Delete it | Git has history |
| `document.querySelector(...)` | Use webpack patches | DOM manipulation forbidden |
| `Vencord.Plugins.plugins["X"]` | `isPluginEnabled` + direct import | Proper interop |
| `plugin.started` check | `isPluginEnabled(plugin.name)` | Proper interop |
| Unexplained magic numbers | Named constants | Readability |
| Unused imports | Remove them | Cleanliness |
---
## TypeScript and Code Style
**Prefer:** `?.` optional chaining, `??` nullish coalescing, `const`, arrow functions, destructuring, template literals, object shorthand, array methods (`.map`, `.filter`, `.find`, `.some`).
**Style:** early returns over nested conditions, trust TypeScript inference (don't annotate obvious types), inline single-use variables, flat over nested, explicit over implicit.
**Less is more.** Less code with the same functionality is always better. KISS over clever.
**No comments.** Never add comments unless explicitly asked. However, preserve existing comments as they contain important context.
```typescript
// GOOD — early return, destructuring, inference, no comments
const getUser = (id: string) => {
const user = UserStore.getUser(id);
if (!user) return null;
const { username, discriminator } = user;
return `${username}#${discriminator}`;
};
// BAD — nested, verbose, unnecessary annotations
function getUser(id: string): string | null {
// Get the user from the store
const user = UserStore.getUser(id);
if (user) {
const username: string = user.username;
const discriminator: string = user.discriminator;
return username + "#" + discriminator;
} else {
return null;
}
}
```
Note: ESLint already enforces `prefer-const`, `prefer-destructuring`, `eqeqeq`, strict equality, unused imports, and import sorting. Focus review on patterns ESLint cannot catch: code philosophy, unnecessary complexity, missing early returns, and unnecessary comments.
---
## React
**Conditional rendering:** return `null`, never `undefined` or bare `return;`
**ErrorBoundary:** wrap complex components: `ErrorBoundary.wrap(MyComponent, { noop: true })`
**useEffect cleanup:** always return cleanup when subscribing to events, timers, or resources:
```typescript
useEffect(() => {
const handler = () => { /* ... */ };
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
```
---
## Discord Types
Never use `any` for Discord objects. Import proper types:
```typescript
import { User, Channel, Guild, GuildMember, Message, Role } from "@vencord/discord-types";
```
Store types, component prop types, and action types are all available. Never use `as unknown as` casting as a workaround.
---
## CSS and Class Names
**Every plugin** must use `classNameFactory` from `@utils/css` for class names:
```typescript
import { classNameFactory } from "@utils/css";
const cl = classNameFactory("vc-my-plugin-");
cl("container") // "vc-my-plugin-container"
```
**Combining classes** from different sources: use `classes()` from `@utils/misc`:
```typescript
className={classes(cl("wrapper"), someDiscordClass, isActive && cl("active"))}
```
---
## Settings
- `settings.store.key` — reactive, auto-persists (React components)
- `settings.plain.key` — non-reactive raw value (performance-critical code)
- **Never** `settings.use()` with arrays in variables. Mutate then reassign instead.
---
## Patch Quality
Patches modify Discord's minified webpack modules. Stability is paramount.
- **One patch per concern.** Each replacement does one thing.
- **Surgical.** Match only what needs replacing, let `find` target the module.
- **No hardcoded minified vars.** Never `e`, `t`, `n`, `r`, `i`, `eD`, `eH` etc. Use `\i`.
- **Bounded gaps.** `.{0,50}` not `.+?` or `.*?` (unbounded = cross-match bugs).
- **No generic patterns.** `/className:\i/` alone is too broad. Add stable anchors.
- **No raw intl hashes.** Use `#{intl::KEY_NAME}` not `.aA4Vce`.
- **Capture groups only when reused** in replace via `$1`, `$2`.
- **`$&`** for append/prepend, **`$self`** for plugin method calls.
```typescript
// GOOD
patches: [{
find: "#{intl::PIN_MESSAGE}),icon:",
replacement: {
match: /#{intl::PIN_MESSAGE}\)/,
replace: "$self.getPinLabel(arguments[0]))"
}
}]
// BAD
patches: [{
find: "pinMessage",
replacement: {
match: /label:e\.pinned\?.+?pinMessage\)/,
replace: "$self.getPinLabel(e))"
}
}]
```
---
## Plugin Interop
```typescript
// GOOD
import { isPluginEnabled } from "@api/PluginManager";
import otherPlugin from "@equicordplugins/otherPlugin";
if (!isPluginEnabled(otherPlugin.name)) return null;
otherPlugin.someFunction();
// BAD
Vencord.Plugins.plugins["OtherPlugin"].someFunction();
(somePlugin as unknown as { method(): void }).method();
```
---
## Text and Descriptions
- Plugin and setting descriptions: capital first letter, end with period.
- Error messages and toasts: natural human text, no dashes or robotic formatting.
```typescript
// GOOD
description: "Adds a button to copy message content."
showToast("Module not found");
// BAD
description: "adds a button to copy message content"
showToast("Module - not found");
```
---
## Performance
- `Map`/`Set` for frequent lookups, not arrays
- `.find()` / `.some()` not `.filter()[0]`
- No spread `...` in loops
- `Promise.all()` for parallel async
---
## Memory Leaks and Cleanup
Plugins that don't use patches don't require a Discord restart to apply. Their `start()` and `stop()` must work cleanly so users can toggle them on/off without leaking resources.
**Every `start()` must have a matching `stop()`.** If `start()` subscribes, registers, or creates anything, `stop()` must undo it. Common leaks to flag:
- Event listeners added but never removed (FluxDispatcher, DOM events, MessageEvents)
- Intervals/timeouts set but never cleared
- MutationObservers created but never disconnected
- Context menu patches added but never removed (use declarative `contextMenus` instead)
- Chat bar buttons added but never removed (use declarative `chatBarButton` instead)
- Styles enabled but never disabled (use `managedStyle` instead)
- Flux subscriptions without corresponding unsubscriptions
**Prefer declarative APIs** that handle cleanup automatically: `flux`, `contextMenus`, `chatBarButton`, `messagePopoverButton`, `managedStyle`. These don't need manual cleanup.
**useEffect must return cleanup** when it creates subscriptions, timers, observers, or any persistent resource. No exceptions.
**Watch for stale closures and references.** If a plugin stores references to DOM nodes, channels, or users in module scope, check that they're nulled out in `stop()`.
```typescript
// GOOD — clean start/stop, no leaks
start() {
this.interval = setInterval(this.update, 5000);
document.addEventListener("visibilitychange", this.onVisibilityChange);
},
stop() {
clearInterval(this.interval);
document.removeEventListener("visibilitychange", this.onVisibilityChange);
}
// BAD — leaks interval and listener forever
start() {
setInterval(this.update, 5000);
document.addEventListener("visibilitychange", this.onVisibilityChange);
},
stop() {}
```
---
## Code Hygiene
- **Delete dead code**, don't comment it out. Git preserves history.
- **No overengineering.** No premature abstractions, no "just in case" handling, no features not requested. Three similar lines beats a one-use helper. If code can be half the size and do the same thing, it should be.
- **No bloat.** Flag unnecessary wrapper functions, redundant type annotations, excessive error handling for things that can't fail, useless try/catch around synchronous code, or 50 lines doing what 10 could do. Code should be lean.
- **No unused imports.** Every import must be referenced.
- **No logging in plugin code.** No `console.log/warn/error`, no `Logger`. Remove all logging statements.
- **No empty catch blocks.** Handle every error (show toast, return fallback, rethrow, etc.).
---
## AI Slop Detection
If a PR shows signs of being AI-generated, mention it but **still review the code fully**. Do not reject or stop reviewing just because it looks AI-generated. Note it as an observation, then continue reviewing every issue as you normally would. Common AI slop patterns:
- Excessive comments explaining obvious code ("// get the user", "// check if null", "// return the result")
- Overly verbose variable names that read like sentences (`isUserCurrentlyLoggedInToTheApplication`)
- Unnecessary abstractions and wrapper functions for trivial operations
- "Just in case" error handling everywhere, including places that can never fail
- Reimplementing utilities that already exist in the codebase (check the Built-in Utilities section)
- Generic, boilerplate-heavy code that looks like it was prompted with "write a Discord plugin that..."
- Excessive JSDoc/TSDoc on every function, even internal one-liners
- Overly defensive coding: null checks on things that are never null, type guards on known types
- Cookie-cutter patterns repeated without understanding (e.g. wrapping every single line in try/catch)
- Descriptions and messages that sound robotic or templated rather than human-written
If you suspect it, say something like: "This looks like it might be AI-generated. There's a lot of unnecessary comments, over-engineering, and reimplemented utilities." Then proceed with the full review, flagging every concrete issue as usual.
---
## Built-in Utilities Reference
If a PR reimplements any of these, flag it. They already exist:
**@utils/misc:** `classes`, `sleep`, `isObject`, `isObjectEmpty`, `parseUrl`, `pluralise`, `identity`
**@utils/guards:** `isTruthy`, `isNonNullish`
**@utils/text:** `formatDuration`, `formatDurationMs`, `humanFriendlyJoin`, `makeCodeblock`, `toInlineCode`, `escapeRegExp`
**@utils/discord:** `getCurrentChannel`, `getCurrentGuild`, `getIntlMessage`, `openPrivateChannel`, `insertTextIntoChatInputBox`, `sendMessage`, `copyWithToast`, `openUserProfile`, `fetchUserProfile`, `getUniqueUsername`, `openInviteModal`
**@utils/css:** `classNameFactory`, `classNameToSelector`
**@utils/clipboard:** `copyToClipboard`
**@utils/modal:** `openModal`, `closeModal`, `ModalRoot`, `ModalHeader`, `ModalContent`, `ModalFooter`, `ModalCloseButton`
**@utils/margins:** `Margins.top8`, `.top16`, `.bottom8`, `.bottom16` etc.
**@utils/web:** `saveFile`, `chooseFile`
**@utils/lazy:** `proxyLazy`, `makeLazy`
**@utils/lazyReact:** `LazyComponent`
**@utils/react:** `useAwaiter`, `useForceUpdater`, `useTimer`
**@api/DataStore:** `get`, `set`, `del` (IndexedDB, async)
**@api/Commands:** `sendBotMessage`, `findOption`
**@webpack/common:**
- Stores: `UserStore`, `GuildStore`, `ChannelStore`, `GuildMemberStore`, `SelectedChannelStore`, `SelectedGuildStore`, `PresenceStore`, `RelationshipStore`, `MessageStore`, `EmojiStore`, `ThemeStore`, `PermissionStore`, `VoiceStateStore`, 30+ more
- Actions: `RestAPI`, `FluxDispatcher`, `MessageActions`, `NavigationRouter`, `ChannelRouter`, `ChannelActionCreators`, `SettingsRouter`
- Utils: `Constants` (`.Endpoints`), `SnowflakeUtils`, `Parser`, `PermissionsBits`, `moment`, `lodash`, `IconUtils`, `ColorUtils`, `ImageUtils`, `DateUtils`, `UsernameUtils`, `DisplayProfileUtils`
- Components: `Tooltip`, `TextInput`, `TextArea`, `Select`, `Slider`, `Avatar`, `Menu`, `Popout`, `ScrollerThin`, `Timestamp`, `MaskedLink`, `ColorPicker`
- Toasts: `Toasts`, `showToast`
- React: `useState`, `useEffect`, `useCallback`, `useStateFromStores`
**@webpack finders:** `findByPropsLazy`, `findByCodeLazy`, `findStoreLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy`
**@components/:** `ErrorBoundary`, `Flex`, `Button`, `Paragraph`, `Heading`, `BaseText`, `Span`, `ErrorCard`, `Link`, `CodeBlock`, `FormSwitch`
---
## Review Severity
1. **CRITICAL** — `any` types, hardcoded CDN/API URLs, direct DOM manipulation, security issues, massive overengineering/bloat, suspected AI slop (note it, but still review everything)
2. **HIGH** — Missing useEffect cleanup, forbidden React APIs, hardcoded class names, logging statements (console or Logger), missing definePluginSettings, unnecessary abstractions
3. **MEDIUM** — Anti-patterns (`||` instead of `??`), utility reimplementations, commented-out code, bad description format, excessive comments
4. **LOW** — Style preferences, minor performance wins, organization

View file

@ -19,7 +19,7 @@ export default definePlugin({
patches: [
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
replacement: [
{
match: /(?<=className:(\i)\.\i,style:\i,)children:\[/,
@ -27,7 +27,7 @@ export default definePlugin({
},
// fix discord weird shrink with extra buttons
{
match: /(?<=\{ref:\i,)style:(\i)(?=,onMouseEnter:\i,onMouseDown:\i,onClick)/,
match: /(?<=\{ref:\i,)style:(\i)/,
replace: "style:{...$1,minWidth:0}"
}
]

View file

@ -54,76 +54,77 @@ export default definePlugin({
settings,
patches: [
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
replacement: {
match: /,nameplate:\i,selectedGuildId:(\i),avatarDecoration/,
replace: ",nameplate:null,selectedGuildId:$1,avatarDecoration"
},
predicate: () => settings.store.removeNameplate
},
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
replacement: [
{
match: /(?<=#{intl::MUTE}\),)className:\i\.\i,/,
replace: ""
match: /,nameplate:\i,selectedGuildId:(\i),avatarDecoration/,
replace: ",nameplate:null,selectedGuildId:$1,avatarDecoration",
predicate: () => settings.store.removeNameplate
},
{
match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/,
replace: ""
match: /hoverText:(\i),forceHover:\i,children:/g,
replace: "hoverText:$1,forceHover:!0,children:",
predicate: () => settings.store.alwaysShowUsername
},
{
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,600}#{intl::ACCOUNT_INPUT_OPTIONS}\)\}\)(?=\])/,
replace: ""
match: /avatarDecoration:(.{0,70}),size:/,
replace: "avatarDecoration:void 0,size:",
predicate: () => settings.store.removeAvatarDecoration
},
{
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,650}#{intl::ACCOUNT_OUTPUT_OPTIONS}\)\}\)(?=\])/,
replace: ""
match: /displayNameStyles:(\i),/,
replace: "displayNameStyles:void 0,",
predicate: () => settings.store.removeUsernameStyles
}
],
predicate: () => settings.store.removeAudioMenus
},
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
replacement: {
match: /hoverText:(\i),forceHover:\i,children:/g,
replace: "hoverText:$1,forceHover:!0,children:"
},
predicate: () => settings.store.alwaysShowUsername
},
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
find: '"MicrophoneButton"',
replacement: [
{
match: /:\{tooltipText:\i\};/,
replace: ":{tooltipText:void 0};"
replace: ":{tooltipText:void 0};",
predicate: () => settings.store.removeButtonTooltips
},
{
match: /(?<=#{intl::MUTE}\),)className:\i\.\i,/,
replace: "",
predicate: () => settings.store.removeAudioMenus
},
{
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,600}#{intl::ACCOUNT_INPUT_OPTIONS}\)\}\)(?=\])/,
replace: "",
predicate: () => settings.store.removeAudioMenus
},
],
},
{
find: "#{intl::f+DDY/::raw},{outputDeviceName",
replacement: [
{
match: /(?<=role:"switch",)tooltipText:\i\}/,
replace: "tooltipText:void 0}"
replace: "tooltipText:void 0}",
predicate: () => settings.store.removeButtonTooltips
},
{
match: /tooltipText:\i,tooltipPositionKey/,
replace: "tooltipText:void 0,tooltipPositionKey"
match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/,
replace: "",
predicate: () => settings.store.removeAudioMenus
},
{
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,650}#{intl::ACCOUNT_OUTPUT_OPTIONS}\)\}\)(?=\])/,
replace: "",
predicate: () => settings.store.removeAudioMenus
}
],
},
{
find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}",
replacement: {
match: /tooltipText:\i,tooltipPositionKey/,
replace: "tooltipText:void 0,tooltipPositionKey"
},
predicate: () => settings.store.removeButtonTooltips
},
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
replacement: {
match: /avatarDecoration:(.{0,70}),size:/,
replace: "avatarDecoration:void 0,size:"
},
predicate: () => settings.store.removeAvatarDecoration
},
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
replacement: {
match: /displayNameStyles:(\i),/,
replace: "displayNameStyles:void 0,"
},
predicate: () => settings.store.removeUsernameStyles
}
],
});

View file

@ -233,7 +233,7 @@ export default definePlugin({
{
find: ']="BADGES"',
replacement: {
match: /(?<=textDecorationColor:.{0,300})(?=onClick:\i,onContextMenu:\i,)/,
match: /(?="data-username-has-gradient")/,
replace: "draggable:!0,onDragStart:e=>$self.onUserDragStart(e,arguments[0].author),\"data-dragify-user\":!0,\"data-user-id\":arguments[0].author.id,"
}
},

View file

@ -100,7 +100,7 @@ function BooIndicator() {
return (
<>
{settings.store.showIndicator && getGhostedChannels().length && (
{settings.store.showIndicator && getGhostedChannels().length > 0 && (
<div id={cl("container")}>
<Tooltip text={getTooltipText()} position="right">
{({ onMouseEnter, onMouseLeave }) => (

View file

@ -10,7 +10,8 @@
background-color: var(--background-primary);
height: 30px;
width: 48px;
margin-left: 8px;
margin-left: auto;
margin-right: auto;
color: var(--text-default);
fill: var(--text-default);
}
@ -153,4 +154,4 @@
.vc-boo-modal-text {
color: var(--text-muted);
}
}

View file

@ -1,129 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/index";
import definePlugin, { OptionType } from "@utils/types";
const MIDDLE_CLICK = 1;
let lastMiddleClickUp = 0;
const settings = definePluginSettings({
scope: {
type: OptionType.SELECT,
description: "Situations in which to prevent middle click from pasting.",
options: [
{
label: "Always Prevent Middle Click Pasting",
value: "always",
default: true
},
{
label: "Only Prevent When Text Area Not Focused",
value: "focus"
},
]
},
threshold: {
type: OptionType.NUMBER,
description: "Milliseconds until pasting is enabled again after a middle click.",
default: 100,
onChange(newValue) { if (newValue < 1) { settings.store.threshold = 1; } },
},
preventLinkOpen: {
type: OptionType.BOOLEAN,
description: "Prevent middle-click on links from opening new tabs while preserving autoscroll.",
default: false,
onChange(newValue) {
if (newValue) {
document.addEventListener("auxclick", handleAuxClick, true);
} else {
document.removeEventListener("auxclick", handleAuxClick, true);
}
},
},
});
function shouldBlockLink(e: MouseEvent): boolean {
if (e.button !== MIDDLE_CLICK) return false;
const target = e.target as HTMLElement | null;
if (!target?.closest) return false;
const a = target.closest("a[href]") as HTMLAnchorElement | null;
if (!a) return false;
const href = a.getAttribute("href");
return !!href && href !== "#";
}
function handleAuxClick(e: MouseEvent) {
if (!shouldBlockLink(e)) return;
e.preventDefault();
e.stopPropagation();
}
export default definePlugin({
name: "LimitMiddleClickPaste",
description: "Prevent middle click pasting either always or just when a text area is not focused.",
authors: [EquicordDevs.Etorix, EquicordDevs.korzi],
settings,
isPastingDisabled(isInput: boolean) {
const pasteBlocked = Date.now() - lastMiddleClickUp < Math.max(settings.store.threshold, 1);
const { scope } = settings.store;
if (!pasteBlocked) return false;
if (scope === "always") return true;
if (scope === "focus" && !isInput) return true;
return false;
},
onMouseUp: (e: MouseEvent) => {
if (e.button === MIDDLE_CLICK) lastMiddleClickUp = Date.now();
},
start() {
document.addEventListener("mouseup", this.onMouseUp);
if (settings.store.preventLinkOpen) {
document.addEventListener("auxclick", handleAuxClick, true);
}
},
stop() {
document.removeEventListener("mouseup", this.onMouseUp);
document.removeEventListener("auxclick", handleAuxClick, true);
},
patches: [
{
// Detects paste events triggered by the "browser" outside of input fields.
find: "document.addEventListener(\"paste\",",
replacement: {
match: /(?<=paste",(\i)=>{)/,
replace: "if($1.target.tagName===\"BUTTON\"||$self.isPastingDisabled(false)){$1.preventDefault?.();$1.stopPropagation?.();return;};"
}
},
{
// Detects paste events triggered inside of Discord's text input.
find: ",origin:\"clipboard\"});",
replacement: {
match: /(?<=handlePaste=(\i)=>{)(?=let)/,
replace: "if($self.isPastingDisabled(true)){$1.preventDefault?.();$1.stopPropagation?.();return;}"
}
},
{
// Detects paste events triggered inside of Discord's search box.
find: "props.handlePastedText&&",
replacement: {
match: /(?<=clipboardData\);)/,
replace: "if($self.isPastingDisabled(true)){arguments[1].preventDefault?.();arguments[1].stopPropagation?.();return;};"
}
},
],
});

View file

@ -8,16 +8,17 @@ import "./style.css";
import { DecoratorProps } from "@api/MemberListDecorators";
import { isPluginEnabled } from "@api/PluginManager";
import { definePluginSettings } from "@api/Settings";
import betterActivities from "@equicordplugins/betterActivities";
import showMeYourName from "@plugins/showMeYourName";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc";
import definePlugin from "@utils/types";
import definePlugin, { OptionType } from "@utils/types";
import { Activity, ApplicationStream, Channel, Message, OnlineStatus, User } from "@vencord/discord-types";
import { MessageFlags } from "@vencord/discord-types/enums";
import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findCssClassesLazy, findExportedComponentLazy } from "@webpack";
import { ChannelStore, ExperimentStore, MessageStore, Parser, RelationshipStore, SnowflakeUtils, UserStore, useStateFromStores } from "@webpack/common";
import { ChannelStore, ExperimentStore, MessageStore, Parser, RelationshipStore, SnowflakeUtils, UserGuildSettingsStore, UserStore, useStateFromStores } from "@webpack/common";
const cl = classNameFactory("vc-message-peek-");
@ -30,6 +31,14 @@ const ActivityText: React.ComponentType<ActivityTextProps> = findComponentByCode
const ONE_HOUR_MS = 60 * 60 * 1000;
const settings = definePluginSettings({
hideMuted: {
type: OptionType.BOOLEAN,
description: "Hide message previews and timestamps for muted DMs and group chats.",
default: false
}
});
type AttachmentType = "image" | "gif" | "video" | "file";
type IconType = AttachmentType | "voice" | "sticker";
@ -150,7 +159,7 @@ function MessagePreviewContent({ channel, user }: { channel: Channel; user: User
return <>Official Discord Message</>;
}
const smynName = isPluginEnabled(showMeYourName.name) ? showMeYourName.getMemberListProfilesReactionsVoiceNameText({ user: user ?? lastMessage?.author, type: "membersList" }) : null;
const smynName = isPluginEnabled(showMeYourName.name) ? showMeYourName.getTypingMemberListProfilesReactionsVoiceNameText({ user: user ?? lastMessage?.author, type: "membersList" }) : null;
if (!lastMessage) {
if (channel.isMultiUserDM()) return <>{channel.recipients.length + 1} Members</>;
@ -231,6 +240,7 @@ export default definePlugin({
name: "MessagePeek",
description: "Shows the last message preview and timestamp in the Direct Messages list.",
authors: [Devs.prism, EquicordDevs.justjxke],
settings,
patches: [
{
find: "PrivateChannel.renderAvatar",
@ -252,11 +262,28 @@ export default definePlugin({
renderMemberListDecorator({ channel }: DecoratorProps) {
if (!channel) return null;
if (settings.store.hideMuted && UserGuildSettingsStore.isChannelMuted(null!, channel.id)) return null;
return <Timestamp channel={channel} />;
},
getSubText(props: PrivateChannelProps) {
const { channel, user, activities, status, applicationStream, voiceChannel } = props;
if (settings.store.hideMuted && UserGuildSettingsStore.isChannelMuted(null!, channel.id)) {
const hasActivity = hasRelevantActivity({ activities, status, applicationStream, voiceChannel });
if (hasActivity) {
return (
<ActivityText
user={user}
activities={activities}
voiceChannel={voiceChannel}
applicationStream={applicationStream}
/>
);
}
return null;
}
const lastMessage = MessageStore.getLastMessage(channel.id) as Message | undefined;
const hasActivity = hasRelevantActivity({ activities, status, applicationStream, voiceChannel });
const showActivity = shouldShowActivity(lastMessage, hasActivity);

View file

@ -0,0 +1,150 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings, SettingsStore } from "@api/Settings";
import { EquicordDevs } from "@utils/index";
import definePlugin, { OptionType } from "@utils/types";
const MIDDLE_CLICK = 1;
let lastMiddleClickUp = 0;
function updateListeners(refresh: boolean = true) {
document.removeEventListener("mouseup", handleMouseUp, true);
document.removeEventListener("auxclick", handleAuxClick, true);
if (refresh) {
document.addEventListener("mouseup", handleMouseUp, true);
document.addEventListener("auxclick", handleAuxClick, true);
}
}
function handleAuxClick(event: MouseEvent) {
if (event.button !== MIDDLE_CLICK) return false;
const { openScope } = settings.store;
const target = event.target as HTMLElement | null;
const anchor = target?.closest("a[href]") as HTMLAnchorElement | null;
const media = target?.closest("a[href][data-role='img'], a[href][data-role='video']") as HTMLAnchorElement | null;
const role = anchor?.dataset.role ?? "";
const isMedia = !!media;
const isLink = !isMedia && !!anchor?.href && anchor.getAttribute("href") !== "#" && !["img", "video", "button"].includes(role);
if (isLink && ["links", "both"].includes(openScope)) {
event.preventDefault();
event.stopPropagation();
} else if (isMedia && ["media", "both"].includes(openScope)) {
event.preventDefault();
event.stopPropagation();
}
}
function handleMouseUp(event: MouseEvent) {
if (event.button === MIDDLE_CLICK) lastMiddleClickUp = Date.now();
}
const settings = definePluginSettings({
openScope: {
type: OptionType.SELECT,
description: "Prevent middle clicking on these content types from opening them.",
options: [
{ label: "Links", value: "links" },
{ label: "Media", value: "media" },
{ label: "Links & Media", value: "both" },
{ label: "None", value: "none", default: true },
],
onChange(newValue) { updateListeners(newValue !== "none"); }
},
pasteScope: {
type: OptionType.SELECT,
description: "Prevent middle click from pasting during these situations.",
options: [
{ label: "Always Prevent Middle Click Pasting", value: "always", default: true },
{ label: "Only Prevent When Text Area Not Focused", value: "focus" },
]
},
pasteThreshold: {
type: OptionType.NUMBER,
description: "Milliseconds until pasting is enabled again after a middle click.",
default: 100,
onChange(newValue) { if (newValue < 1) { settings.store.pasteThreshold = 1; } }
}
});
function migrate() {
const { plugins } = SettingsStore.plain;
const oldPlugin = plugins?.LimitMiddleClickPaste;
if (!oldPlugin) return;
const newPlugin = plugins.MiddleClickTweaks ??= { enabled: false };
const { scope, threshold, preventLinkOpen } = oldPlugin;
if (scope) newPlugin.pasteScope = scope;
if (threshold) newPlugin.pasteThreshold = threshold;
if (preventLinkOpen) newPlugin.openScope = "both";
if (oldPlugin.enabled) newPlugin.enabled = true;
delete plugins.LimitMiddleClickPaste;
SettingsStore.markAsChanged();
}
migrate();
export default definePlugin({
name: "MiddleClickTweaks",
description: "Various middle click tweaks, such as with pasting and link opening.",
authors: [EquicordDevs.Etorix, EquicordDevs.korzi],
settings,
tags: ["LimitMiddleClickPaste"],
isPastingDisabled(isInput: boolean) {
const pasteBlocked = Date.now() - lastMiddleClickUp < Math.max(settings.store.pasteThreshold, 1);
const { pasteScope } = settings.store;
if (!pasteBlocked) return false;
if (pasteScope === "always") return true;
if (pasteScope === "focus" && !isInput) return true;
return false;
},
start() {
migrate();
updateListeners();
},
stop() { updateListeners(false); },
patches: [
{
// Detects paste events triggered by the "browser" outside of input fields.
find: "document.addEventListener(\"paste\",",
replacement: {
match: /(?<=paste",(\i)=>{)/,
replace: "if($1.target.tagName===\"BUTTON\"||$self.isPastingDisabled(false)){$1.preventDefault?.();$1.stopPropagation?.();return;};"
}
},
{
// Detects paste events triggered inside of Discord's text input.
find: ",origin:\"clipboard\"});",
replacement: {
match: /(?<=handlePaste=(\i)=>{)(?=let)/,
replace: "if($self.isPastingDisabled(true)){$1.preventDefault?.();$1.stopPropagation?.();return;}"
}
},
{
// Detects paste events triggered inside of Discord's search box.
find: "props.handlePastedText&&",
replacement: {
match: /(?<=clipboardData\);)/,
replace: "if($self.isPastingDisabled(true)){arguments[1].preventDefault?.();arguments[1].stopPropagation?.();return;};"
}
},
],
});

View file

@ -52,10 +52,10 @@ export default definePlugin({
patches: [
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
replacement: {
// react.jsx)(AccountPanel, { ..., showTaglessAccountPanel: blah })
match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,hidePrivateData:)/,
match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,(occluded|hidePrivateData):)/,
// react.jsx(WrapperComponent, { VencordOriginal: AccountPanel, ...
replace: "$self.PanelWrapper,{VencordOriginal:$1,"
},

View file

@ -58,6 +58,10 @@ type ProfileCollectible = {
};
type ImageInput = string | { imageUri: string; } | null | undefined;
type DisplayNameStylesLike = DisplayNameStyles & {
fontId?: number;
effectId?: number;
};
function dispatch(type: string, payload: Record<string, unknown>) {
FluxDispatcher.dispatch({ type, ...payload });
@ -73,6 +77,22 @@ function hasImageInput(value: ImageInput): boolean {
return typeof value === "object" && "imageUri" in value && isNonEmptyString(value.imageUri);
}
function normalizeDisplayNameStyles(value: DisplayNameStylesLike | null | undefined): DisplayNameStylesLike | null {
if (!value) return null;
const fontId = value.fontId ?? value.font_id;
const effectId = value.effectId ?? value.effect_id;
if (typeof fontId !== "number" || typeof effectId !== "number") return null;
const colors = Array.isArray(value.colors) ? [...value.colors] : [];
return {
fontId,
effectId,
font_id: fontId,
effect_id: effectId,
colors
};
}
export async function imageUrlToBase64(url: string): Promise<string | null> {
try {
const response = await fetch(url);
@ -208,12 +228,11 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
type: nameplateToUse.type || 2
} : null;
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? userAny.displayNameStyles;
const displayNameStyles = displayNameStylesToUse ? {
font_id: displayNameStylesToUse.font_id ?? 0,
effect_id: displayNameStylesToUse.effect_id ?? 0,
colors: Array.isArray(displayNameStylesToUse.colors) ? displayNameStylesToUse.colors : []
} : null;
const savedDisplayNameStyles = isGuildProfile
? (guildMember?.displayNameStyles ?? userAny.displayNameStyles)
: userAny.displayNameStyles;
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? savedDisplayNameStyles;
const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse as DisplayNameStylesLike | null | undefined);
const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar;
const avatarToUse: ImageInput = hasImageInput(pendingAvatar)
@ -245,7 +264,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
globalName: isGuildProfile
? (pendingChanges.pendingNickname ?? guildMember?.nick ?? null)
: (pendingChanges.pendingGlobalName ?? currentUser.globalName ?? null),
pronouns: pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null,
pronouns: isGuildProfile ? null : (pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null),
avatarDecoration,
profileEffect,
nameplate,
@ -278,11 +297,16 @@ export async function loadPresetAsPending(preset: ProfilePreset, guildId?: strin
const current = await getCurrentProfile(guildId, {
isGuildProfile: isGuild
});
const profilePrefix = "USER_SETTINGS_ACCOUNT";
const profilePrefix = isGuild ? "GUILD_IDENTITY_SETTINGS" : "USER_SETTINGS_ACCOUNT";
const dispatchProfile = (type: string, payload: Record<string, unknown>) => {
dispatch(type, isGuild ? { ...payload, guildId } : payload);
};
const setPending = (type: string, payload: Record<string, unknown>) => dispatchProfile(type, payload);
const setPending = (type: string, payload: Record<string, unknown>) => {
dispatchProfile(type, payload);
if (isGuild && type.startsWith("GUILD_IDENTITY_SETTINGS_")) {
dispatchProfile(type.replace("GUILD_IDENTITY_SETTINGS_", "USER_SETTINGS_ACCOUNT_"), payload);
}
};
if ("avatarDataUrl" in preset) {
const avatarValue = preset.avatarDataUrl;
@ -300,14 +324,14 @@ export async function loadPresetAsPending(preset: ProfilePreset, guildId?: strin
setPending(`${profilePrefix}_SET_PENDING_BIO`, { bio: bioValue });
}
if (!options.skipPronouns && "pronouns" in preset && preset.pronouns !== current.pronouns) {
if (!isGuild && !options.skipPronouns && "pronouns" in preset && preset.pronouns !== current.pronouns) {
const pronounsValue = preset.pronouns === null ? "" : preset.pronouns;
setPending(`${profilePrefix}_SET_PENDING_PRONOUNS`, { pronouns: pronounsValue });
}
if (!options.skipGlobalName && preset.globalName !== undefined && preset.globalName !== current.globalName) {
if (isGuild) {
setPending("USER_SETTINGS_ACCOUNT_SET_PENDING_NICKNAME", { nickname: preset.globalName });
setPending("GUILD_IDENTITY_SETTINGS_SET_PENDING_NICKNAME", { nickname: preset.globalName });
} else {
setPending("USER_SETTINGS_ACCOUNT_SET_PENDING_GLOBAL_NAME", { globalName: preset.globalName });
}
@ -331,8 +355,11 @@ export async function loadPresetAsPending(preset: ProfilePreset, guildId?: strin
});
}
if (preset.displayNameStyles !== undefined && !jsonEq(preset.displayNameStyles, current.displayNameStyles)) {
setPending(`${profilePrefix}_SET_PENDING_DISPLAY_NAME_STYLES`, { displayNameStyles: preset.displayNameStyles });
if (preset.displayNameStyles !== undefined) {
const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles as DisplayNameStylesLike | null | undefined);
if (!jsonEq(presetDisplayNameStyles, current.displayNameStyles)) {
setPending(`${profilePrefix}_SET_PENDING_DISPLAY_NAME_STYLES`, { displayNameStyles: presetDisplayNameStyles });
}
}
if (preset.themeColors !== undefined && !jsonEq(preset.themeColors, current.themeColors)) {
@ -360,4 +387,4 @@ export async function loadPresetAsPending(preset: ProfilePreset, guildId?: strin
} catch (err) {
throw err;
}
}
}

View file

@ -1007,6 +1007,8 @@ function getQuestAcceptedButtonText(quest: Quest, prepositional: boolean = false
}
function getQuestPanelPercentComplete({ quest, percentCompleteText }: { quest: Quest; percentCompleteText?: string; }): { percentComplete: number; } | { percentComplete: number; percentCompleteText: string; } | null {
if (!quest) { return null; }
const task = getQuestTask(quest);
if (!task) { return null; }
@ -1294,8 +1296,8 @@ export default definePlugin({
// Fixes the progress tracking for auto-completing Quests.
find: ",{progressTextAnimation:",
replacement: {
match: /(?<=children:\i}=)(\i)/,
replace: "Object.assign({},$1,$1.quest?$self.getQuestPanelPercentComplete($1):{})"
match: /(let{percentComplete:.{0,115}?children:\i}=)(\i)/,
replace: "const questifyProgress=$self.getQuestPanelPercentComplete({...$2,quest:$2.children?.props?.quest});$1Object.assign({},$2,questifyProgress??{})"
}
},
{

View file

@ -46,7 +46,8 @@ export function fixUpQuote(quote: string): string {
export function generateFileNamePreview(message: string): string {
const words = message.split(" ");
return words.length > 6 ? words.slice(0, 6).join(" ") : words.join(" ");
const preview = words.length > 6 ? words.slice(0, 6).join(" ") : words.join(" ");
return preview.slice(0, 10);
}
export function getFileExtension(saveAsGif: boolean): string {
@ -153,12 +154,31 @@ function calculateTextLines(
let currentLine: string[] = [];
words.forEach(word => {
const testLine = [...currentLine, word].join(" ");
if (ctx.measureText(testLine).width > maxWidth && currentLine.length) {
lines.push(currentLine.join(" "));
currentLine = [word];
if (ctx.measureText(word).width > maxWidth) {
if (currentLine.length) {
lines.push(currentLine.join(" "));
currentLine = [];
}
let chunk = "";
for (const char of word) {
const testChunk = chunk + char;
if (ctx.measureText(testChunk).width > maxWidth) {
if (chunk) lines.push(chunk);
chunk = char;
} else {
chunk = testChunk;
}
}
if (chunk) lines.push(chunk);
} else {
currentLine.push(word);
const testLine = [...currentLine, word].join(" ");
if (ctx.measureText(testLine).width > maxWidth && currentLine.length) {
lines.push(currentLine.join(" "));
currentLine = [word];
} else {
currentLine.push(word);
}
}
});

View file

@ -45,6 +45,11 @@ const settings = definePluginSettings({
description: "Comma or space separated list of friends' user IDs you want to recieve join messages from",
default: ""
},
ignoredFriends: {
type: OptionType.STRING,
description: "Comma or space separated list of friends' user IDs you do NOT want to recieve join messages from",
default: ""
},
ignoreBlockedUsers: {
type: OptionType.BOOLEAN,
description: "Do not send messages about blocked users joining/leaving/moving voice channels",
@ -89,9 +94,11 @@ function sendVoiceStatusMessage(channelId: string, content: string, userId: stri
function isFriendAllowlisted(friendId: string) {
if (!RelationshipStore.isFriend(friendId)) return false;
const list = settings.store.allowedFriends.split(",").join(" ").split(" ").filter(i => i.length > 0);
if (list.join(" ").length < 1) return true;
return list.includes(friendId);
const ignoreList = settings.store.ignoredFriends.split(",").join(" ").split(" ").filter(i => i.length > 0);
if (ignoreList.includes(friendId)) return false;
const allowList = settings.store.allowedFriends.split(",").join(" ").split(" ").filter(i => i.length > 0);
if (allowList.join(" ").length < 1) return true;
return allowList.includes(friendId);
}
// Blatantly stolen from VcNarrator plugin

View file

@ -75,7 +75,7 @@ export default definePlugin({
patches: [
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
group: true,
replacement: [
{
@ -87,7 +87,7 @@ export default definePlugin({
replace: "$&$self.onPopoutClose();"
},
{
match: /(?:#{intl::5fWB8U::raw}|#{intl::SET_STATUS})\)(?<=ref:(\i),style:.+?)/,
match: /ref:(\i),style:\i(?=.{0,250}#{intl::5fWB8U::raw})/,
replace: "$&,onContextMenu:($self.grabRef($1),$self.openAccountPanelContextMenu)"
}
]

View file

@ -127,7 +127,7 @@ export default definePlugin({
predicate: () => settings.store.disableFade
},
{ // Load menu TOC eagerly
find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}",
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
replacement: {
match: /(?=handleOpenSettingsContextMenu=.{0,100}?null!=\i&&.{0,100}?(await [^};]*?\)\)))/,
replace: "_vencordBetterSettingsEagerLoad=(async ()=>$1)();"

View file

@ -87,7 +87,7 @@ export default definePlugin({
},
// Current user area, at bottom of channels/dm list
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
replacement: [
// Use Decor avatar decoration hook
{

View file

@ -91,7 +91,7 @@ export default definePlugin({
{
find: "useCanFavoriteChannel",
replacement: {
match: /(?<=\i\.id\)\),\i=)\i\.isThread\(\)/,
match: /(?<=isFavorite\(\i\.id\).{0,5})\i\.isThread\(\)/,
replace: "false",
}
},

View file

@ -461,10 +461,10 @@ export default definePlugin({
},
},
{
find: 'className:"channelMention",children:',
find: '"channel_mention"});',
replacement: {
// Show inside voice channel instead of trying to join them when clicking on a channel mention
match: /(?<=getChannel\(\i\);null!=(\i))(?=.{0,100}?selectVoiceChannel)/,
match: /(?<=getChannel\(\i\);if\(null!=(\i))(?=.{0,150}?selectVoiceChannel)/,
replace: (_, channel) => `&&!$self.isHiddenChannel(${channel})`
}
},

View file

@ -13,6 +13,7 @@ import { definePluginSettings } from "@api/Settings";
import { Button, TextButton } from "@components/Button";
import ErrorBoundary from "@components/ErrorBoundary";
import { Heading } from "@components/Heading";
import ircColors from "@plugins/ircColors";
import mentionAvatars from "@plugins/mentionAvatars";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/index";
@ -28,7 +29,6 @@ const wrapEmojis = findByCodeLazy("lastIndex;return");
const adjustColor = findByCodeLazy("light1", "dark1", "toonStroke");
const AccessibilityStore = findStoreLazy("AccessibilityStore");
const colorPattern = /^#(?:[\da-f]{3}){1,2}$|^#(?:[\da-f]{4}){1,2}$|(rgb|hsl)a?\((\s*-?\d+%?\s*,){2}(\s*-?\d+%?\s*)\)|(rgb|hsl)a?\((\s*-?\d+%?\s*,){3}\s*(0|(0?\.\d+)|1)\)$/iu;
const roleColorPattern = /^role((?:\+|-)\d{0,4})?$/iu;
const symbolPattern = /^[\p{S}\p{P}]{1,3}$/iu;
const templatePattern = /(?:\{(?:custom|friend|nick|display|user)(?:,\s*(?:custom|friend|nick|display|user))*\})/iu;
@ -36,29 +36,59 @@ const templatePattern = /(?:\{(?:custom|friend|nick|display|user)(?:,\s*(?:custo
type CustomNicknameData = Record<string, string>;
let customNicknames: CustomNicknameData = {};
function adjustHex(color: string, percent: number): string {
let hex = color.replace("#", "");
let toCSSCache: Map<string, string | null> | null = null;
let toCSSProbe: HTMLDivElement | null = null;
if (hex.length === 3) {
hex = hex.split("").map(c => c + c).join("");
}
function toCSS(color: string | number | null | undefined): string | null {
if (color == null) return null;
if (typeof color === "number") return `#${color.toString(16).padStart(6, "0")}`;
if (!color) return null;
const num = parseInt(hex, 16);
let r = (num >> 16) & 0xFF;
let g = (num >> 8) & 0xFF;
let b = num & 0xFF;
const cached = toCSSCache?.get(color);
if (cached !== undefined) return cached ?? null;
if (!toCSSProbe) return null;
toCSSProbe.style.color = "";
toCSSProbe.style.color = color;
const result = toCSSProbe.style.color !== "" ? color : null;
toCSSCache?.set(color, result);
return result;
}
let convertToRGBCanvas: HTMLCanvasElement | null = null;
let convertToRGBCtx: CanvasRenderingContext2D | null = null;
let convertToRGBCache: Map<string, [number, number, number] | null> | null = null;
function convertToRGB(color: string): [number, number, number] | null {
const cached = convertToRGBCache?.get(color);
if (cached !== undefined) return cached ?? null;
if (!convertToRGBCanvas || !convertToRGBCtx) return null;
convertToRGBCtx.fillStyle = "#000000";
convertToRGBCtx.clearRect(0, 0, 1, 1);
convertToRGBCtx.fillStyle = color;
convertToRGBCtx.fillRect(0, 0, 1, 1);
const [r, g, b] = convertToRGBCtx.getImageData(0, 0, 1, 1).data;
const result: [number, number, number] = [r, g, b];
convertToRGBCache?.set(color, result);
return result;
}
function adjustBrightness(color: string, percent: number): string {
const rgb = convertToRGB(color);
if (!rgb) return color;
let [r, g, b] = rgb;
r = Math.max(0, Math.min(255, r + Math.round(r * (percent / 100))));
g = Math.max(0, Math.min(255, g + Math.round(g * (percent / 100))));
b = Math.max(0, Math.min(255, b + Math.round(b * (percent / 100))));
const newColor = ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0");
const hex = `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
if (hex === "#ffffff" || hex === "#000000") return color;
if (newColor === "ffffff" || newColor === "000000") {
return color;
}
return `#${newColor}`;
return hex;
}
function validColor(color: string) {
@ -73,66 +103,43 @@ function validColor(color: string) {
return !isNaN(percentage) && percentage <= 100 && percentage >= -100;
}
const colorTestDiv = document.createElement("div");
colorTestDiv.style.borderColor = color;
const isValid = colorTestDiv.style.borderColor !== "" && colorPattern.test(color);
colorTestDiv.remove();
return isValid;
return !!toCSS(color);
}
function resolveColor(
user: User | GuildMember,
colorStrings: colorStringsType,
displayNameStyles: { effectId: number; colors: number[]; } | null | undefined,
savedColor: string,
canUseGradient: boolean,
inGuild: boolean,
forceWhite: boolean
forceDefault: boolean
): Record<string, any> | null {
if (!savedColor.trim()) { return null; }
let gradient: any = null;
let primaryColor: any = savedColor;
let secondaryColor: any = savedColor;
let tertiaryColor: any = savedColor;
let primaryAdjusted: any = savedColor;
let secondaryAdjusted: any = savedColor;
let tertiaryAdjusted: any = savedColor;
let primaryColor: any = null;
let secondaryColor: any = null;
let tertiaryColor: any = null;
let primaryAdjusted: any = null;
let secondaryAdjusted: any = null;
let tertiaryAdjusted: any = null;
if (savedColor.toLowerCase().includes("role")) {
const percentage = roleColorPattern.exec(savedColor)?.[1] || "";
if (percentage && isNaN(parseInt(percentage))) return null;
const colorStrings = (user as any)?.colorStrings || {};
const displayStyles = !inGuild ? (user as any)?.displayNameStyles || {} : {};
primaryColor = forceDefault ? toCSS("var(--text-strong)") : (toCSS(colorStrings?.primaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[0])) || toCSS("var(--text-strong)"));
secondaryColor = forceDefault ? null : (toCSS(colorStrings?.secondaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[1])) || null);
tertiaryColor = forceDefault ? null : (toCSS(colorStrings?.tertiaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[2])) || null);
primaryColor = forceWhite ? "#ffffff" : colorStrings.primaryColor || displayStyles?.colors?.[0] || "#ffffff";
if (typeof primaryColor === "number") primaryColor = `#${primaryColor.toString(16).padStart(6, "0")}`;
secondaryColor = forceWhite ? null : colorStrings.secondaryColor || displayStyles?.colors?.[1] || null;
if (typeof secondaryColor === "number") secondaryColor = `#${secondaryColor.toString(16).padStart(6, "0")}`;
tertiaryColor = forceWhite ? null : colorStrings.tertiaryColor || displayStyles?.colors?.[2] || null;
if (typeof tertiaryColor === "number") tertiaryColor = `#${tertiaryColor.toString(16).padStart(6, "0")}`;
primaryAdjusted = primaryColor;
secondaryAdjusted = secondaryColor;
tertiaryAdjusted = tertiaryColor;
if (primaryColor && percentage) {
primaryAdjusted = adjustHex(primaryColor, parseInt(percentage));
}
if (secondaryColor && percentage) {
secondaryAdjusted = adjustHex(secondaryColor, parseInt(percentage));
}
if (tertiaryColor && percentage) {
tertiaryAdjusted = adjustHex(tertiaryColor, parseInt(percentage));
}
primaryAdjusted = percentage ? adjustBrightness(primaryColor, parseInt(percentage)) : primaryColor;
secondaryAdjusted = secondaryColor && percentage ? adjustBrightness(secondaryColor, parseInt(percentage)) : secondaryColor;
tertiaryAdjusted = tertiaryColor && percentage ? adjustBrightness(tertiaryColor, parseInt(percentage)) : tertiaryColor;
} else {
primaryColor = savedColor;
}
gradient = !canUseGradient || !secondaryColor || forceWhite
gradient = !canUseGradient || !secondaryColor || forceDefault
? null
: tertiaryColor
? "linear-gradient(to right,var(--custom-gradient-color-1),var(--custom-gradient-color-2),var(--custom-gradient-color-3),var(--custom-gradient-color-1))"
@ -164,7 +171,7 @@ function resolveColor(
"--custom-gradient-color-2": secondaryColor || primaryColor,
"--custom-gradient-color-3": tertiaryColor || primaryColor,
"background-image": gradient,
"animation": "smyn-animation 1.5s linear infinite"
"animation": "smyn-animation var(--smyn-gradient-duration) linear infinite"
},
static: {
original: {
@ -300,6 +307,8 @@ interface mentionProps {
interface messageProps {
message: Message | null | undefined;
colorString?: string;
colorStrings: colorStringsType;
userOverride?: User;
isRepliedMessage?: boolean;
withMentionPrefix?: boolean;
@ -307,12 +316,14 @@ interface messageProps {
interface memberListProfileReactionProps {
user: User | null | undefined;
type: "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel";
type: "typingIndicator" | "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel";
guildId?: string;
tags?: any;
}
function getMemberListProfilesReactionsVoiceName(
type colorStringsType = { primaryColor: string | null, secondaryColor: string | null, tertiaryColor: string | null; } | null | undefined;
function getTypingMemberListProfilesReactionsVoiceName(
props: memberListProfileReactionProps,
): [string | null, JSX.Element | null, string | null] {
const { user, type } = props;
@ -321,16 +332,16 @@ function getMemberListProfilesReactionsVoiceName(
const guildId = props.guildId || props.tags?.props?.displayProfile?.guildId || null;
const member = guildId && user ? GuildMemberStore.getMember(guildId, user.id) : null;
const author = user && member ? { ...user, ...member } : user || member || null;
const shouldHookless = type === "reactionsTooltip" || type === "profilesTooltip";
const shouldHookless = ["typingIndicator", "reactionsTooltip", "profilesTooltip"].includes(type);
return renderUsername(author, null, null, type, "", shouldHookless, !!guildId);
}
function getMemberListProfilesReactionsVoiceNameText(props: memberListProfileReactionProps): string | null {
return getMemberListProfilesReactionsVoiceName(props)[2];
function getTypingMemberListProfilesReactionsVoiceNameText(props: memberListProfileReactionProps): string | null {
return getTypingMemberListProfilesReactionsVoiceName(props)[2];
}
function getMemberListProfilesReactionsVoiceNameElement(props: memberListProfileReactionProps): JSX.Element | null {
return getMemberListProfilesReactionsVoiceName(props)[1];
function getTypingMemberListProfilesReactionsVoiceNameElement(props: memberListProfileReactionProps): JSX.Element | null {
return getTypingMemberListProfilesReactionsVoiceName(props)[1];
}
function getMessageName(props: messageProps): [string | null, JSX.Element | null, string | null] {
@ -343,7 +354,7 @@ function getMessageName(props: messageProps): [string | null, JSX.Element | null
const member = isWebhook ? null : target && channel ? GuildMemberStore.getMember(channel.guild_id, target.id) : null;
const author = user && member ? { ...user, ...member } : user || member || null;
const mentionSymbol = hideDefaultAtSign && (!isRepliedMessage || replies) ? "" : withMentionPrefix ? "@" : "";
return renderUsername(author, channel?.id || null, message?.id || null, isRepliedMessage ? "replies" : "messages", mentionSymbol, false, !!channel?.guild_id);
return renderUsername(author, channel?.id || null, message?.id || null, isRepliedMessage ? "replies" : "messages", mentionSymbol, false, !!channel?.guild_id, props.colorString, props.colorStrings);
}
function getMessageNameElement(props: messageProps): JSX.Element | null {
@ -362,7 +373,20 @@ function getMentionNameElement(props: mentionProps): JSX.Element | null {
const member = channel ? GuildMemberStore.getMember(channel.guild_id, userId) : null;
const author = user && member ? { ...user, ...member } : user || member || null;
const mentionSymbol = hideDefaultAtSign && mentions ? "" : "@";
return renderUsername(author, channelId || null, nestedProps?.messageId || null, "mentions", mentionSymbol, false, !!channel?.guild_id)[1];
let colorString: string | undefined = undefined;
let colorStrings: colorStringsType = undefined;
if (isPluginEnabled(ircColors.name)) {
const color = ircColors.calculateNameColorForMessageContext({ message: { author: { id: userId } }, channel });
if (color) {
colorString = color;
colorStrings = { primaryColor: color, secondaryColor: null, tertiaryColor: null };
}
}
return renderUsername(author, channelId || null, nestedProps?.messageId || null, "mentions", mentionSymbol, false, !!channel?.guild_id, colorString, colorStrings)[1];
}
function getEffectType(effectId: number | null | undefined): string | null {
@ -405,14 +429,17 @@ function renderUsername(
author: User | GuildMember | null,
channelId: string | null,
messageId: string | null,
type: "messages" | "replies" | "mentions" | "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel",
type: "messages" | "replies" | "typingIndicator" | "mentions" | "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel",
mentionSymbol: string,
hookless: boolean,
inGuild: boolean
inGuild: boolean,
colorString?: string,
colorStrings?: { primaryColor: string | null, secondaryColor: string | null, tertiaryColor: string | null; } | null
): [string | null, JSX.Element | null, string | null] {
const isMessage = type === "messages";
const isReply = type === "replies";
const isMention = type === "mentions";
const isTyping = type === "typingIndicator";
const isMember = type === "membersList";
const isProfile = type === "profilesPopout";
const isReactionsPopout = type === "reactionsPopout";
@ -420,8 +447,8 @@ function renderUsername(
const isReaction = isReactionsTooltip || isReactionsPopout;
const isVoice = type === "voiceChannel";
const config = hookless ? settings.store : settings.use(["messages", "replies", "mentions", "memberList", "profilePopout", "reactions", "friendNameOnlyInDirectMessages", "customNameOnlyInDirectMessages", "discriminators", "hideDefaultAtSign", "truncateAllNamesWithStreamerMode", "removeDuplicates", "ignoreGradients", "ignoreFonts", "animateGradients", "includedNames", "customNameColor", "friendNameColor", "nicknameColor", "displayNameColor", "usernameColor", "nameSeparator", "triggerNameRerender"]);
const { messages, replies, mentions, memberList, profilePopout, reactions, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages, discriminators, truncateAllNamesWithStreamerMode, removeDuplicates, ignoreGradients, ignoreFonts, animateGradients, includedNames, customNameColor, friendNameColor, nicknameColor, displayNameColor, usernameColor, nameSeparator, triggerNameRerender } = config;
const config = hookless ? settings.store : settings.use(["messages", "replies", "mentions", "typingIndicator", "memberList", "profilePopout", "reactions", "friendNameOnlyInDirectMessages", "customNameOnlyInDirectMessages", "discriminators", "hideDefaultAtSign", "truncateAllNamesWithStreamerMode", "removeDuplicates", "ignoreGradients", "ignoreFonts", "animateGradients", "includedNames", "customNameColor", "friendNameColor", "nicknameColor", "displayNameColor", "usernameColor", "nameSeparator", "triggerNameRerender"]);
const { messages, replies, mentions, typingIndicator, memberList, profilePopout, reactions, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages, discriminators, truncateAllNamesWithStreamerMode, removeDuplicates, ignoreGradients, ignoreFonts, animateGradients, includedNames, customNameColor, friendNameColor, nicknameColor, displayNameColor, usernameColor, nameSeparator, triggerNameRerender } = config;
const channel = channelId ? ChannelStore.getChannel(channelId) || null : null;
const message = channelId && messageId ? MessageStore.getMessage(channelId, messageId) : null;
@ -435,27 +462,38 @@ function renderUsername(
? hoveringReactionPopoutSet.has((author as User).id)
: false;
const authorDisplayNameStyles = (!inGuild && (author as any)?.displayNameStyles) || null;
if (colorString && !colorStrings) {
colorStrings = {
primaryColor: colorString,
secondaryColor: null,
tertiaryColor: null
};
}
const ircColorsEnabled = isPluginEnabled(ircColors.name);
const authorColorStrings = colorStrings || (author as any)?.colorStrings || null;
const authorDisplayNameStyles = (!inGuild && !ircColorsEnabled && (author as any)?.displayNameStyles) || null;
const effectType = authorDisplayNameStyles ? getEffectType(authorDisplayNameStyles.effectId) : null;
const effectCSSVars = authorDisplayNameStyles ? computeEffectCSSVars(authorDisplayNameStyles) : {};
const hasEffect = !!effectType;
const needsEffectDataAttr = effectType === "neon" || effectType === "toon" || effectType === "pop";
const shouldShowEffect = hasEffect && isHovering;
const shouldAnimateEffect = shouldShowEffect && !AccessibilityStore.useReducedMotion;
const shouldUseDMDefault = !inGuild && !isHovering;
const shouldUseDMDefault = !inGuild && !ircColorsEnabled && !isHovering;
const canUseGradient = ((author as GuildMember)?.guildId ? (GuildStore.getGuild((author as GuildMember).guildId) ?? {}).premiumFeatures?.features.includes("ENHANCED_ROLE_COLORS") : !inGuild);
const useTopRoleStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
const topRoleStyle = author ? resolveColor(author, "Role", canUseGradient, inGuild, shouldUseDMDefault) : null;
const topRoleStyle = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, "Role", canUseGradient, inGuild, shouldUseDMDefault) : null;
const hasGradient = !!topRoleStyle?.gradient && Object.keys(topRoleStyle.gradient).length > 0;
const textMutedValue = getComputedStyle(document.documentElement)?.getPropertyValue("--text-muted")?.trim() || "#72767d";
const options = splitTemplate(includedNames);
const resolvedUsernameColor = author ? resolveColor(author, usernameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedDisplayNameColor = author ? resolveColor(author, displayNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedNicknameColor = author ? resolveColor(author, nicknameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedFriendNameColor = author ? resolveColor(author, friendNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedCustomNameColor = author ? resolveColor(author, customNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedUsernameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, usernameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedDisplayNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, displayNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedNicknameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, nicknameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedFriendNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, friendNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const resolvedCustomNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, customNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
const affixColor = { color: textMutedValue, "-webkit-text-fill-color": textMutedValue, isolation: "isolate", "white-space": "pre", "font-family": "var(--font-primary)", "letter-spacing": "normal" };
const { username, display, nick, friend, custom } = getProcessedNames(author, truncateAllNamesWithStreamerMode, discriminators, inGuild, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages);
@ -551,6 +589,8 @@ function renderUsername(
return [null, null, null];
} else if (isMention && !mentions) {
return [null, null, null];
} else if (isTyping && !typingIndicator) {
return [null, null, null];
} else if (isMember && !memberList) {
return [null, null, null];
} else if (isProfile && !profilePopout) {
@ -580,6 +620,7 @@ function renderUsername(
}
const remainingNames = [first, second, third, fourth, fifth].filter(Boolean);
first = remainingNames.shift();
second = remainingNames.shift();
third = remainingNames.shift();
@ -591,37 +632,40 @@ function renderUsername(
const shouldAnimateSecondaryNames = animateGradients && !ignoreGradients;
const firstDataText = mentionSymbol + first.name;
const secondDataText = second && shouldAnimateSecondaryNames ? (second.prefix + second.name + second.suffix) : "";
const thirdDataText = third && shouldAnimateSecondaryNames ? (third.prefix + third.name + third.suffix) : "";
const fourthDataText = fourth && shouldAnimateSecondaryNames ? (fourth.prefix + fourth.name + fourth.suffix) : "";
const fifthDataText = fifth && shouldAnimateSecondaryNames ? (fifth.prefix + fifth.name + fifth.suffix) : "";
const secondDataText = second && shouldAnimateSecondaryNames ? second.name : "";
const thirdDataText = third && shouldAnimateSecondaryNames ? third.name : "";
const fourthDataText = fourth && shouldAnimateSecondaryNames ? fourth.name : "";
const fifthDataText = fifth && shouldAnimateSecondaryNames ? fifth.name : "";
const allDataText = [firstDataText, secondDataText, thirdDataText, fourthDataText, fifthDataText].filter(Boolean).join(nameSeparator).trim();
// Only mentions and reactions popouts should patch in the gradient glow or else a double glow will appear on messages.
const hoveringClass = (isHovering ? " show-me-your-name-gradient-hovered" : "");
const hoveringClass = (isHovering ? " smyn-gradient-hovered" : "");
const gradientClasses = useTopRoleStyle
? "show-me-your-name-gradient show-me-your-name-gradient-inherit-bg" + hoveringClass
: "show-me-your-name-gradient show-me-your-name-gradient-unset-bg" + hoveringClass;
? "smyn-gradient smyn-gradient-inherit-bg" + hoveringClass
: "smyn-gradient smyn-gradient-unset-bg" + hoveringClass;
const firstGroupClasses = "show-me-your-name-name-group show-me-your-name-first-name-group";
const secondGroupClasses = "show-me-your-name-name-group show-me-your-name-second-name-group";
const thirdGroupClasses = "show-me-your-name-name-group show-me-your-name-third-name-group";
const fourthGroupClasses = "show-me-your-name-name-group show-me-your-name-fourth-name-group";
const fifthGroupClasses = "show-me-your-name-name-group show-me-your-name-fifth-name-group";
const firstNameClasses = "show-me-your-name-name show-me-your-name-first-name";
const secondNameClasses = "show-me-your-name-name show-me-your-name-second-name";
const thirdNameClasses = "show-me-your-name-name show-me-your-name-third-name";
const fourthNameClasses = "show-me-your-name-name show-me-your-name-fourth-name";
const fifthNameClasses = "show-me-your-name-name show-me-your-name-fifth-name";
const prefixClasses = "show-me-your-name-affix show-me-your-name-prefix";
const suffixClasses = "show-me-your-name-affix show-me-your-name-suffix";
const firstGroupClasses = "smyn-name-group smyn-first-name-group";
const secondGroupClasses = "smyn-name-group smyn-second-name-group";
const thirdGroupClasses = "smyn-name-group smyn-third-name-group";
const fourthGroupClasses = "smyn-name-group smyn-fourth-name-group";
const fifthGroupClasses = "smyn-name-group smyn-fifth-name-group";
const firstNameClasses = "smyn-name smyn-first-name";
const secondNameClasses = "smyn-name smyn-second-name";
const thirdNameClasses = "smyn-name smyn-third-name";
const fourthNameClasses = "smyn-name smyn-fourth-name";
const fifthNameClasses = "smyn-name smyn-fifth-name";
const prefixClasses = "smyn-affix smyn-prefix";
const suffixClasses = "smyn-affix smyn-suffix";
const animationDuration = Math.max(1, 1.5 * (first.name.length / 12));
const topLevelStyle = {
// Allows names to wrap in reaction popouts.
...(isReactionsPopout
? { display: "flex", flexWrap: "wrap", lineHeight: "1.1em", fontSize: "0.9em" }
: {}),
...(hasEffect ? effectCSSVars : {})
...(hasEffect ? effectCSSVars : {}),
"--smyn-gradient-duration": `${animationDuration}s`
} as React.CSSProperties;
const nameElement = (
@ -630,7 +674,7 @@ function renderUsername(
...topLevelStyle,
...(topRoleStyle?.normal.original || {})
}}
className="show-me-your-name-container"
className="smyn-container"
>
{mentionSymbol && <span>{mentionSymbol}</span>}
{(
@ -667,9 +711,7 @@ function renderUsername(
].map(({ name, dataText, groupClass, nameClass, position }) => name && (
<span
key={position}
className={SMYNC(groupClass, { [gradientClasses]: shouldGradientGlow && shouldAnimateSecondaryNames })}
data-text={shouldGradientGlow && dataText ? dataText : undefined}
style={(shouldGradientGlow && shouldAnimateSecondaryNames ? name.style.gradient.animated : undefined) as React.CSSProperties}
className={SMYNC(groupClass)}
>
<span style={affixColor as React.CSSProperties} className={prefixClasses}>
<span>{nameSeparator}</span>
@ -677,10 +719,12 @@ function renderUsername(
<span
// On non-primary names, allow disabling the effects completely, or just their animation & glow.
className={SMYNC(nameClass, {
[gradientClasses]: shouldGradientGlow && shouldAnimateSecondaryNames,
"smyn-effect-container": shouldShowEffect && !ignoreGradients,
[`smyn-effect-${effectType}`]: shouldShowEffect && !ignoreGradients,
"smyn-effect-animated": shouldAnimateEffect && shouldAnimateSecondaryNames
})}
data-text={shouldGradientGlow && dataText ? dataText : undefined}
data-username-with-effects={needsEffectDataAttr && shouldShowEffect && !ignoreGradients ? name.name : undefined}
style={{
...(ignoreFonts ? { "font-family": "var(--font-primary)", "letter-spacing": "normal" } : {}),
@ -814,7 +858,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
style={{ width: "100%" }}
/>
<TextButton
className="show-me-your-name-reset-button"
className="smyn-reset-button"
onClick={async () => {
setValue("");
delete customNicknames[user.id];
@ -826,7 +870,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
</TextButton>
<div style={{ paddingTop: "10px", flexGrow: 0 }}></div>
</ModalContent>
<ModalFooter className="show-me-your-name-modal-footer-container">
<ModalFooter className="smyn-modal-footer-container">
<Button
variant="primary"
onClick={async () => {
@ -895,10 +939,15 @@ const settings = definePluginSettings({
default: true,
description: "Display custom name format in mentions.",
},
typingIndicator: {
type: OptionType.BOOLEAN,
default: true,
description: "Display the first available name listed in your custom name format in the typing indicator.",
},
memberList: {
type: OptionType.BOOLEAN,
default: true,
description: "Display the first available name listed in your custom name format in the member list and DMs list.",
description: "Display the first available name listed in your custom name format in the members list, DMs list, and friends list.",
},
profilePopout: {
type: OptionType.BOOLEAN,
@ -973,31 +1022,31 @@ const settings = definePluginSettings({
},
customNameColor: {
type: OptionType.STRING,
description: "The color to use for the custom name you assigned a user if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the custom name you assigned a user if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
friendNameColor: {
type: OptionType.STRING,
description: "The color to use for a friend's nickname if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for a friend's nickname if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
nicknameColor: {
type: OptionType.STRING,
description: "The color to use for the nickname if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the nickname if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
displayNameColor: {
type: OptionType.STRING,
description: "The color to use for the display name if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the display name if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
usernameColor: {
type: OptionType.STRING,
description: "The color to use for the username if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the username if it's not the first displayed. Leave blank for default. Accepts any valid CSS input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
@ -1012,7 +1061,7 @@ const settings = definePluginSettings({
export default definePlugin({
name: "ShowMeYourName",
description: "Display any permutation of custom nicknames, friend nicknames, server nicknames, display names, and usernames in chat.",
authors: [Devs.Rini, Devs.TheKodeToad, EquicordDevs.Etorix, Devs.sadan, Devs.prism],
authors: [EquicordDevs.Etorix, Devs.Rini, Devs.TheKodeToad, Devs.sadan, Devs.prism],
tags: ["SMYN", "Nicknames", "Custom Nicknames",],
isModified: true,
settings,
@ -1026,8 +1075,8 @@ export default definePlugin({
replacement: [
{
// Replace names in messages and replies.
match: /(onContextMenu:\i,children:)(.{0,250}?),"data-text":(\i\+\i)/,
replace: "$1$self.getMessageNameElement(arguments[0])??($2),\"data-text\":$self.getMessageNameText(arguments[0])??($3)"
match: /(?<=colorString:(\i),colorStrings:(\i).{0,900}?)style:.{0,120}?,(onClick:\i,onContextMenu:\i,children:)(.{0,250}?),"data-text":(\i\+\i)/,
replace: "$3$self.getMessageNameElement({...arguments[0],colorString:$1,colorStrings:$2})??($4),\"data-text\":$self.getMessageNameText(arguments[0])??($5)"
},
{
// Pass the message object to the should-animate checker.
@ -1036,12 +1085,37 @@ export default definePlugin({
}
]
},
{
// Replace names in the typing indicator.
find: "activityInviteEducationActivity:",
replacement: {
match: /(?=\i.\i.getName\((\i.guild_id),\i.id,(\i)\))/,
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({user:$2,type:\"typingIndicator\",guildId:$1})??"
},
},
{
// Replace names in DMs list.
find: "ImpressionNames.DM_LIST_RIGHT_CLICK_MENU_SHOWN",
replacement: {
match: /(?<=getMentionCount\(\i.id\)>0\),\i=)/,
replace: "$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??"
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??"
},
},
{
// Replace names in the friends list.
find: "hasUniqueUsername()}),usernameClass",
replacement: {
match: /(?<=nick:)(\i)/,
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,type:\"membersList\"})??$1"
},
},
{
// Don't block name style in friends list just
// because the name is the same as the username.
find: "location:\"DiscordTag\"});",
replacement: {
match: /(?<=forceUsername:(\i),.{0,550}?displayNameStyles:)\i!==\i\?(\i.displayNameStyles):null/,
replace: "!$1?$2:null"
},
},
{
@ -1049,7 +1123,7 @@ export default definePlugin({
find: "channel.isSystemDM(),",
replacement: {
match: /(?<=}\);)(return.{0,500}?{text:)(\i,position:"bottom",children:.{0,40}?children:)(\i\?\?\i.\i.getName\(\i\))/,
replace: "const smynName=arguments[0].channel.recipients.length===1?$self.getMemberListProfilesReactionsVoiceNameText({user:$self.UserStore.getUser(arguments[0].channel.recipients[0]),type:\"profilesPopout\"})??null:null;$1smynName??$2smynName??$3"
replace: "const smynName=arguments[0].channel.recipients.length===1?$self.getTypingMemberListProfilesReactionsVoiceNameText({user:$self.UserStore.getUser(arguments[0].channel.recipients[0]),type:\"profilesPopout\"})??null:null;$1smynName??$2smynName??$3"
},
},
{
@ -1090,7 +1164,7 @@ export default definePlugin({
find: "let{colorRoleName:",
replacement: {
match: /(let{colorRoleName:\i,colorString:\i,colorStrings:\i,)name:(\i)/,
replace: "$1showMeYourNameName:$2=$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??(arguments[0].name)"
replace: "$1showMeYourNameName:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??(arguments[0].name)"
}
},
{
@ -1098,7 +1172,7 @@ export default definePlugin({
find: "shouldWrap:!0,loop:!0,inProfile:!0",
replacement: {
match: /(tags:\i,)nickname:(\i)/,
replace: "$1showMeYourNameNickname:$2=$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"profilesPopout\"})??(arguments[0].nickname)"
replace: "$1showMeYourNameNickname:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"profilesPopout\"})??(arguments[0].nickname)"
},
},
{
@ -1109,11 +1183,11 @@ export default definePlugin({
replacement: [
{
match: /(displayName:)(\i.\i.getName\(void 0,void 0,\i\))/,
replace: "$1$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:null,type:\"profilesTooltip\"})??($2)"
replace: "$1$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:null,type:\"profilesTooltip\"})??($2)"
},
{
match: /(displayName:)(\i.\i.getName\(\i,\i,\i\))/,
replace: "$1$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].guildId,type:\"profilesTooltip\"})??($2)"
replace: "$1$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].guildId,type:\"profilesTooltip\"})??($2)"
}
]
},
@ -1122,7 +1196,7 @@ export default definePlugin({
find: "reactionTooltip1,",
replacement: {
match: /(\i.\i.getName\((\i),\i\?\.id,(\i)\))/,
replace: "$self.getMemberListProfilesReactionsVoiceNameText({user:$3,guildId:$2,type:\"reactionsTooltip\"})??($1)"
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({user:$3,guildId:$2,type:\"reactionsTooltip\"})??($1)"
}
},
{
@ -1137,7 +1211,7 @@ export default definePlugin({
{
// Replace names in reaction popouts.
match: /(?<=Child,{className:\i.\i,children:)/g,
replace: "($self.getMemberListProfilesReactionsVoiceNameElement({user:arguments[0].user,guildId:arguments[0].guildId,type:\"reactionsPopout\"}))??"
replace: "($self.getTypingMemberListProfilesReactionsVoiceNameElement({user:arguments[0].user,guildId:arguments[0].guildId,type:\"reactionsPopout\"}))??"
}
]
},
@ -1146,16 +1220,33 @@ export default definePlugin({
find: ",connectUserDragSource:",
replacement: {
match: /(serverDeaf:\i,)nick:(\i)/,
replace: "$1showMeYourNameVoice:$2=$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].channel.guild_id,type:\"voiceChannel\"})??(arguments[0].nick)"
replace: "$1showMeYourNameVoice:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].channel.guild_id,type:\"voiceChannel\"})??(arguments[0].nick)"
}
}
],
async start() {
toCSSCache = new Map();
toCSSProbe = document.createElement("div");
convertToRGBCanvas = document.createElement("canvas");
convertToRGBCanvas.width = convertToRGBCanvas.height = 1;
convertToRGBCtx = convertToRGBCanvas.getContext("2d", { willReadFrequently: true });
convertToRGBCache = new Map();
const data = await DataStore.get<CustomNicknameData>("SMYNCustomNicknames");
customNicknames = data ?? {};
},
stop() {
toCSSCache?.clear();
toCSSCache = null;
toCSSProbe = null;
convertToRGBCache?.clear();
convertToRGBCache = null;
convertToRGBCanvas = null;
convertToRGBCtx = null;
},
contextMenus: {
"user-context": userContextPatch
},
@ -1191,6 +1282,6 @@ export default definePlugin({
getMessageNameText,
getMessageNameElement,
getMentionNameElement,
getMemberListProfilesReactionsVoiceNameText,
getMemberListProfilesReactionsVoiceNameElement
getTypingMemberListProfilesReactionsVoiceNameText,
getTypingMemberListProfilesReactionsVoiceNameElement
});

View file

@ -31,10 +31,12 @@
.smyn-gradient-unset-bg::after {
background: unset;
-webkit-text-fill-color: currentcolor;
}
.smyn-gradient-inherit-bg::after {
background: inherit;
-webkit-text-fill-color: transparent;
}
.smyn-gradient-hovered::after {