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