diff --git a/.gemini/config.yaml b/.gemini/config.yaml
index 18045fbba..5536dc86c 100644
--- a/.gemini/config.yaml
+++ b/.gemini/config.yaml
@@ -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"
diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md
index 9665cd377..baa617b84 100644
--- a/.gemini/styleguide.md
+++ b/.gemini/styleguide.md
@@ -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
diff --git a/src/equicordplugins/_api/userArea.ts b/src/equicordplugins/_api/userArea.ts
index 181edfd79..6f746ff86 100644
--- a/src/equicordplugins/_api/userArea.ts
+++ b/src/equicordplugins/_api/userArea.ts
@@ -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}"
}
]
diff --git a/src/equicordplugins/betterUserArea/index.ts b/src/equicordplugins/betterUserArea/index.ts
index fdde42738..0d48d04f3 100644
--- a/src/equicordplugins/betterUserArea/index.ts
+++ b/src/equicordplugins/betterUserArea/index.ts
@@ -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
- }
],
});
diff --git a/src/equicordplugins/dragify/index.tsx b/src/equicordplugins/dragify/index.tsx
index 47b6a5469..2338fdf54 100644
--- a/src/equicordplugins/dragify/index.tsx
+++ b/src/equicordplugins/dragify/index.tsx
@@ -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,"
}
},
diff --git a/src/equicordplugins/ghosted/index.tsx b/src/equicordplugins/ghosted/index.tsx
index 954c9b624..845a34f6b 100644
--- a/src/equicordplugins/ghosted/index.tsx
+++ b/src/equicordplugins/ghosted/index.tsx
@@ -100,7 +100,7 @@ function BooIndicator() {
return (
<>
- {settings.store.showIndicator && getGhostedChannels().length && (
+ {settings.store.showIndicator && getGhostedChannels().length > 0 && (
{({ onMouseEnter, onMouseLeave }) => (
diff --git a/src/equicordplugins/ghosted/styles.css b/src/equicordplugins/ghosted/styles.css
index fb373f8fa..bb5d05bbf 100644
--- a/src/equicordplugins/ghosted/styles.css
+++ b/src/equicordplugins/ghosted/styles.css
@@ -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);
-}
\ No newline at end of file
+}
diff --git a/src/equicordplugins/limitMiddleClickPaste/index.ts b/src/equicordplugins/limitMiddleClickPaste/index.ts
deleted file mode 100644
index 632eae2fe..000000000
--- a/src/equicordplugins/limitMiddleClickPaste/index.ts
+++ /dev/null
@@ -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;};"
- }
- },
- ],
-});
diff --git a/src/equicordplugins/messagePeek/index.tsx b/src/equicordplugins/messagePeek/index.tsx
index 9e28bca9c..312d28ac8 100644
--- a/src/equicordplugins/messagePeek/index.tsx
+++ b/src/equicordplugins/messagePeek/index.tsx
@@ -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 = 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 ;
},
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 (
+
+ );
+ }
+ return null;
+ }
+
const lastMessage = MessageStore.getLastMessage(channel.id) as Message | undefined;
const hasActivity = hasRelevantActivity({ activities, status, applicationStream, voiceChannel });
const showActivity = shouldShowActivity(lastMessage, hasActivity);
diff --git a/src/equicordplugins/middleClickTweaks/index.ts b/src/equicordplugins/middleClickTweaks/index.ts
new file mode 100644
index 000000000..da10a5d21
--- /dev/null
+++ b/src/equicordplugins/middleClickTweaks/index.ts
@@ -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;};"
+ }
+ },
+ ],
+});
diff --git a/src/equicordplugins/musicControls/index.tsx b/src/equicordplugins/musicControls/index.tsx
index 50d8c623a..a46c3073f 100644
--- a/src/equicordplugins/musicControls/index.tsx
+++ b/src/equicordplugins/musicControls/index.tsx
@@ -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,"
},
diff --git a/src/equicordplugins/profileSets/utils/profile.ts b/src/equicordplugins/profileSets/utils/profile.ts
index 2408623eb..69d4dcd84 100644
--- a/src/equicordplugins/profileSets/utils/profile.ts
+++ b/src/equicordplugins/profileSets/utils/profile.ts
@@ -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) {
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 {
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) => {
dispatch(type, isGuild ? { ...payload, guildId } : payload);
};
- const setPending = (type: string, payload: Record) => dispatchProfile(type, payload);
+ const setPending = (type: string, payload: Record) => {
+ 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;
}
-}
+}
\ No newline at end of file
diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx
index c850b285c..276ce2461 100644
--- a/src/equicordplugins/questify/index.tsx
+++ b/src/equicordplugins/questify/index.tsx
@@ -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??{})"
}
},
{
diff --git a/src/equicordplugins/quoter/utils.tsx b/src/equicordplugins/quoter/utils.tsx
index faf83dd6f..44fcf7cba 100644
--- a/src/equicordplugins/quoter/utils.tsx
+++ b/src/equicordplugins/quoter/utils.tsx
@@ -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);
+ }
}
});
diff --git a/src/equicordplugins/voiceJoinMessages/index.ts b/src/equicordplugins/voiceJoinMessages/index.ts
index ae321763b..12824ab91 100644
--- a/src/equicordplugins/voiceJoinMessages/index.ts
+++ b/src/equicordplugins/voiceJoinMessages/index.ts
@@ -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
diff --git a/src/plugins/accountPanelServerProfile/index.tsx b/src/plugins/accountPanelServerProfile/index.tsx
index 8d90ff5eb..af19dfa5d 100644
--- a/src/plugins/accountPanelServerProfile/index.tsx
+++ b/src/plugins/accountPanelServerProfile/index.tsx
@@ -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)"
}
]
diff --git a/src/plugins/betterSettings/index.tsx b/src/plugins/betterSettings/index.tsx
index 1c8e8a76b..e33178043 100644
--- a/src/plugins/betterSettings/index.tsx
+++ b/src/plugins/betterSettings/index.tsx
@@ -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)();"
diff --git a/src/plugins/decor/index.tsx b/src/plugins/decor/index.tsx
index 605d3055d..8f8998675 100644
--- a/src/plugins/decor/index.tsx
+++ b/src/plugins/decor/index.tsx
@@ -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
{
diff --git a/src/plugins/experiments/index.tsx b/src/plugins/experiments/index.tsx
index 5c865d6b5..2ebb81f2d 100644
--- a/src/plugins/experiments/index.tsx
+++ b/src/plugins/experiments/index.tsx
@@ -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",
}
},
diff --git a/src/plugins/showHiddenChannels/index.tsx b/src/plugins/showHiddenChannels/index.tsx
index eb5409e87..3cc27e81b 100644
--- a/src/plugins/showHiddenChannels/index.tsx
+++ b/src/plugins/showHiddenChannels/index.tsx
@@ -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})`
}
},
diff --git a/src/plugins/showMeYourName/index.tsx b/src/plugins/showMeYourName/index.tsx
index b3dd75307..79fcf4d96 100644
--- a/src/plugins/showMeYourName/index.tsx
+++ b/src/plugins/showMeYourName/index.tsx
@@ -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;
let customNicknames: CustomNicknameData = {};
-function adjustHex(color: string, percent: number): string {
- let hex = color.replace("#", "");
+let toCSSCache: Map | 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 | 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 | 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 && {mentionSymbol}}
{(
@@ -667,9 +711,7 @@ function renderUsername(
].map(({ name, dataText, groupClass, nameClass, position }) => name && (
{nameSeparator}
@@ -677,10 +719,12 @@ function renderUsername(
{
setValue("");
delete customNicknames[user.id];
@@ -826,7 +870,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
-
+