Merge branch 'dev' into page

This commit is contained in:
thororen1234 2026-02-26 11:24:28 -05:00
commit cf11b32a72
No known key found for this signature in database
91 changed files with 6202 additions and 2112 deletions

View file

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

View file

@ -1,134 +1,383 @@
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 |
| `/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

212
.rules Normal file
View file

@ -0,0 +1,212 @@
# Equicord Development Guidelines
## Critical Rules
- **NEVER** edit existing license headers. New files use 2026.
- Push back when the user idea is wrong or not correct.
- **NEVER** git checkout files without explicit permission.
- Always verify components/functions arent deprecated before using.
- **NEVER** use `any` for Discord objects. Import proper types from `@vencord/discord-types`.
- Fix with smallest possible change. No refactoring unless explicitly asked.
- Solve the problem at hand only. No patterns for patterns sake.
- **NEVER** overengineer. No premature abstractions, no helpers for one-time operations, no building for hypothetical futures. Three similar lines is better than a premature abstraction.
- Write modern, robust, professional code. Use current language features, proper error handling, and clean structure.
- 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 Rules
- **DELETE** dead code, dont comment it. Git preserves history.
- No hardcoded values, unused imports, magic numbers.
- 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"`.
- When combining multiple class names use `classes()` from `@utils/misc`, not template strings like `` `${a} ${b}` ``.
- Use utilities from `@utils/`, `@api/`, `@components/`.
- Use `Logger` from `@utils/Logger`, not `console.log`.
- Descriptions: capital first letter, end with period.
- Less code with same functionality is **ALWAYS** better. If it can be done in fewer lines without losing clarity, do it.
- Keep logic flat. Avoid deep nesting, prefer early returns, guard clauses, and simple control flow.
- **NEVER** add comments unless explicitly asked.
- Keep existing comments, they contain important context.
- Use `Map`/`Set`, `.some`/`.find`, no spread in loops, `Promise.all`.
- 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
- Wrap complex components with `ErrorBoundary.wrap(Component, { noop: true })`.
- Return `null` for conditional rendering, not `undefined`.
- **Forbidden:** `React.cloneElement`, `React.isValidElement`, `React.memo()`, `React.lazy`, `React.Children`
- Always return cleanup functions in `useEffect`.
## Forbidden
### Code
- **No raw DOM manipulation.** No `document.querySelector`, no `MutationObserver`, no `element.style`. Always use webpack patches and React.
- Empty catch blocks.
- Hardcoded minified vars in patches (`e,t,n`).
- **NEVER** use `settings.use()` with arrays. Mutate then reassign.
### Plugins
- **No selfbot or API abuse.** Nothing that automates user actions, spoofs client state, or abuses Discord's API in any way.
- No plugins that only do something trivially achievable with existing built-in plugins or Discord features.
- No CSS-only plugins that only hide or redesign UI elements.
- No plugins targeting specific third-party bots (official Discord apps are fine).
- No untrusted third-party APIs. No plugins requiring users to supply their own API keys.
- Do not introduce new dependencies unless strictly necessary and well justified.
## Anti-Patterns
- `value !== null && value !== undefined` ⟹ `value`
- `array && array.length > 0` ⟹ `array.length`
- `settings?.store?.value` ⟹ `settings.store.value`
- `value || defaultValue` ⟹ `value ?? defaultValue`
## Patching
### Core Principles
- Minimum code touched. Stability over cleverness. One patch per concern.
- Try multiple approaches, keep simplifying until you find the cleanest solution.
- Remove any regex character that doesnt change the match result.
- Not overengineered, not overlooked, just clean and future-proof.
### Find Strings
- Use `#{intl::KEY}` in find when possible, most stable anchor.
- If find matches multiple modules, add nearby stable string like `"),icon:"` or function name.
- `"#{intl::PIN_MESSAGE}),icon:"` is better than generic strings.
### Match Patterns
- Match only what you need to replace, let find do the targeting.
- Can use `#{intl::KEY}` in match too, gets canonicalized to hash.
- `/#{intl::KEY}\)/` beats `/label:\i\.pinned\?.{0,60}#{intl::KEY}\)/`
- `$&` keeps original, append/prepend to it.
- Use bounded gaps: `.{0,50}` not `.+?` or `.*?`
- Only use capture groups if reusing in replace.
### NEVER Do
- **Hardcoded minified vars:** `e,t,n,r,i,o,s,l,c,u,$_,xx,eD,eH,eW` — use `\i` instead
- **Minified chains:** `\i\.\i` alone — surround with stable strings
- **Unbounded gaps:** `.+?` or `.*?` — use `.{0,N}`
- **Generic patterns:** `/className:\i/` alone — add anchor
- **Raw intl hashes:** `.aA4Vce` — use `#{intl::KEY_NAME}`
### Patch Examples
**Clean replacement:**
```js
find: "#{intl::PIN_MESSAGE}),icon:"
match: /#{intl::PIN_MESSAGE}\)/
replace: "$self.getPinLabel(arguments[0]))"
```
**Appending:**
```js
find: "#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}"
match: /#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}.{0,100}(?=])/
replace: "$&,$self.renderTooltip(arguments[0].guild)"
```
**Wrapping:**
```js
find: "#{intl::THREE_USERS_TYPING}"
match: /(?<="aria-atomic":!0,children:)\i/
replace: "$self.renderTypingUsers({ children: $& })"
```
## Plugin Interop
- `isPluginEnabled(name)` from `@api/PluginManager` takes a string, checks required/isDependency/enabled.
- Import the plugin directly from its path to access `.name` and functions.
- Dont use `Vencord.Plugins.plugins` or `plugin.started`. Dont use `"as unknown as"` casting.
```ts
import { isPluginEnabled } from "@api/PluginManager";
import otherPlugin from "@equicordplugins/otherPlugin";
if (!isPluginEnabled(otherPlugin.name)) return null;
otherPlugin.someFunction();
```
## 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
- **@utils/clipboard** — `copyToClipboard`
- **@utils/discord** — `insertTextIntoChatInputBox`, `getCurrentChannel`, `getCurrentGuild`, `getIntlMessage`, `openUserProfile`, `openPrivateChannel`, `sendMessage`, `copyWithToast`, `getUniqueUsername`, `fetchUserProfile`
- **@utils/css** — `classNameFactory`, `classNameToSelector`
- **@utils/misc** — `classes`, `sleep`, `isObject`, `isObjectEmpty`, `pluralise`, `parseUrl`, `identity`
- **@utils/text** — `formatDuration`, `formatDurationMs`, `humanFriendlyJoin`, `makeCodeblock`, `toInlineCode`, `escapeRegExp`
- **@utils/modal** — `openModal`, `closeModal`, `ModalRoot`, `ModalHeader`, `ModalContent`, `ModalCloseButton`
- **@utils/margins** — `Margins` (`Margins.top8`, `Margins.bottom16`, etc.)
- **@utils/guards** — `isTruthy`, `isNonNullish`
- **@utils/web** — `saveFile`, `chooseFile`
### Webpack Common
#### IconUtils
From `@webpack/common`. **NEVER** hardcode `cdn.discordapp.com` URLs.
- `IconUtils.getUserAvatarURL(user, canAnimate?, size?)`
- `IconUtils.getUserBannerURL({ id, banner, canAnimate?, size })`
- `IconUtils.getGuildIconURL({ id, icon, size?, canAnimate? })`
- `IconUtils.getGuildBannerURL(guild, canAnimate?)`
- `IconUtils.getChannelIconURL({ id, icon })`
- `IconUtils.getEmojiURL({ id, animated, size })`
- `IconUtils.getApplicationIconURL(data)`
- `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`, `URLUtils`, `Humanize`, `EmojiUtils` 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** from `@webpack` — `findByPropsLazy`, `findStoreLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy`
- **Common** from `@webpack/common` — `useState`, `useEffect`, `useCallback`, `useStateFromStores`
### Never Hardcode
- `"cdn.discordapp.com/avatars/"` etc. ⟹ `IconUtils`
- `"/api/v9"`, `"/users/@me"` ⟹ `Constants.Endpoints` or `RestAPI`
- `console.log/warn/error` ⟹ `Logger` from `@utils/Logger`
- `` `${a} ${b}` `` for class names ⟹ `classes(a, b)` from `@utils/misc`

1
AGENTS.md Symbolic link
View file

@ -0,0 +1 @@
.rules

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
.rules

1
GEMINI.md Symbolic link
View file

@ -0,0 +1 @@
.rules

View file

@ -139,6 +139,5 @@ window.VencordNative = {
setUpdateState: NOOP,
onCheckUpdates: NOOP,
onRepair: NOOP,
onAbout: NOOP,
},
};

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "equicord",
"private": "true",
"version": "1.14.2.0",
"version": "1.14.3.0",
"description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": {
@ -122,4 +122,4 @@
"engines": {
"node": ">=18"
}
}
}

View file

@ -14,15 +14,15 @@ export interface PermissionOverwrite {
export interface AvatarDecorationData {
asset: string;
expires_at: string | null;
sku_id: string;
skuId: string;
}
/** Clan identity information. */
export interface ClanData {
badge: string;
identity_enabled: boolean;
identity_guild_id: string;
tag: string;
identityEnabled: boolean;
identityGuildId: string;
}
/** Display name style configuration. */
@ -38,7 +38,8 @@ export interface CollectibleItem {
expires_at: string | null;
label: string;
palette: string;
sku_id: string;
type: number;
skuId: string;
}
/** User collectibles configuration. */

View file

@ -39,7 +39,7 @@ export class User extends DiscordRecord {
get avatarDecoration(): AvatarDecorationData | null;
get createdAt(): Date;
get isProvisional(): boolean;
get nameplate(): { asset: string; sku_id: string; } | null;
get nameplate(): { asset: string; skuId: string; } | null;
get premiumGroupRole(): { id: string; name: string; color: number; } | null;
get tag(): string;

View file

@ -23,10 +23,10 @@ export interface EmbedMedia {
height: number;
width: number;
url: string;
proxyURL: string;
proxyURL?: string;
placeholder: string;
placeholderVersion: number;
description: string;
description?: string;
srcIsAnimated: boolean;
flags: number;
contentType: string;

View file

@ -411,6 +411,24 @@ export type MaskedLink = ComponentType<PropsWithChildren<{
channelId?: string;
}>>;
interface ScrollToOptions {
animate?: boolean;
callback?: (() => unknown);
}
/** Full type can be found at {@link https://github.com/fedeericodl/discord-client-types/blob/main/src/discord_common/js/packages/design/components/Scroller/utils/core/getAnimatedScrollHelpers.ts} */
export interface ScrollerBaseRef {
scrollTo: (props: { to: number; } & ScrollToOptions) => void;
scrollPageUp: (props?: ScrollToOptions) => void;
scrollPageDown: (props?: ScrollToOptions) => void;
scrollToTop: (props?: ScrollToOptions) => void;
scrollToBottom: (props?: ScrollToOptions) => void;
isScrolledToTop: () => boolean;
isScrolledToBottom: () => boolean;
getDistanceFromTop: () => number;
getDistanceFromBottom: () => number;
}
export interface ScrollerBaseProps {
className?: string;
style?: CSSProperties;
@ -418,6 +436,7 @@ export interface ScrollerBaseProps {
paddingFix?: boolean;
onClose?(): void;
onScroll?(): void;
ref?: Ref<ScrollerBaseRef>;
}
export type ScrollerThin = ComponentType<PropsWithChildren<ScrollerBaseProps & {
@ -448,9 +467,9 @@ export type ListScrollerThin = ComponentType<ScrollerBaseProps & {
renderSidebar?: (listVisible: boolean, sidebarVisible: boolean) => React.ReactNode;
wrapSection?: (section: number, children: React.ReactNode) => React.ReactNode;
sectionHeight: number;
rowHeight: number;
footerHeight?: number;
sectionHeight: number | ((section: number) => number);
rowHeight: number | ((section: number, row: number) => number);
footerHeight?: number | ((section: number) => number);
sidebarHeight?: number;
chunkSize?: number;

View file

@ -0,0 +1,26 @@
import { Channel, FluxStore, Message } from "..";
/** Full pending reply state object */
interface PendingReply {
/** The channel in which the pending reply was created */
channel: Channel;
/** The message that you're replying to */
message: Message;
/** Whether to ping the person you're replying to */
shouldMention: boolean;
/** Whether to show the ping \@ON/\@OFF toggle. Disabled by default for self-replies. */
showMentionToggle: boolean;
}
/** Shallow state object which is persisted between restarts. Based on {@link PendingReply}. */
interface PendingReplyState {
channelId: string;
messageId: string;
shouldMention: boolean;
showMentionToggle: boolean;
}
export class PendingReplyStore extends FluxStore {
getState(): Record<string, PendingReplyState>;
getPendingReply(channelId: string): PendingReply | undefined;
}

View file

@ -25,6 +25,7 @@ export * from "./MediaEngineStore";
export * from "./MessageStore";
export * from "./NotificationSettingsStore";
export * from "./OverridePremiumTypeStore";
export * from "./PendingReplyStore";
export * from "./PermissionStore";
export * from "./PopoutWindowStore";
export * from "./PresenceStore";

View file

@ -25,14 +25,29 @@ export type Parser = Record<
| "parse"
| "parseTopic"
| "parseEmbedTitle"
| "parseEmbedTitleWithoutLinks"
| "parseInlineReply"
| "parseGuildVerificationFormRule"
| "parseGuildEventDescription"
| "parseAutoModerationSystemMessage"
| "parseForumPostGuidelines"
| "parseForumPostMostRecentMessage",
| "parseVoiceChannelStatus",
(content: string, inline?: boolean, state?: Record<string, any>) => ReactNode[]
> & Record<"defaultRules" | "guildEventRules", Record<string, Record<"react" | "html" | "parse" | "match" | "order", any>>>;
> & Record<
| "parseToAST"
| "parseTopicToAST"
| "parseEmbedTitleToAST"
| "parseEmbedTitleWithoutLinksToAST"
| "parseInlineReplyToAST"
| "parseAutoModerationSystemMessageToAST",
(content: string, inline?: boolean, state?: Record<string, any>) => any[]
> & Record<"defaultRules" | "guildEventRules" | "notifCenterV2MessagePreviewRules" | "lockscreenWidgetMessageRules", Record<string, Record<"react" | "html" | "parse" | "match" | "order", any>>> & {
reactParserFor(rules: Record<string, any>): (content: string, inline?: boolean, state?: Record<string, any>) => ReactNode[];
astParserFor(rules: Record<string, any>): (content: string, inline?: boolean, state?: Record<string, any>) => any[];
createReactRules(options: Record<string, any>): Record<string, any>;
defaultReactRuleOptions: Record<string, any>;
combineAndInjectMentionRule(rules: Record<string, any>, mentionRule: Record<string, any>): Record<string, any>;
};
export interface Alerts {
show(alert: {
@ -48,16 +63,39 @@ export interface Alerts {
onConfirmSecondary?(): void;
onCloseCallback?(): void;
}): void;
confirm(alert: {
title: any;
body: React.ReactNode;
className?: string;
confirmColor?: string;
cancelText?: string;
confirmText?: string;
secondaryConfirmText?: string;
onCancel?(): void;
onConfirmSecondary?(): void;
onCloseCallback?(): void;
}): Promise<boolean>;
/** This is a noop, it does nothing. */
close(): void;
}
export interface SnowflakeUtils {
fromTimestamp(timestamp: number): string;
fromTimestampWithSequence(timestamp: number, sequence: number): string;
extractTimestamp(snowflake: string): number;
age(snowflake: string): number;
atPreviousMillisecond(snowflake: string): string;
atNextMillisecond(snowflake: string): string;
compare(snowflake1?: string, snowflake2?: string): number;
isProbablyAValidSnowflake(snowflake: string): boolean;
castChannelIdAsMessageId(channelId: string): string;
castMessageIdAsChannelId(messageId: string): string;
castGuildIdAsEveryoneGuildRoleId(guildId: string): string;
cast(snowflake: string): string;
keys(obj: Record<string, any>): string[];
forEach(obj: Record<string, any>, callback: (value: any, key: string) => void): void;
forEachKey(obj: Record<string, any>, callback: (key: string) => void): void;
entries(obj: Record<string, any>): [string, any][];
}
interface RestRequestData {
@ -249,45 +287,55 @@ export interface UsernameUtils {
useName(user: User): string;
getUserTag(user: User, options?: UserNameUtilsTagOptions): string;
useUserTag(user: User, options?: UserNameUtilsTagOptions): string;
isNameConcealed(user: User): boolean;
getUserIsStaff(user: User): boolean;
useDirectMessageRecipient: any;
humanizeStatus: any;
}
// TODO: fix type
export class DisplayProfile {
userId: string;
guildId?: string;
banner?: string;
bio?: string;
pronouns?: string;
accentColor?: number;
themeColors?: number[];
profileEffect?: any;
popoutAnimationParticleType?: any;
profileEffectId?: string;
fetchStartedAt?: Date | null;
fetchEndedAt?: Date | null;
_userProfile?: any;
_guildMemberProfile?: any;
canUsePremiumProfileCustomization: boolean;
canEditThemes: boolean;
premiumGuildSince: Date | null;
premiumSince: Date | null;
premiumType?: PremiumType;
primaryColor?: number;
get premiumSince(): Date | null;
get premiumGuildSince(): Date | null;
get premiumType(): PremiumType | undefined;
get private(): boolean;
get widgets(): any[] | undefined;
get gameWidgets(): any[] | undefined;
get primaryColor(): number | undefined;
get canUsePremiumProfileCustomization(): boolean;
get canEditThemes(): boolean;
get application(): any;
get isLoaded(): boolean;
hasThemeColors(): boolean;
hasPremiumCustomization(): boolean;
isUsingGuildMemberBanner(): boolean;
isUsingGuildMemberBio(): boolean;
isUsingGuildMemberPronouns(): boolean;
getBannerURL(options: { canAnimate: boolean; size: number; }): string;
getPreviewBanner(banner: string | null, canAnimate: boolean, size?: number): string | null;
getPreviewBio(bio: string | undefined): string | undefined;
getPreviewThemeColors(themeColors: number[] | undefined): number[] | undefined;
getBadges(): Array<{
id: string;
description: string;
icon: string;
link?: string;
}>;
getBannerURL(options: { canAnimate: boolean; size: number; }): string;
getLegacyUsername(): string | null;
hasFullProfile(): boolean;
hasPremiumCustomization(): boolean;
hasThemeColors(): boolean;
isUsingGuildMemberBanner(): boolean;
isUsingGuildMemberBio(): boolean;
isUsingGuildMemberPronouns(): boolean;
}
export interface DisplayProfileUtils {
@ -317,3 +365,51 @@ export interface CommandOptions {
max_value?: number;
autocomplete?: boolean;
}
export interface URLUtils {
URL_REGEX: RegExp;
makeUrl(url: string): URL | null;
isDiscordUrl(url: string): boolean;
isDiscordCdnUrl(url: string): boolean;
isDiscordAssetUrl(url: string, proxyUrl?: string, originalUrl?: string): boolean;
isDiscordDirectAssetUrl(url: string): boolean;
isDiscordProxiedAssetUrl(url: string, proxyUrl?: string, originalUrl?: string): boolean;
isDiscordHostname(hostname: string): boolean;
isDiscordLocalhost(hostname: string): boolean;
isDiscordProtocol(url: string): boolean;
isDiscordUri(url: string): boolean;
isDiscordUrlOrUri(url: string): boolean;
isAllowedGifProviderUrl(url: string): boolean;
isAppRoute(url: string): boolean;
isOriginalContentTypeDifferent(url: string, proxyUrl: string): boolean;
toURLSafe(url: string): URL | null;
safeDecodeURIComponent(component: string): string;
safeParseWithQuery(url: string): URL | null;
format(url: URL): string;
formatPathWithQuery(path: string, query: Record<string, string>): string;
formatSearch(query: Record<string, string>): string;
}
export interface Humanize {
filesize(bytes: number, base?: number, decimals?: number, decimalSep?: string, thousandsSep?: string, suffix?: string): string;
intword(number: number, units?: string[], base?: number, decimals?: number, decimalSep?: string, thousandsSep?: string): string;
relativeTime(timestamp?: number): string;
naturalDay(timestamp?: number, format?: string): string;
ordinal(value: number): string;
numberFormat(number: number, decimals?: number, decimalSep?: string, thousandsSep?: string): string;
truncatechars(str: string, maxLength: number): string;
truncatewords(str: string, maxWords: number): string;
pad(str: string, length: number, padChar?: string, direction?: "left" | "right"): string;
date(format: string, timestamp?: Date | number): string;
time(): number;
linebreaks(str: string): string;
nl2br(str: string): string;
}
export interface EmojiUtils {
getEmojiColors(emoji: any): number[] | null;
getURL(emoji: any): string;
filterUnsupportedEmojis(emojis: any[]): any[];
triggerFullscreenAnimation(emoji: any, node: HTMLElement): void;
applyPlatformToThemedEmojiColorPalette(colors: any): any;
}

View file

@ -102,7 +102,6 @@ export default {
setUpdateState: (available: boolean) => ipcRenderer.send(IpcEvents.SET_TRAY_UPDATE_STATE, available),
onCheckUpdates: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_CHECK_UPDATES, cb); },
onRepair: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_REPAIR, cb); },
onAbout: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_ABOUT, cb); },
},
pluginHelpers: PluginHelpers

View file

@ -39,7 +39,7 @@ async function runReporter() {
await loadLazyChunksDone;
if (IS_REPORTER && IS_WEB && !IS_VESKTOP) {
if (IS_REPORTER && IS_WEB && !IS_VESKTOP && !IS_EQUIBOP) {
console.log("[REPORTER_META]", {
buildNumber: getBuildNumber(),
buildHash: window.GLOBAL_ENV.SENTRY_TAGS.buildId

View file

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

View file

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

View file

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

View file

@ -97,15 +97,21 @@ const settings = definePluginSettings({
accountStandingButton: {
type: OptionType.BOOLEAN,
description: "Show an account standing button in the header bar",
default: false,
restartNeeded: true,
}
default: false,
},
restoreFileDownloadButton: {
type: OptionType.BOOLEAN,
description: "Adds back the Download button at the top right corner of files",
restartNeeded: true,
default: false
},
});
export default definePlugin({
name: "EquicordHelper",
description: "Used to provide support, fix discord caused crashes, and other misc features.",
authors: [Devs.thororen, EquicordDevs.nyx, EquicordDevs.Naibuu, EquicordDevs.keircn, EquicordDevs.SerStars, EquicordDevs.mart],
authors: [Devs.thororen, EquicordDevs.nyx, EquicordDevs.Naibuu, EquicordDevs.keircn, EquicordDevs.SerStars, EquicordDevs.mart, EquicordDevs.omaw],
required: true,
settings,
headerBarButton: {
@ -164,6 +170,7 @@ export default definePlugin({
replace: "true||$&"
},
},
// Show your own activity buttons because discord removes them for who knows why
{
find: ".USER_PROFILE_ACTIVITY_BUTTONS),",
predicate: () => settings.store.showYourOwnActivityButtons && !isPluginEnabled(customRPC.name),
@ -200,6 +207,15 @@ export default definePlugin({
replace: "1"
}
},
// Restore File Download Button
{
predicate: () => settings.store.restoreFileDownloadButton,
find: '"VISUAL_PLACEHOLDER":',
replacement: {
match: /(\.downloadUrl,showDownload:)\i/,
replace: "$1!0"
}
},
],
renderMessageAccessory(props) {
return (

View file

@ -0,0 +1,294 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import { LazyComponentWrapper } from "@utils/lazyReact";
import { Channel, Message, MessageAttachment, ScrollerBaseRef } from "@vencord/discord-types";
import { ChannelType } from "@vencord/discord-types/enums";
import { findByCodeLazy, findByPropsLazy, findComponentByCode, findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack";
import { ChannelStore, ExpressionPickerStore, ListScrollerThin, lodash, PermissionsBits, PermissionStore, React, useCallback, useEffect, useMemo, useRef, useState, useStateFromStores } from "@webpack/common";
import { ReactNode } from "react";
import { AttachmentContext, EmbedContext, EmbedMosaicContext } from ".";
import { SignedUrlsStore } from "./stores";
import { AttachmentItem, AttachmentsComponentProps, CustomItemFormat, FavoriteButtonProps, FavouriteItemFormat, FilePickerItemProps, FilePickerProps, ManaSearchBarProps, MessageComponentClass } from "./types";
import { cl, defs, hasPermission, ImageUtils, sendAttachment, useFavourites, useListScroller, useResizeObserver } from "./utils";
const ManaSearchBar = findComponentByCodeLazy<ManaSearchBarProps>("#{intl::SEARCH}),ref");
const FavoriteButton = findComponentByCodeLazy<FavoriteButtonProps>("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}");
const SendIcon = findComponentByCodeLazy("M6.6 10.02 14 11.4a.6.6");
const DmChannel: Channel & { new(base?: Partial<Channel>): Channel; } = findByPropsLazy("fromServer", "sortRecipients");
const MessageClass: Message & { new(base?: Partial<Message>): Message; } = findByCodeLazy(";mentionChannels;mentionGames;");
const Classes = findCssClassesLazy("gifFavoriteButton", "ctaButtonContainer");
export const AttachmentPreview = proxyLazyWebpack(() => {
// findComponentByCodeLazy doesn't work properly with component classes, this must be kept within the lazy scope
const MessageComponent = findComponentByCode("this.renderAttachments") as LazyComponentWrapper<MessageComponentClass>;
class MessageAttachmentsComponent extends MessageComponent {
render(): ReactNode {
return this.renderAttachments(this.props.message);
}
}
const channel = Object.freeze(new DmChannel({ id: "0", type: ChannelType.GUILD_TEXT }));
return function AttachmentPreview({ attachment }: AttachmentsComponentProps) {
const message = useMemo(
() => new MessageClass({ attachments: [attachment], channel_id: channel.id }),
[attachment]
);
return (
<MessageAttachmentsComponent
channel={channel}
message={message}
canDeleteAttachments={false}
shouldHideMediaOptions={false}
inlineAttachmentMedia
/>
);
};
});
export function FilePicker({ onSelectItem }: FilePickerProps) {
const listRef = useRef<ScrollerBaseRef>(null);
const { channelId, query } = ExpressionPickerStore.useExpressionPickerStore(store => ({
channelId: store.activeChannelId as string,
query: store.searchQuery
}));
const channel = useStateFromStores([ChannelStore], () => ChannelStore.getChannel(channelId), [channelId]);
const favs = useFavourites(CustomItemFormat.ATTACHMENT, query);
const count = useMemo(() => (favs ? Object.keys(favs).length : 0), [favs]);
const [rowHeights, handleResize] = useListScroller();
const handleSubmit = useCallback((url: string) => onSelectItem({ url }), []);
const renderRow = (row: number) => {
const item = favs?.[row];
if (!item) return null;
return (
<FilePickerItem
key={item.url}
url={item.url}
file={item.data}
channel={channel}
reducePadding={row !== count - 1}
onResize={handleResize}
onSubmit={handleSubmit}
/>
);
};
useEffect(() => void listRef.current?.scrollToTop(), [query]);
return (
<div id="files-picker-tab-panel" role="tabpanel" aria-labelledby="files-picker-tab" className={cl("container")}>
<div className={cl("container-header")}>
<ManaSearchBar
autoFocus
placeholder="Search files"
query={query}
onChange={query => ExpressionPickerStore.setSearchQuery(query)}
onClear={() => ExpressionPickerStore.setSearchQuery("")}
/>
</div>
{count > 0 ? (
<div className={cl("container-body")}>
<ListScrollerThin
ref={listRef}
sections={[count]}
sectionHeight={0}
rowHeight={(_, row) => (favs?.[row] && rowHeights.get(favs[row].url)) ?? 100}
renderSection={() => null}
renderRow={({ row }) => renderRow(row)}
/>
</div>
) : (
<div className={cl("container-body", "container-info")} inert>
{query.trim() ? <EmptyList /> : <Demo />}
</div>
)}
</div>
);
}
function EmptyList() {
return <BaseText className={cl("info-text")}>No files match your search.</BaseText>;
}
const demoAttachment: MessageAttachment = {
id: "1",
filename: "file",
content_type: "application/octet-stream",
size: 123 * 1024,
spoiler: false,
url: "",
proxy_url: ""
};
function Demo() {
return (
<>
<div className={cl("attachment-container", "demo", "first")}>
<AttachmentPreview attachment={demoAttachment} />
<FavoriteButton
className={cl("demo-favourite-button")}
url="https://example.org"
src="https://example.org"
width={100}
height={100}
format={FavouriteItemFormat.NONE}
/>
</div>
<BaseText className={cl("info-text")}>
Click the star to favourite a file.
<br />
Favourite files will show up here!
</BaseText>
</>
);
}
export function FilePickerItem({ url, file, channel, onResize, onSubmit, reducePadding }: FilePickerItemProps) {
const [isFetching, setIsFetching] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useResizeObserver(ref, ({ height }) => onResize(url, height), [onResize, url]);
const attachment = useStateFromStores(
[SignedUrlsStore],
() => ({ ...file, url: SignedUrlsStore.get(file.url), proxy_url: SignedUrlsStore.get(file.proxy_url) }),
[file],
lodash.isEqual
) as MessageAttachment;
const { canAttachFiles, canSendMessages } = useStateFromStores(
[PermissionStore],
() => ({
canAttachFiles: hasPermission(PermissionsBits.ATTACH_FILES, channel),
canSendMessages: hasPermission(PermissionsBits.SEND_MESSAGES, channel)
}),
[channel]
);
const handleClick = useMemo(() => {
switch (true) {
case canAttachFiles:
return async () => {
setIsFetching(true);
await sendAttachment(attachment, channel!);
ExpressionPickerStore.closeExpressionPicker();
setIsFetching(false);
};
case canSendMessages:
return () => onSubmit(url);
default:
return null;
}
}, [attachment, canAttachFiles, canSendMessages, channel, url]);
return (
<div ref={ref} className={cl("attachment-container", reducePadding && "reduced-padding")}>
<AttachmentPreview attachment={attachment} />
{handleClick && (
<Button onClick={handleClick} variant="secondary" disabled={isFetching}>
<SendIcon size="refresh_sm" color="currentColor" />
</Button>
)}
</div>
);
}
export function EmbedAccessory() {
const embed = React.useContext(EmbedContext);
const mosaicIndex = React.useContext(EmbedMosaicContext);
const props: FavoriteButtonProps | null = useMemo(() => {
if (!embed || embed.type === "gifv") return null;
const { video, image, images, thumbnail } = embed;
if (video) {
// This field is missing on videos by third party providers (TikTok, YouTube ...)
const isProxiedVideo = !!video.proxyURL;
// External videos don't have a video.proxyURL property that could be used for the preview - use the static thumbnail instead
const src = video.proxyURL ?? thumbnail?.proxyURL ?? video.url;
const format = isProxiedVideo ? FavouriteItemFormat.VIDEO : FavouriteItemFormat.IMAGE;
// External videos' content.url usually doesn't point to a valid resource that could be embedded
const url = !isProxiedVideo ? embed.url! : video.url;
return { ...video, format, src, url };
}
const img = (mosaicIndex != null && images?.[mosaicIndex]) || image;
if (!img) return null;
const src = img.proxyURL ?? img.url;
// Do not render the custom embed accessory if the original image already has a gif accessory
const isAnimated = ImageUtils.isAnimated({ ...img, original: img.url, src, animated: false });
if (isAnimated) return null;
return { ...img, format: FavouriteItemFormat.IMAGE, src };
}, [embed, mosaicIndex]);
return (
props && (
<div className={cl("image-accessory")}>
<FavoriteButton {...props} className={Classes.gifFavoriteButton} />
</div>
)
);
}
const visualMediaFormats: Partial<Record<AttachmentItem["type"], FavouriteItemFormat>> = Object.freeze({
IMAGE: FavouriteItemFormat.IMAGE,
VIDEO: FavouriteItemFormat.VIDEO,
CLIP: FavouriteItemFormat.VIDEO
});
export function AttachmentAccessory() {
const attachment = React.useContext(AttachmentContext);
const props: FavoriteButtonProps | null = useMemo(() => {
if (!attachment?.downloadUrl) return null;
const { originalItem, type, downloadUrl, width = 600, height = 400, srcIsAnimated } = attachment;
// Do not render the custom accessory if the original attachment component already has a gif accessory
const isAnimated = ImageUtils.isAnimated({
original: originalItem.url,
src: originalItem.proxy_url,
animated: false,
srcIsAnimated
});
if (isAnimated) return null;
if (type in visualMediaFormats) {
return { format: visualMediaFormats[type]!, src: originalItem.proxy_url, url: downloadUrl, width, height };
}
// Non visual attachments have to be encoded to store metadata in the src property.
// Note that this isn't a valid url yet, the full url (with a fallback image for vanilla client compat)
// is generated via `getThumbnailUrl` once the user clicks the favourite button
const src = defs.encode(CustomItemFormat.ATTACHMENT, originalItem)?.toString();
if (!src) return null;
return { format: FavouriteItemFormat.NONE, src, url: downloadUrl, width, height };
}, [attachment]);
return props && <FavoriteButton {...props} className={cl("attachment-accessory")} />;
}

View file

@ -5,133 +5,159 @@
*/
import { Devs, EquicordDevs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
import definePlugin from "@utils/types";
import { Embed } from "@vencord/discord-types";
import { findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack";
import { proxyLazyWebpack } from "@webpack";
import { React } from "@webpack/common";
import { Component, ReactNode } from "react";
import { ComponentType, ReactNode } from "react";
enum Format {
NONE = 0,
IMAGE = 1,
VIDEO = 2
}
import { AttachmentAccessory, EmbedAccessory, FilePicker } from "./components";
import { SignedUrlsStore } from "./stores";
import managedStyle from "./style.css?managed";
import { AttachmentItem, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types";
import { getThumbnailUrl } from "./utils";
interface FavoriteButtonProps {
width: number;
height: number;
// Media URL
src: string;
// Provider source URL
url: string;
format: Format;
className?: string;
}
interface AccessoryProps
extends Pick<FavoriteButtonProps, "width" | "height" | "url"> {
proxyUrl?: string;
video?: boolean;
}
interface EmbedComponent extends Component<{ embed: Embed; }> {
__render: () => ReactNode;
}
const FavoriteButton = findComponentByCodeLazy<FavoriteButtonProps>("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}");
const Classes = findCssClassesLazy("gifFavoriteButton", "ctaButtonContainer");
const EmbedContext = proxyLazyWebpack(() => React.createContext<null | Embed>(null));
export const EmbedContext = proxyLazyWebpack(() => React.createContext<null | Embed>(null));
export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext<null | number>(null));
export const AttachmentContext = proxyLazyWebpack(() => React.createContext<null | AttachmentItem>(null));
export default definePlugin({
name: "FavouriteAnything",
description: "Favourite any image",
description: "Favourite any image, video, or file attachment",
authors: [Devs.nin0dev, EquicordDevs.davri],
managedStyle,
patches: [
// EMBEDS
{
find: "static isAnimated",
find: "#{intl::SUPPRESS_ALL_EMBEDS}",
replacement: [
// .isAnimated is checked in almost every media overlay event listener, so it's easier to patch the source.
{
match: /static isAnimated\((\i)\)\{/,
replace:
"static isAnimated($1,override){if(!override)return true;"
// Wrap the embed component's render method in a custom context to avoid having to drill props
match: "render()",
replace: "$&{return $self.renderEmbed.call(this)}__render()"
},
// Always render the custom accessory if the prop wasn't provided. This mostly affects video attachments.
// Url and proxyUrl are additionally set to the same value, since the original url property only stores the thumbnail.
{
match: /(?<=this\.props\.renderAccessory\(\):)null/,
replace:
"$self.Accessory({...this.props,url:this.props.proxyUrl,video:true})"
},
// Always return static thumbnails for non gif media to prevent graphical glitches (side effect of the first patch).
{
match: /getSrc\(\i\)\{.*?let \i=/,
replace: "$&!this.constructor.isAnimated(this.props,true)||"
},
// Hide the default "GIF" tag accessory that is visible when discord is unfocused.
{
match: "return this.props.shouldRenderAccessory?",
replace: "$&!this.constructor.isAnimated(this.props,true)||"
// Specify the index for individual items in embed.images
match: /\.images\.map\((\i)=>(this.renderImage\(\{[^}]{50,100}\}\))\)/,
replace: ".images.map(($1,index)=>$self.renderEmbedMosaicItem($2,index))"
}
]
},
// Wrap the embed component with a custom context provider to avoid having to drill props.
{
find: "#{intl::SUPPRESS_ALL_EMBEDS}",
// Override the default renderAdjacentContent prop value for all types of embed components (renderImageComponent, renderVideoComponent...)
find: "#{intl::MEDIA_MOSAIC_ALT_TEXT_POPOUT_TITLE}",
replacement: {
match: "render()",
replace: "$&{return $self.renderEmbed.call(this)}__render()"
match: /renderAdjacentContent:(\i)/g,
replace: "$&=$self.renderEmbedAccessory"
}
},
// Replace the default gif accessory with a custom one that skips fileType checks. Mostly affects image attachments.
// ATTACHMENTS
{
find: "renderComponentAccessories",
find: '["VIDEO","CLIP","AUDIO"]',
replacement: [
{
// Wrap the attachment component in a custom context to avoid having to drill props
match: /(?<=children:)(\i)=>(\i\(\1\))\}\):(\i\(\))/,
replace: "$1=>$self.renderAttachment($2,arguments[0])}):$self.renderAttachment($3,arguments[0])"
},
{
// Always add our custom accessory to the attachment's adjacent content
match: "=[];",
replace: "=[$self.renderAttachmentAccessory()];"
}
]
},
// EXPRESSION PICKER
{
find: "#{intl::EXPRESSION_PICKER_CATEGORIES_A11Y_LABEL}",
replacement: [
{
// Replace the "GIFs" tab with two custom tabs
match: /\(0,\i\.jsx\)\((\i),[^}]{20,40}?"aria-selected":(\i)[^}]{50,100}?#{intl::EXPRESSION_PICKER_GIF}\)\}\)/,
replace: "$self.renderTabs($1,$2)"
},
{
// Insert the custom file picker into the expression picker's body
match: /\{onSelectGIF:(\i),[^}]{20,40}\}\):null,(?=(\i)===)/,
replace: "$&$self.renderFilePicker($2,$1),"
}
]
},
{
// Hide favourite files from the GIFs/Media tab
find: '.sortBy("order").reverse().value()',
replacement: {
match: /\i=>\(\)=>\{.{200,300}?null\}/,
replace: "props=>()=>$self.Accessory({...props,video:false})"
match: '.sortBy("order").reverse()',
replace: "$&.filter($self.filterGifs)"
}
},
// Add a proxyUrl prop alongside the src prop, which is used for video thumbnails.
// FAVOURITE BUTTON
{
find: /disableArrowKeySeek:\i\}\)\}/,
find: "#{intl::GIF_TOOLTIP_REMOVE_FROM_FAVORITES}",
replacement: {
match: /src:\i(?=,\.\.\.\i)/,
replace: "$&,proxyUrl:this.props.src"
// Intercept the onClick callback to replace the placeholder thumbnail with a valid CDN link
match: /\(0,(\i\.\i)\)\((\{[^}].{40,60}?\})\)/,
replace: "$self.interceptAddToFavourites($2).then($1)"
}
}
],
renderEmbed(this: EmbedComponent) {
renderTabs(Tab: ComponentType<ExpressionPickerTabProps>, activeView: ExpressionPickerView) {
return (
<EmbedContext.Provider value={this.props.embed}>
{this.__render()}
</EmbedContext.Provider>
<>
<Tab
id="gif-picker-tab"
key="gif-picker-tab"
aria-controls="gif-picker-tab-panel"
aria-selected={activeView === ExpressionPickerView.GIF}
isActive={activeView === ExpressionPickerView.GIF}
viewType={ExpressionPickerView.GIF}
>
Media
</Tab>
<Tab
id="files-picker-tab"
key="files-picker-tab"
aria-controls="files-picker-tab-panel"
aria-selected={activeView === ExpressionPickerView.FILES}
isActive={activeView === ExpressionPickerView.FILES}
viewType={ExpressionPickerView.FILES}
>
{getIntlMessage("FILES")}
</Tab>
</>
);
},
Accessory(props: AccessoryProps) {
const embed = React.useContext(EmbedContext);
const content = embed?.image ?? embed?.video;
renderFilePicker(activeView: ExpressionPickerView, onSelectGIF: (item: { url: string; }) => void) {
return activeView === ExpressionPickerView.FILES ? <FilePicker onSelectItem={onSelectGIF} /> : null;
},
renderAttachment(children: ReactNode, props: { item: AttachmentItem; }) {
return <AttachmentContext.Provider value={props.item}>{children}</AttachmentContext.Provider>;
},
renderEmbed(this: EmbedComponent) {
return <EmbedContext.Provider value={this.props.embed}>{this.__render()}</EmbedContext.Provider>;
},
renderEmbedMosaicItem(children: ReactNode, index: number) {
return <EmbedMosaicContext.Provider value={index}>{children}</EmbedMosaicContext.Provider>;
},
renderAttachmentAccessory: () => <AttachmentAccessory />,
renderEmbedAccessory: () => <EmbedAccessory />,
filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE,
interceptAddToFavourites: async (item: FavouriteItem & { url: string; }) => {
if (item.format !== FavouriteItemFormat.NONE) return item;
const { url, proxyUrl, width, height, video } =
embed && content
? {
...content,
url: (embed.type === "gifv" && embed.url) || content.url,
proxyUrl: content.proxyURL,
video: !!embed.video
}
: props;
SignedUrlsStore.addSigned(item.url);
if (!width || !height || !url) return null;
if (URL.canParse(item.src)) {
SignedUrlsStore.addSigned(item.src);
return item;
}
return (
<FavoriteButton
format={video ? Format.VIDEO : Format.IMAGE}
className={Classes?.gifFavoriteButton}
src={proxyUrl ?? url}
url={url}
width={width}
height={height}
/>
);
const thumbnail = await getThumbnailUrl(item.src, item.width, item.height);
if (!thumbnail) return item;
thumbnail.search = "";
thumbnail.hash = item.src;
return { ...item, src: `${thumbnail}` };
}
});

View file

@ -0,0 +1,31 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { MessageAttachment } from "@vencord/discord-types";
const allowedHosts = new Set([
"cdn.discordapp.com",
"images-ext-1.discordapp.net",
"images-ext-2.discordapp.net",
"media.discordapp.net"
]);
// Discord has very strict CORS rules for which types of assets can be fetched from where (CDN/Media proxy),
// and most binary file types are prohibited by both. This function serves as a simple bypass.
export async function fetchAttachment(_: unknown, attachment: MessageAttachment) {
const { content_type, filename } = attachment;
const url = URL.parse(attachment.url);
if (!url || !allowedHosts.has(url.hostname)) throw new Error("Invalid URL");
const res = await fetch(url, { headers: { Accept: "*/*" } });
if (!res.ok) throw new Error("Server error");
const blob = await res.blob();
const type = blob.type || content_type || "application/octet-stream";
const data = await blob.arrayBuffer();
return { type, data, filename };
}

View file

@ -0,0 +1,176 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/*
* This file contains modified code from:
* https://github.com/tc39/proposal-arraybuffer-base64
* * Copyright (c) 2017 ECMA TC39 and contributors
* *
* * Permission is hereby granted, free of charge, to any person obtaining a copy
* * of this software and associated documentation files (the "Software"), to deal
* * in the Software without restriction, including without limitation the rights
* * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* * copies of the Software, and to permit persons to whom the Software is
* * furnished to do so, subject to the following conditions:
* *
* * The above copyright notice and this permission notice shall be included in all
* * copies or substantial portions of the Software.
* *
* * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* * SOFTWARE.
*/
// TODO: Remove once discord stops being insane and updates electron
interface Uint8ArrayExtended<T extends ArrayBufferLike = ArrayBufferLike> extends Uint8Array<T> {
toBase64(options?: { alphabet?: "base64" | "base64url"; omitPadding?: boolean; }): string;
}
interface Uint8ArrayConstructorExtended extends Uint8ArrayConstructor {
fromBase64(
base64: string,
options?: {
alphabet?: "base64" | "base64url";
lastChunkHandling?: "loose" | "strict" | "stop-before-partial";
}
): Uint8ArrayExtended<ArrayBuffer>;
}
function supportsToBase64(array: Uint8Array<ArrayBufferLike>): array is Uint8ArrayExtended<ArrayBufferLike> {
return "toBase64" in array && typeof array.toBase64 === "function";
}
function supportsFromBase64(ctor: Uint8ArrayConstructor): ctor is Uint8ArrayConstructorExtended {
return "fromBase64" in ctor && typeof ctor.fromBase64 === "function";
}
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const map = new Map(chars.split("").map((c, i) => [c, i]));
export function uint8ArrayToBase64(arr: Uint8Array): string {
if (supportsToBase64(arr)) {
return arr.toBase64({ alphabet: "base64url", omitPadding: true });
}
if ("detached" in arr.buffer && arr.buffer.detached) {
throw new TypeError("toBase64 called on array backed by detached buffer");
}
let result = "";
let i = 0;
for (; i + 2 < arr.length; i += 3) {
const triplet = (arr[i] << 16) + (arr[i + 1] << 8) + arr[i + 2];
result +=
chars[(triplet >> 18) & 63] +
chars[(triplet >> 12) & 63] +
chars[(triplet >> 6) & 63] +
chars[triplet & 63];
}
if (i + 2 === arr.length) {
const triplet = (arr[i] << 16) + (arr[i + 1] << 8);
result += chars[(triplet >> 18) & 63] + chars[(triplet >> 12) & 63] + chars[(triplet >> 6) & 63];
} else if (i + 1 === arr.length) {
const triplet = arr[i] << 16;
result += chars[(triplet >> 18) & 63] + chars[(triplet >> 12) & 63];
}
return result;
}
function decodeBase64Chunk(chunk: string): number[] {
const actualChunkLength = chunk.length;
if (actualChunkLength < 4) {
chunk += actualChunkLength === 2 ? "AA" : "A";
}
const c1 = chunk[0];
const c2 = chunk[1];
const c3 = chunk[2];
const c4 = chunk[3];
const triplet = (map.get(c1)! << 18) + (map.get(c2)! << 12) + (map.get(c3)! << 6) + map.get(c4)!;
const chunkBytes = [(triplet >> 16) & 255, (triplet >> 8) & 255, triplet & 255];
if (actualChunkLength === 2) {
return [chunkBytes[0]];
} else if (actualChunkLength === 3) {
return [chunkBytes[0], chunkBytes[1]];
}
return chunkBytes;
}
const asciiWhitespaceRegex = /[\u0009\u000A\u000C\u000D\u0020]/;
function skipAsciiWhitespace(string: string, index: number): number {
for (; index < string.length; ++index) {
if (!asciiWhitespaceRegex.test(string[index])) {
break;
}
}
return index;
}
export function base64ToUint8Array(string: string): Uint8Array {
if (supportsFromBase64(Uint8Array)) {
return Uint8Array.fromBase64(string, { alphabet: "base64url" });
}
const bytes: number[] = [];
let chunk = "";
let index = 0;
while (true) {
index = skipAsciiWhitespace(string, index);
if (index === string.length) {
if (chunk.length > 0) {
if (chunk.length === 1) {
throw new SyntaxError("malformed padding: exactly one additional character");
}
bytes.push(...decodeBase64Chunk(chunk));
}
break;
}
const char = string[index];
++index;
if (char === "=") {
if (chunk.length < 2) {
throw new SyntaxError("padding is too early");
}
index = skipAsciiWhitespace(string, index);
if (chunk.length === 2) {
if (index === string.length) {
throw new SyntaxError("malformed padding - only one =");
}
if (string[index] === "=") {
++index;
index = skipAsciiWhitespace(string, index);
}
}
if (index < string.length) {
throw new SyntaxError("unexpected character after padding");
}
bytes.push(...decodeBase64Chunk(chunk));
break;
}
if (!chars.includes(char)) {
throw new SyntaxError(`unexpected character ${JSON.stringify(char)}`);
}
chunk += char;
if (chunk.length === 4) {
bytes.push(...decodeBase64Chunk(chunk));
chunk = "";
}
}
return new Uint8Array(bytes);
}

View file

@ -0,0 +1,98 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { proxyLazyWebpack } from "@webpack";
import { Constants, Flux, FluxDispatcher, RestAPI } from "@webpack/common";
import { RefreshedUrlsResponse } from "./types";
import { BatchedRequestQueue, isAllowedHost } from "./utils";
/** Used for storing and automatically refreshing signed CDN/Media proxy urls ({@link https://docs.discord.food/reference#signed-attachment-urls}). */
export const SignedUrlsStore = proxyLazyWebpack(() => {
interface Store {
get(url: string): string | null;
addSigned(url: string): void;
}
class SignedUrlsStore extends Flux.Store implements Store {
private static readonly _expirationThreshold = 60 * 60 * 1000;
private _urls = new Map<string, string>();
private _queue = new BatchedRequestQueue<string>(batch => this._handleBatch(batch), {
maxCount: 50,
timeout: 50
});
__getLocalVars() {
return { urls: this._urls, queue: this._queue };
}
public get(url: string): string | null {
const key = URL.parse(url);
if (!this._isValid(key)) return null;
const value = this._urls.get(`${this._clean(key)}`) ?? null;
const parsed = URL.parse(value!);
if (!parsed || this._willExpire(parsed)) this._refresh(key);
return value;
}
public addSigned(url: string): void {
const parsed = URL.parse(url);
if (!this._isValid(parsed)) return;
if (this._willExpire(parsed)) this._refresh(parsed);
else this._update([[`${this._clean(parsed)}`, url]]);
}
private _refresh(url: URL): void {
this._queue.add(`${this._clean(url)}`);
}
private _clean(url: URL): URL {
const clean = new URL(url);
clean.search = "";
clean.hash = "";
return clean;
}
private _isValid(url: URL | null): url is URL {
return !!(url && isAllowedHost(url.hostname));
}
private _willExpire(url: URL): boolean {
const expiryTimestamp = parseInt(url.searchParams.get("ex")!, 16) * 1000;
return isNaN(expiryTimestamp) || expiryTimestamp - SignedUrlsStore._expirationThreshold < Date.now();
}
private _update(urls: [string, string][]): void {
let hasChanged: boolean = false;
for (const [url, value] of urls) {
if (!value || url === value || this._urls.get(url) === value) continue;
this._urls.set(url, value);
hasChanged = true;
}
if (hasChanged) this.emitChange();
}
private async _handleBatch(batch: string[]): Promise<void> {
await RestAPI.post({
url: Constants.Endpoints.ATTACHMENTS_REFRESH_URLS,
body: { attachment_urls: batch },
retries: 3
}).then(({ body }: { body: RefreshedUrlsResponse; }) =>
this._update(body.refreshed_urls.map(({ original, refreshed }) => [original, refreshed!]))
);
}
}
return new SignedUrlsStore(FluxDispatcher) as Store;
});

View file

@ -0,0 +1,112 @@
.vc-favouriteAnything-attachment-container {
padding: 12px;
display: flex;
gap: 12px;
&.vc-favouriteAnything-reduced-padding {
padding-bottom: 0;
}
> :first-child {
margin: 0;
flex: 1;
min-width: 0;
> [class*="nonVisualMediaItem"] {
width: 100%;
[class*="mosaicItem"] {
min-width: 0;
max-width: unset;
width: 100%;
}
[class*="spoilerContent"] {
min-width: 0;
flex: 1;
}
}
}
> button {
width: 15%;
min-width: 54px;
}
}
.vc-favouriteAnything-container {
background: var(--background-base-lower);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
width: 100%;
}
.vc-favouriteAnything-container-header {
background: var(--background-surface-high);
border-bottom: 1px solid var(--border-subtle);
padding: var(--custom-gif-picker-gutter-size);
z-index: 1;
}
.vc-favouriteAnything-container-body {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
flex: 1;
overflow: hidden;
&.vc-favouriteAnything-container-info {
justify-content: center;
align-items: center;
gap: 1.5rem;
}
}
.vc-favouriteAnything-image-accessory {
inset-inline-start: 6px;
inset-inline-end: unset;
position: absolute;
top: 6px;
z-index: 3;
cursor: pointer;
:has(> &) {
position: relative;
}
:is(.imageWrapper:is(:hover, :focus-within) ~ &, :hover, :focus-within) > [class*="gifFavoriteButton"] {
opacity: 1;
transform: translateY(0);
}
}
.vc-favouriteAnything-attachment-accessory {
cursor: pointer;
&:hover {
background: var(--interactive-background-hover);
}
}
.vc-favouriteAnything-info-text {
text-align: center;
line-height: 1.6;
font-size: 1.1rem;
}
.vc-favouriteAnything-demo {
width: 70%;
justify-self: center;
position: relative;
.vc-favouriteAnything-demo-favourite-button {
position: absolute;
inset-inline-end: 4px;
top: 4px;
border-radius: 4px;
box-shadow: 0 0 0 4px var(--yellow-300);
}
}

View file

@ -0,0 +1,140 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Channel, Embed, EmbedJSON, Message, MessageAttachment, TextInput } from "@vencord/discord-types";
import { Component, ComponentClass, ComponentProps, ComponentPropsWithRef, Key, PropsWithChildren, ReactNode, RefObject } from "react";
import { JsonValue, PartialDeep } from "type-fest";
export enum ExpressionPickerView {
EMOJI = "emoji",
GIF = "gif",
STICKER = "sticker",
SOUNDBOARD = "soundboard",
FILES = "files"
}
export interface ExpressionPickerTabProps extends PropsWithChildren {
id?: string;
"aria-controls"?: string;
"aria-selected"?: boolean;
isActive?: boolean;
viewType: ExpressionPickerView;
}
export interface FavoriteButtonProps extends Omit<FavouriteItem, "order"> {
url: string;
gifSrc?: string;
className?: string;
}
// Partial type, renderAttachments only uses a few props
interface MessageComponentProps {
message: Message;
channel: Channel;
gifAutoPlay?: boolean;
canDeleteAttachments?: boolean;
shouldHideMediaOptions?: boolean;
inlineAttachmentMedia?: boolean;
}
export interface MessageComponentClass extends Omit<ComponentClass<MessageComponentProps>, "new"> {
new(props: MessageComponentProps): Component<MessageComponentProps> & {
renderAttachments(message: Partial<Message>): ReactNode;
};
}
export interface ManaSearchBarProps extends Pick<
ComponentPropsWithRef<TextInput>,
"autoFocus" | "placeholder" | "onKeyDown" | "disabled" | "onChange" | "onBlur" | "onFocus" | "autoComplete" | "ref"
> {
query?: string;
onClear?: () => void;
inputProps?: ComponentProps<TextInput>;
}
export interface FilePickerProps {
onSelectItem: (item: { url: string; }) => void;
}
export interface FilePickerItemProps {
url: string;
file: MessageAttachment;
channel: Channel | null;
reducePadding?: boolean;
onResize: (key: Key, height: number) => void;
onSubmit: (url: string) => void;
}
export interface AttachmentsComponentProps {
attachment: MessageAttachment;
}
export interface EmbedComponent extends Component<{ embed: Embed; }> {
__render: () => ReactNode;
}
export interface AttachmentItem {
contentType: string;
type: "IMAGE" | "VIDEO" | "CLIP" | "AUDIO" | "VISUAL_PLACEHOLDER" | "PLAINTEXT_PREVIEW" | "OTHER" | "INVALID";
width?: number;
height?: number;
downloadUrl: string;
spoiler: boolean;
srcIsAnimated: boolean;
uniqueId: string;
originalItem: MessageAttachment;
}
export enum FavouriteItemFormat {
NONE = 0,
IMAGE = 1,
VIDEO = 2
}
export interface FavouriteItem {
format: FavouriteItemFormat;
src: string;
width: number;
height: number;
order: number;
}
export enum CustomItemFormat {
ATTACHMENT = 0
}
export interface CustomItemDef<A = any, B extends JsonValue = any> {
encode: (data: A) => B | null;
decode: (data: PartialDeep<B, { recurseIntoArrays: true; }>) => NoInfer<A> | null;
stringify: (data: A) => string;
}
export type ItemsDef<T> = T & {
[K in keyof T]: T[K] extends CustomItemDef<infer A, infer B> ? CustomItemDef<A, B> : never;
};
export interface RefreshedUrlsResponse {
refreshed_urls: [
{
original: string;
refreshed: string | null;
}
];
}
export interface UnfurledEmbedsResponse {
embeds: EmbedJSON[];
}
export type ResizeObserverHook = (
ref: RefObject<Element | null>,
callback: (size: { width: number; height: number; }) => void,
deps?: unknown[]
) => void;
export interface ImageUtils {
isAnimated(image: { src: string; original?: string; animated: boolean; srcIsAnimated?: boolean; }): boolean;
}

View file

@ -0,0 +1,312 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@utils/css";
import { sendMessage } from "@utils/discord";
import { proxyLazy } from "@utils/lazy";
import { Queue } from "@utils/Queue";
import { useForceUpdater } from "@utils/react";
import { PluginNative } from "@utils/types";
import { Channel, MessageAttachment } from "@vencord/discord-types";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { Constants, DraftType, FluxDispatcher, MessageActions, PendingReplyStore, PermissionStore, RestAPI, Toasts, UploadAttachmentStore, UploadHandler, UploadManager, useCallback, useEffect, useRef, UserSettingsActionCreators, UserSettingsProtoStore, useStateFromStores } from "@webpack/common";
import { deflateSync, inflateSync } from "fflate";
import { Key } from "react";
import { JsonValue } from "type-fest";
import { base64ToUint8Array, uint8ArrayToBase64 } from "./polyfills";
import { CustomItemDef, CustomItemFormat, FavouriteItem, FavouriteItemFormat, ImageUtils as ImageUtils_, ItemsDef, ResizeObserverHook, UnfurledEmbedsResponse } from "./types";
const Native = VencordNative.pluginHelpers.FavouriteAnything as PluginNative<typeof import("./native")>;
export const cl = classNameFactory("vc-favouriteAnything-");
export const useResizeObserver: ResizeObserverHook = findByCodeLazy("borderBoxSize", "blockSize", "inlineSize");
export const ImageUtils: ImageUtils_ = findByPropsLazy("isAnimated", "getFormatQuality");
const defineItem = <const A, const B extends JsonValue>(item: CustomItemDef<A, B>) => item;
function defineItems<T extends Record<CustomItemFormat, CustomItemDef>>(def: ItemsDef<T>) {
type Type<F extends CustomItemFormat> = T[F] extends CustomItemDef<infer A> ? A : never;
return {
encode: <F extends CustomItemFormat>(format: F, data: Type<F>) => {
try {
const obj = [format, def[format].encode(data)];
const buf = deflateSync(new TextEncoder().encode(JSON.stringify(obj)));
return uint8ArrayToBase64(buf);
} catch {
return null;
}
},
decode: (raw: string) => {
try {
if (!raw) return null;
const buf = inflateSync(base64ToUint8Array(raw));
const parsed: unknown[] | null = JSON.parse(new TextDecoder().decode(buf));
if (!Array.isArray(parsed)) return null;
const [format, data] = parsed as [keyof typeof def, JsonValue];
if (!(format in def)) return null;
return { format, data: def[format].decode(data) } as {
[F in CustomItemFormat]: { format: F; data: Type<F>; };
}[CustomItemFormat];
} catch {
return null;
}
},
stringify: <F extends CustomItemFormat>(format: F, item: Type<F>) => def[format].stringify(item)
};
}
// Encode/Decode definitions for custom favourite items.
// The encode callback must return a json compatible object, preferably as compact as possible.
// Decode must recreate the original object based on the encoded value.
// Stringify returns a simple string representation used for thumbnail text and expression picker search.
export const defs = defineItems({
[CustomItemFormat.ATTACHMENT]: defineItem({
encode: ({ id, filename, size, url, content_type = "" }: MessageAttachment) => [
id,
filename,
size,
new URL(url).pathname,
content_type
],
decode: ([id, filename, size, path, content_type]) => ({
id: id ?? "0",
filename: filename ?? "UNKNOWN",
size: +size! || 0,
url: `${new URL(path!, `https://${window.GLOBAL_ENV.CDN_HOST}`)}`,
proxy_url: `${new URL(path!, `https://${window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT}`)}`,
content_type: content_type ?? "application/octet-stream",
spoiler: filename?.startsWith("SPOILER_") ?? false
}),
stringify: ({ filename }) => filename
})
// This could be expanded in the future with other item types (e.g. voice messages)
});
// TODO: make thumbnails prettier
const fallbackThumbnail = new URL("https://images-ext-1.discordapp.net/external/pGTJg3YdSHpyGTltH4vZUKEyQoNzf5mtqbSJs7I4ebc/https/equicord.org/assets/plugins/favoriteAnything/invalid.png");
export async function getThumbnailUrl(data: string, width: number, height: number): Promise<URL | null> {
try {
const decoded = defs.decode(data);
if (!decoded || !width || !height) return null;
const text = defs.stringify(decoded.format, decoded.data);
const url = new URL(`https://placehold.jp/42/444/fff/${width}x${height}.png`);
url.searchParams.append("text", text);
return await RestAPI.post({
url: Constants.Endpoints.UNFURL_EMBED_URLS,
body: { urls: [url] },
retries: 3
}).then(({ body }: { body: UnfurledEmbedsResponse; }) => {
const [{ thumbnail } = {}] = body.embeds;
return thumbnail?.proxy_url ? new URL(thumbnail.proxy_url) : fallbackThumbnail;
});
} catch {
return fallbackThumbnail;
}
}
export const isAllowedHost = proxyLazy(() => {
// GLOBAL_ENV is not initialized immediately
const allowedHosts = new Set<string>([
window.GLOBAL_ENV.CDN_HOST,
...[window.GLOBAL_ENV.IMAGE_PROXY_ENDPOINTS, window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT]
.flatMap(endpoint => endpoint.split(","))
.map(endpoint => URL.parse(`https://${endpoint}`)?.hostname)
.filter(Boolean)
]);
return (value: string) => allowedHosts.has(value);
});
async function fetchAttachment(attachment: MessageAttachment): Promise<File> {
if (!IS_WEB)
return Native.fetchAttachment(attachment).then(
({ data, filename, type }) => new File([data], filename, { type })
);
const { content_type, filename } = attachment;
const url = URL.parse(attachment.url);
if (!url || !isAllowedHost(url.hostname)) throw new Error("Invalid URL");
const res = await fetch(url, { headers: { Accept: "*/*" } });
if (!res.ok) throw new Error("Server error");
const blob = await res.blob();
const type = blob.type || content_type || "application/octet-stream";
const data = await blob.arrayBuffer();
return new File([data], filename, { type });
}
export async function sendAttachment(attachment: MessageAttachment, channel: Channel) {
const file = await fetchAttachment(attachment)
.catch(() =>
Toasts.show({
message: `Couldn't fetch ${attachment.filename}`,
id: Toasts.genId(),
type: Toasts.Type.FAILURE
})
);
if (!file) return;
// Using promptToUpload instead of addFiles directly since it has file size checks with error popups
await UploadHandler.promptToUpload([file], channel, DraftType.ChannelMessage).catch(() =>
Toasts.show({
message: `Couldn't upload ${attachment.filename}`,
id: Toasts.genId(),
type: Toasts.Type.FAILURE
})
);
const uploads = [...UploadAttachmentStore.getUploads(channel.id, DraftType.ChannelMessage)];
const uploadIdx = uploads.findIndex(({ item }) => item.file === file);
if (uploadIdx === -1) return;
const reply = PendingReplyStore.getPendingReply(channel.id);
const [upload] = uploads.splice(uploadIdx);
UploadManager.setUploads({ uploads, channelId: channel.id, draftType: DraftType.ChannelMessage });
FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId: channel.id });
void sendMessage(channel.id, {}, false, {
...MessageActions.getSendMessageOptionsForReply(reply),
attachmentsToUpload: [upload]
});
}
export function hasPermission(permission: bigint, channel: Channel | null): boolean {
return !!channel && (PermissionStore.can(permission, channel) || channel.isPrivate());
}
const diacriticsRegex = /[\u0300-\u036f]/g;
function normalize(str: string) {
return str.normalize("NFD").replace(diacriticsRegex, "").normalize("NFKC").toLowerCase().trim();
}
// Stolen from favGifSearch
function fuzzySearch(searchQuery: string, searchString: string) {
let searchIndex = 0;
let score = 0;
for (let i = 0; i < searchString.length; i++) {
if (searchString[i] === searchQuery[searchIndex]) {
score++;
searchIndex++;
} else {
score--;
}
if (searchIndex === searchQuery.length) {
return score;
}
}
return null;
}
function filterItems(items: Record<string, FavouriteItem> | null, itemFormat: CustomItemFormat, query?: string) {
if (!items) return null;
const validItems = Object.entries(items)
.filter(([, { format }]) => format === FavouriteItemFormat.NONE)
.map(([url, { src, ...rest }]) => ({
...rest,
...defs.decode(URL.parse(src)?.hash.replace("#", "") ?? "")!,
url
}))
.filter(({ format, data }) => data && format === itemFormat);
if (!query) return validItems.sort((a, b) => b.order - a.order);
return validItems
.map(item => ({
item,
score: fuzzySearch(query, normalize(defs.stringify(item.format, item.data)))
}))
.filter(({ score }) => score !== null)
.sort((a, b) => b.score! - a.score!)
.map(({ item }) => item);
}
export function useFavourites(itemFormat: CustomItemFormat, searchQuery?: string) {
useEffect(() => void UserSettingsActionCreators.FrecencyUserSettingsActionCreators.loadIfNecessary(), []);
const { state } = useStateFromStores(
[UserSettingsProtoStore],
() => {
const query = searchQuery && normalize(searchQuery);
const items: Record<string, FavouriteItem> | null =
UserSettingsProtoStore.frecencyWithoutFetchingLatest.favoriteGifs?.gifs;
return { query, state: filterItems(items, itemFormat, query) };
},
[searchQuery],
// Do not rerender components using this hook unless the query has changed or the items were loaded for the first time
// This matches the behavior of the gif picker, where unfavouriting an item doesn't immediately hide it
(prev, next) => !!prev.state === !!next.state && prev.query === next.query
);
return state;
}
// Helper hook for the ListScroller component, similar utility is used in the forum channel list view
// for keeping track of the individual row heights
export function useListScroller() {
const rowHeights = useRef(new Map<Key, number>());
const update = useForceUpdater();
const handleResize = useCallback((key: Key, height: number) => {
if (height === rowHeights.current.get(key)) return;
rowHeights.current.set(key, height);
update();
}, []);
return [rowHeights.current, handleResize] as const;
}
// Wrapper class for Queue which allows batching multiple requests into one.
// A request is fired immediately if at least `maxCount` items are in this queue,
// or if enough time (`timeout`) has passed since the last item was added.
// Subsequent requests are fired in sequence.
export class BatchedRequestQueue<T> {
private items: T[] = [];
private timer: NodeJS.Timeout | null = null;
private readonly queue: Queue = new Queue();
constructor(
private readonly cb: (items: T[]) => Promise<void>,
private readonly options: { maxCount: number; timeout?: number; }
) { }
public add(item: T) {
if (this.items.indexOf(item) !== -1) return;
this.items.push(item);
if (this.items.length >= this.options.maxCount) {
this.flush();
} else {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.flush(), this.options.timeout);
}
}
private flush() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
if (this.items.length === 0) return;
const batch = this.items.splice(0, 50);
this.queue.push(() => this.cb(batch).catch(() => this.items.push(...batch)));
}
}

View file

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

View file

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

View file

@ -22,7 +22,7 @@ import { Alerts, Button, ContextMenuApi, FluxDispatcher, Menu, React, showToast,
import { addToCollection, cache_collections, createCollection, DATA_COLLECTION_NAME, deleteCollection, fixPrefix, getCollections, getGifById, getItemCollectionNameFromId, moveGifToCollection, refreshCacheCollection, removeFromCollection, renameCollection, updateGif } from "./utils/collectionManager";
import { getFormat } from "./utils/getFormat";
import { getGif } from "./utils/getGif";
import { refreshGifUrl } from "./utils/refreshUrl";
import { batchRefreshAttachmentUrls, isCdnUrlExpired } from "./utils/refreshUrl";
import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils";
import { uuidv4 } from "./utils/uuidv4";
@ -385,11 +385,44 @@ export default definePlugin({
width: g.width,
height: g.height
})).reverse();
const expiredGifs = collection.gifs.filter(g => g.src && g.url && (isCdnUrlExpired(g.src) || isCdnUrlExpired(g.url)));
if (expiredGifs.length > 0) {
const allUrls = [...new Set<string>(
expiredGifs.flatMap(g => [g.src, g.url].filter((u): u is string => !!u && isCdnUrlExpired(u)))
)];
const refreshInBatches = async () => {
const fullMap: Record<string, string> = {};
for (let i = 0; i < allUrls.length; i += 50) {
const batch = allUrls.slice(i, i + 50);
const result = await batchRefreshAttachmentUrls(batch);
Object.assign(fullMap, result);
}
return fullMap;
};
refreshInBatches().then(async refreshMap => {
if (!Object.keys(refreshMap).length) return;
let anyUpdated = false;
for (const gif of expiredGifs) {
const newSrc = refreshMap[gif.src] ?? gif.src;
const newUrl = refreshMap[gif.url] ?? gif.url;
if (newSrc !== gif.src || newUrl !== gif.url) {
await updateGif(gif.id, { ...gif, src: newSrc, url: newUrl });
anyUpdated = true;
}
}
if (!anyUpdated) return;
const { query } = instance.props;
FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: `${query} ` });
await new Promise<void>(r => setTimeout(r, 2500));
FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query });
});
}
}
}
},
hidePrefix(name) {
return name.split(":".length > 1) ? name.replace(/.+?:/, "") : name;
return name.includes(":") ? name.replace(/.+?:/, "") : name;
},
insertCollections(instance) {
const shouldRemoveAll = settings.store.onlyShowCollections;
@ -547,40 +580,6 @@ const RemoveItemContextMenu = ({ type, nameOrId, instance }) => (
}}
/>
<Menu.MenuSeparator />
<Menu.MenuItem
key="refresh-url"
id="refresh-url"
label="Refresh URL"
action={async () => {
const gifInfo = getGifById(nameOrId);
if (!gifInfo) return;
if (!gifInfo.channelId || !gifInfo.messageId || !gifInfo.attachmentId) {
showToast("Cannot refresh: GIF missing metadata (re-add to collection)", Toasts.Type.FAILURE);
return;
}
showToast("Refreshing URL...", Toasts.Type.MESSAGE);
const refreshedGif = await refreshGifUrl(gifInfo);
if (refreshedGif) {
await updateGif(nameOrId, refreshedGif);
const collectionName = getItemCollectionNameFromId(nameOrId);
FluxDispatcher.dispatch({
type: "GIF_PICKER_QUERY",
query: `${collectionName} `
});
FluxDispatcher.dispatch({
type: "GIF_PICKER_QUERY",
query: `${collectionName}`
});
showToast("URL refreshed successfully", Toasts.Type.SUCCESS);
} else {
showToast("Failed to refresh URL", Toasts.Type.FAILURE);
}
}}
/>
<Menu.MenuSeparator />
<Menu.MenuItem
key="copy-url"
id="copy-url"

View file

@ -63,7 +63,7 @@ export function getGifByMessageAndUrl(url: string, message: Message): Gif | null
id: uuidv4(settings.store.itemPrefix),
height: embed.image.height,
width: embed.image.width,
src: embed.image.proxyURL,
src: embed.image.proxyURL!,
url: embed.image.url,
};
// Tennor

View file

@ -4,46 +4,31 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Gif } from "@equicordplugins/gifCollections/types";
import { RestAPI } from "@webpack/common";
async function fetchMessageFromAPI(channelId: string, messageId: string): Promise<any> {
export function isCdnUrlExpired(url: string): boolean {
try {
const response = await RestAPI.get({
url: `/channels/${channelId}/messages`,
query: {
limit: 1,
around: messageId
}
const ex = new URL(url).searchParams.get("ex");
if (!ex) return false;
return parseInt(ex, 16) * 1000 < Date.now();
} catch {
return false;
}
}
export async function batchRefreshAttachmentUrls(urls: string[]): Promise<Record<string, string>> {
try {
const response = await RestAPI.post({
url: "/attachments/refresh-urls",
body: { attachment_urls: urls }
});
if (response.ok && response.body.length > 0) {
return response.body.find((m: any) => m.id === messageId);
if (!response.ok) return {};
const map: Record<string, string> = {};
for (const { original, refreshed } of response.body.refreshed_urls) {
map[original] = refreshed;
}
} catch (error) {
console.error("Failed to fetch message for GIF refresh:", error);
return map;
} catch {
return {};
}
return null;
}
export async function refreshGifUrl(gif: Gif): Promise<Gif | null> {
if (!gif.channelId || !gif.messageId || !gif.attachmentId) {
return null;
}
const message = await fetchMessageFromAPI(gif.channelId, gif.messageId);
if (!message) {
return null;
}
const attachment = message.attachments?.find((a: any) => a.id === gif.attachmentId);
if (!attachment) {
return null;
}
return {
...gif,
src: attachment.proxy_url,
url: attachment.url
};
}

View file

@ -0,0 +1,70 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { IconComponent, OptionType } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { findExportedComponentLazy } from "@webpack";
import { ChannelStore, FluxDispatcher, Menu } from "@webpack/common";
const EyeIconLazy = findExportedComponentLazy("EyeIcon");
const EyeIcon: IconComponent = props => <EyeIconLazy {...props} />;
const hideMessage = (messageId: string, channelId: string) => {
FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
id: messageId,
channelId,
mlDeleted: true,
});
};
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
const group = findGroupChildrenByChildId("copy-text", children);
if (!group) return;
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
<Menu.MenuItem
id="vc-hidemessages"
label="Hide"
icon={EyeIcon}
action={() => hideMessage(message.id, message.channel_id)}
/>
));
};
const settings = definePluginSettings({
hidePopoverButton: {
type: OptionType.BOOLEAN,
description: "Hide the hide button in the message popover.",
default: false
}
});
export default definePlugin({
name: "HideMessages",
description: "A plugin to temporarily hide messages until you restart.",
authors: [EquicordDevs.yash],
contextMenus: {
"message": messageCtxPatch
},
settings,
messagePopoverButton: {
icon: EyeIcon,
render(message: Message) {
if (settings.store.hidePopoverButton) return null;
return {
label: "Hide",
icon: EyeIcon,
message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: () => hideMessage(message.id, message.channel_id)
};
}
}
});

View file

@ -17,6 +17,20 @@ import { Channel } from "@vencord/discord-types";
import { findComponentByCodeLazy } from "@webpack";
import { FluxDispatcher, Menu, React, Tooltip, UserStore } from "@webpack/common";
interface CallUpdate {
ringing: string[];
ongoingRings: Record<number, string>;
messageId: string;
region: string;
}
const args: CallUpdate = {
ringing: [],
ongoingRings: {},
messageId: "",
region: "",
};
const ignoredChannelIds = new Set<string>();
const cl = classNameFactory("vc-ignore-calls-");
const Deafen = findComponentByCodeLazy("0-1.02-.1H3.05a9");
@ -74,12 +88,6 @@ const settings = definePluginSettings({
},
});
const args = {
ringing: [],
messageId: "",
region: "",
};
export default definePlugin({
name: "IgnoreCalls",
description: "Allows you to ignore calls from specific users or dm groups.",
@ -99,8 +107,9 @@ export default definePlugin({
"gdm-context": ContextMenuPatch,
},
flux: {
async CALL_UPDATE({ ringing, messageId, region }) {
args.ringing = ringing;
async CALL_UPDATE({ ringing, ongoingRings, messageId, region }) {
args.ringing = ringing || [];
args.ongoingRings = ongoingRings || {};
args.messageId = messageId;
args.region = region;
}
@ -113,6 +122,7 @@ export default definePlugin({
type: "CALL_UPDATE",
channelId: channel.id,
ringing: args.ringing.filter((id: string) => id !== currentUserId),
ongoingRings: args.ongoingRings,
messageId: args.messageId,
region: args.region
});
@ -133,6 +143,7 @@ export default definePlugin({
type: "CALL_UPDATE",
channelId: channel.id,
ringing: args.ringing.filter((id: string) => id !== currentUserId),
ongoingRings: args.ongoingRings,
messageId: args.messageId,
region: args.region
});

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,33 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "NoPushToTalk",
description: "Bypasses the push-to-talk requirement for voice activity in channels that enforce it.",
authors: [EquicordDevs.omaw],
patches: [
{
find: "PermissionVADStore",
replacement: [
{
match: /\|\|\i\.\i\.can\(\i\.\i\.USE_VAD,\i\)\|\|/,
replace: "||true||"
},
{
match: /shouldShowWarning\(\)\{return!\i\}/,
replace: "shouldShowWarning(){return false}"
},
{
match: /canUseVoiceActivity\(\)\{return \i\}/,
replace: "canUseVoiceActivity(){return true}"
},
]
}
],
});

View file

@ -41,6 +41,10 @@ const settings = definePluginSettings({
label: "Playstation",
value: "playstation",
},
{
label: "VR",
value: "vr",
},
]
}
});
@ -48,7 +52,7 @@ const settings = definePluginSettings({
export default definePlugin({
name: "PlatformSpoofer",
description: "Spoof what platform or device you're on",
authors: [EquicordDevs.Drag],
authors: [EquicordDevs.Drag, EquicordDevs.neoarz],
settingsAboutComponent: () => (
<Notice.Warning>
We can't guarantee this plugin won't get you warned or banned.
@ -95,6 +99,8 @@ export default definePlugin({
return { browser: "Discord Embedded", vcIcon: 2 };
case "playstation":
return { browser: "Discord Embedded", vcIcon: 3 };
case "vr":
return { browser: "Discord VR", vcIcon: 4 };
default:
return null;
}

View file

@ -4,7 +4,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isNonNullish } from "@utils/guards";
import { classes } from "@utils/misc";
import { ProfilePreset } from "@vencord/discord-types";
import { ContextMenuApi, Menu, React, TextInput } from "@webpack/common";
import { cl } from "..";
@ -37,6 +39,7 @@ export function PresetList({
currentPage,
onPageChange
}: PresetListProps) {
type EditableProfile = Omit<ProfilePreset, "name" | "timestamp">;
const [renaming, setRenaming] = React.useState<number>(-1);
const [renameText, setRenameText] = React.useState("");
const isGuildProfile = section === "server";
@ -149,11 +152,11 @@ export function PresetList({
label="Update"
action={async () => {
const profile = await getCurrentProfile(guildId, { isGuildProfile });
Object.entries(profile).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
updatePresetField(actualIndex, key as any, value, section, guildId);
}
});
await Promise.all(
(Object.entries(profile) as [keyof EditableProfile, EditableProfile[keyof EditableProfile]][])
.filter(([, value]) => isNonNullish(value))
.map(([key, value]) => updatePresetField(actualIndex, key, value, section, guildId))
);
onUpdate();
}}
/>

View file

@ -137,7 +137,7 @@ export function PresetManager({ section, guildId }: PresetManagerProps) {
});
};
const avatarSize = settings.store.avatarSize || 40;
const { avatarSize } = settings.store;
const hasPresets = presets.length > 0;
const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE;

View file

@ -18,9 +18,11 @@ import { loadPresets, PresetSection } from "./utils/storage";
export const cl = classNameFactory("vc-profile-presets-");
export const settings = definePluginSettings({
avatarSize: {
type: OptionType.NUMBER,
type: OptionType.SLIDER,
description: "Avatar size in preset list.",
default: 40,
markers: [56, 64, 72, 80, 88, 96],
default: 56,
stickToMarkers: true
},
});
@ -40,8 +42,8 @@ export default definePlugin({
{
find: "USER_SETTINGS_GUILD_PROFILE)",
replacement: {
match: /onChange:\i\}\)(?=.{0,25}profilePreviewTitle:)/,
replace: '$&,$self.renderPresetSection("server")'
match: /guildId:(\i\.id),onChange:(\i)\}\)(?=.{0,25}profilePreviewTitle:)/,
replace: 'guildId:$1,onChange:$2}),$self.renderPresetSection("server",$1)'
}
}
],

View file

@ -4,24 +4,64 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isNonNullish } from "@utils/guards";
import { ProfilePreset } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { showToast, Toasts } from "@webpack/common";
import { getCurrentProfile } from "./profile";
import { addPreset, movePresetInArray, PresetSection, presets, removePreset, replaceAllPresets, savePresetsData, updatePreset, type ProfilePresetEx } from "./storage";
import { addPreset, movePresetInArray, presets, PresetSection, type ProfilePresetEx, removePreset, replaceAllPresets, savePresetsData, updatePreset } from "./storage";
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
function isImageInput(value: unknown): value is string | { imageUri: string; } {
if (typeof value === "string") return value.length > 0;
return typeof value === "object" && isNonNullish(value) && "imageUri" in value && typeof (value as { imageUri: unknown }).imageUri === "string";
}
function getFreshPendingAvatar(section: PresetSection, guildId?: string): string | null {
const pending = (section === "server" && guildId
? UserProfileSettingsStore.getPendingChanges?.(guildId)
: UserProfileSettingsStore.getPendingChanges?.()) ?? {};
const pendingObj = pending as Record<string, unknown>;
const candidates = [
pendingObj.selectedAvatarRaw,
pendingObj.presetAvatarRaw,
pendingObj.pendingAvatar,
pendingObj.avatar,
pendingObj.selectedAvatarProcessed,
pendingObj.presetAvatarProcessed
];
const selected = candidates.find(isImageInput);
if (!selected) return null;
return typeof selected === "string" ? selected : selected.imageUri;
}
export async function savePreset(name: string, section: PresetSection, guildId?: string) {
const profile = await getCurrentProfile(guildId, { isGuildProfile: section === "server" });
const freshPendingAvatar = getFreshPendingAvatar(section, guildId);
const effectiveAvatar = freshPendingAvatar ?? profile.avatarDataUrl ?? null;
const newPreset: ProfilePresetEx = {
name,
timestamp: Date.now(),
...profile,
avatarDataUrl: effectiveAvatar,
};
addPreset(newPreset);
await savePresetsData(section);
}
export async function updatePresetField(index: number, field: keyof Omit<ProfilePreset, "name" | "timestamp">, value: any, section: PresetSection, guildId?: string) {
export async function updatePresetField<K extends keyof Omit<ProfilePreset, "name" | "timestamp">>(
index: number,
field: K,
value: Omit<ProfilePreset, "name" | "timestamp">[K],
section: PresetSection,
guildId?: string
) {
if (index < 0 || index >= presets.length) return;
void guildId;
const updatedPreset = {
...presets[index],
@ -55,17 +95,14 @@ export async function renamePreset(index: number, newName: string, section: Pres
}
export function exportPresets(section: PresetSection) {
try {
const dataStr = JSON.stringify(presets, null, 2);
const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.href = url;
link.download = `profile-presets-${section}-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
} catch {
}
const dataStr = JSON.stringify(presets, null, 2);
const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.href = url;
link.download = `profile-presets-${section}-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
}
export type ImportDecision = "override" | "merge" | "cancel";
@ -108,6 +145,7 @@ export async function importPresets(
await savePresetsData(section);
forceUpdate();
} catch {
showToast("Failed to import presets. The file might be invalid.", Toasts.Type.FAILURE);
}
};
input.click();

View file

@ -7,12 +7,16 @@
import { getUserSettingLazy } from "@api/UserSettings";
import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { FluxDispatcher, GuildMemberStore,IconUtils, UserProfileStore, UserStore } from "@webpack/common";
import { FluxDispatcher, GuildMemberStore, IconUtils, UserProfileStore, UserStore } from "@webpack/common";
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
type PendingChanges = Record<string, unknown> & {
selectedAvatarRaw?: ImageInput;
presetAvatarRaw?: ImageInput;
selectedAvatarProcessed?: ImageInput;
presetAvatarProcessed?: ImageInput;
pendingAvatar?: ImageInput;
avatar?: ImageInput;
pendingBanner?: ImageInput;
@ -30,34 +34,22 @@ type PendingChanges = Record<string, unknown> & {
pendingPrimaryGuildId?: string | null;
};
type UserExtras = {
collectibles?: {
nameplate?: Nameplate;
};
displayNameStyles?: DisplayNameStyles;
avatarDecorationData?: {
asset: string;
skuId: string;
} | null;
primaryGuild?: {
identityGuildId?: string | null;
} | null;
};
type ProfileCollectible = {
skuId: string;
title?: string;
description?: string;
accessibilityLabel?: string;
reducedMotionSrc?: string;
thumbnailPreviewSrc?: string;
effects?: ProfileEffect["effects"];
animationType?: number;
staticFrameSrc?: string;
type?: number;
};
type ImageInput = string | { imageUri: string; } | null | undefined;
type DisplayNameStylesLike = DisplayNameStyles & {
fontId?: number;
effectId?: number;
};
type CurrentProfileOptions = {
isGuildProfile?: boolean;
};
type LoadPresetOptions = {
skipGlobalName?: boolean;
skipBio?: boolean;
skipPronouns?: boolean;
isGuildProfile?: boolean;
};
function dispatch(type: string, payload: Record<string, unknown>) {
FluxDispatcher.dispatch({ type, ...payload });
@ -70,7 +62,23 @@ function isNonEmptyString(value: unknown): value is string {
function hasImageInput(value: ImageInput): boolean {
if (!value) return false;
if (typeof value === "string") return value.length > 0;
return typeof value === "object" && "imageUri" in value && isNonEmptyString(value.imageUri);
return typeof value === "object" && isNonEmptyString(value?.imageUri);
}
function normalizeDisplayNameStyles(value: DisplayNameStylesLike | null | undefined): DisplayNameStylesLike | null {
if (!value) return null;
const fontId = value.fontId ?? value.font_id;
const effectId = value.effectId ?? value.effect_id;
if (typeof fontId !== "number" || typeof effectId !== "number") return null;
const colors = Array.isArray(value.colors) ? [...value.colors] : [];
return {
fontId,
effectId,
font_id: fontId,
effect_id: effectId,
colors
};
}
export async function imageUrlToBase64(url: string): Promise<string | null> {
@ -91,7 +99,7 @@ export async function imageUrlToBase64(url: string): Promise<string | null> {
async function processImage(imageData: ImageInput, userId: string, type: "avatar" | "banner", guildId?: string, useGuildPath?: boolean): Promise<string | null> {
if (!imageData) return null;
if (typeof imageData === "object" && "imageUri" in imageData && isNonEmptyString(imageData.imageUri)) {
if (typeof imageData === "object" && isNonEmptyString(imageData?.imageUri)) {
return imageData.imageUri;
}
@ -117,17 +125,6 @@ async function processImage(imageData: ImageInput, userId: string, type: "avatar
return null;
}
type CurrentProfileOptions = {
isGuildProfile?: boolean;
};
type LoadPresetOptions = {
skipGlobalName?: boolean;
skipBio?: boolean;
skipPronouns?: boolean;
isGuildProfile?: boolean;
};
export async function getCurrentProfile(guildId?: string, options: CurrentProfileOptions = {}): Promise<Omit<ProfilePreset, "name" | "timestamp">> {
const currentUser = UserStore.getCurrentUser();
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
@ -135,19 +132,17 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
const effectiveGuildId = isGuildProfile ? guildId : undefined;
const guildProfile = effectiveGuildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, effectiveGuildId) : null;
const userProfile = guildProfile ?? baseProfile;
const userAny = currentUser as UserExtras;
const userAny = currentUser;
const guildMember = effectiveGuildId ? GuildMemberStore.getMember(effectiveGuildId, currentUser.id) : null;
const pendingChangesDefault = (UserProfileSettingsStore.getPendingChanges() ?? {}) as PendingChanges;
const pendingChangesForGuild = (effectiveGuildId
? (UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {})
: {}) as PendingChanges;
const pendingChangesDefault: PendingChanges = UserProfileSettingsStore.getPendingChanges() ?? {};
const pendingChangesForGuild: PendingChanges = UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {};
const pendingChanges: PendingChanges = isGuildProfile && Object.keys(pendingChangesForGuild).length > 0
? pendingChangesForGuild
: pendingChangesDefault;
const customStatusSetting = CustomStatusSettings.getSetting();
const customStatus: CustomStatus | null = isGuildProfile
const customStatus = isGuildProfile
? null
: {
text: customStatusSetting?.text ?? "",
@ -180,7 +175,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
type: effectToUse.type || 1
};
} else if (effectToUse.skuId) {
const collectibles = (userProfile as { collectibles?: ProfileCollectible[]; } | null)?.collectibles;
const collectibles = userProfile?.collectibles;
const collectible = collectibles?.find(c => c?.skuId === effectToUse.skuId);
if (collectible) {
profileEffect = {
@ -208,12 +203,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);
const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar;
const avatarToUse: ImageInput = hasImageInput(pendingAvatar)
@ -245,7 +239,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,
@ -263,98 +257,156 @@ function jsonEq(a: unknown, b: unknown): boolean {
}
function customStatusEq(a: CustomStatus | null | undefined, b: CustomStatus | null | undefined): boolean {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (a == null || b == null) return a == null && b == null;
return a.text === b.text
&& String(a.emojiId ?? "") === String(b.emojiId ?? "")
&& a.emojiName === b.emojiName
&& String(a.expiresAtMs ?? "0") === String(b.expiresAtMs ?? "0");
}
function resolvePendingAvatar(pendingChanges: PendingChanges | null): ImageInput {
if (!pendingChanges) return null;
const candidates: ImageInput[] = [
pendingChanges.selectedAvatarRaw,
pendingChanges.presetAvatarRaw,
pendingChanges.pendingAvatar,
pendingChanges.avatar,
pendingChanges.selectedAvatarProcessed,
pendingChanges.presetAvatarProcessed
];
return candidates.find(hasImageInput) ?? null;
}
function normalizeImageValue(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object" && "imageUri" in value) {
const { imageUri } = value as { imageUri: unknown; };
return typeof imageUri === "string" ? imageUri : null;
}
return null;
}
function collectibleEqBySku(a: { skuId?: string | number | null; } | null | undefined, b: { skuId?: string | number | null; } | null | undefined): boolean {
if (a == null || b == null) return a == null && b == null;
return String(a.skuId ?? "") === String(b.skuId ?? "");
}
function avatarDecorationEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean {
if (a == null || b == null) return a == null && b == null;
return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? "");
}
function nameplateEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean {
if (a == null || b == null) return a == null && b == null;
return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? "");
}
export async function loadPresetAsPending(preset: ProfilePreset, guildId?: string, options: LoadPresetOptions = {}) {
try {
const isGuild = options.isGuildProfile ?? Boolean(guildId);
if (isGuild && !guildId) return;
const currentUser = UserStore.getCurrentUser();
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
const guildProfile = isGuild && guildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, guildId) : null;
const equippedProfile = guildProfile ?? baseProfile;
const current = await getCurrentProfile(guildId, {
isGuildProfile: isGuild
});
const profilePrefix = "USER_SETTINGS_ACCOUNT";
const dispatchProfile = (type: string, payload: Record<string, unknown>) => {
dispatch(type, isGuild ? { ...payload, guildId } : payload);
const pendingChanges = (isGuild && guildId
? UserProfileSettingsStore.getPendingChanges(guildId)
: UserProfileSettingsStore.getPendingChanges());
const equippedAvatarDecoration = currentUser.avatarDecorationData ?? null;
const equippedProfileEffect = equippedProfile?.profileEffect ?? null;
const equippedNameplate = currentUser.collectibles?.nameplate ?? null;
const pendingAvatarDecoration = pendingChanges?.pendingAvatarDecoration ?? null;
const pendingProfileEffect = pendingChanges?.pendingProfileEffect ?? null;
const pendingNameplate = pendingChanges?.pendingNameplate ?? null;
const setPending = (field: string, payload: Record<string, unknown>) => {
const cleanPayload = Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== undefined));
if (!Object.keys(cleanPayload).length) return;
dispatch(`USER_PROFILE_SETTINGS_SET_PENDING_${field}`, isGuild ? { ...cleanPayload, guildId } : cleanPayload);
};
const setPending = (type: string, payload: Record<string, unknown>) => dispatchProfile(type, payload);
if ("avatarDataUrl" in preset) {
const avatarValue = preset.avatarDataUrl;
if (isGuild || avatarValue !== current.avatarDataUrl) {
setPending(`${profilePrefix}_SET_PENDING_AVATAR`, { avatar: avatarValue });
const presetAvatar = normalizeImageValue(avatarValue);
const currentAvatar = normalizeImageValue(current.avatarDataUrl);
const pendingAvatar = normalizeImageValue(resolvePendingAvatar(pendingChanges));
if (isGuild || (presetAvatar !== currentAvatar && presetAvatar !== pendingAvatar)) {
const avatarPayload =
typeof avatarValue === "string" && avatarValue.startsWith("data:")
? {
assetOrigin: "NEW_ASSET",
imageUri: avatarValue,
description: `profilesets-${preset.name ?? "preset"}`
}
: avatarValue;
setPending("AVATAR", { avatar: avatarPayload });
}
}
if ("bannerDataUrl" in preset && preset.bannerDataUrl !== current.bannerDataUrl) {
setPending(`${profilePrefix}_SET_PENDING_BANNER`, { banner: preset.bannerDataUrl });
setPending("BANNER", { banner: preset.bannerDataUrl });
}
if (!options.skipBio && "bio" in preset && preset.bio !== current.bio) {
if (!options.skipBio && preset?.bio !== current?.bio) {
const bioValue = preset.bio === null ? "" : preset.bio;
setPending(`${profilePrefix}_SET_PENDING_BIO`, { bio: bioValue });
setPending("BIO", { bio: bioValue });
}
if (!options.skipPronouns && "pronouns" in preset && preset.pronouns !== current.pronouns) {
if (!isGuild && !options.skipPronouns && preset?.pronouns !== current?.pronouns) {
const pronounsValue = preset.pronouns === null ? "" : preset.pronouns;
setPending(`${profilePrefix}_SET_PENDING_PRONOUNS`, { pronouns: pronounsValue });
setPending("PRONOUNS", { pronouns: pronounsValue });
}
if (!options.skipGlobalName && preset.globalName !== undefined && preset.globalName !== current.globalName) {
if (!options.skipGlobalName && preset?.globalName !== current?.globalName) {
if (isGuild) {
setPending("USER_SETTINGS_ACCOUNT_SET_PENDING_NICKNAME", { nickname: preset.globalName });
setPending("NICKNAME", { nickname: preset.globalName });
} else {
setPending("USER_SETTINGS_ACCOUNT_SET_PENDING_GLOBAL_NAME", { globalName: preset.globalName });
setPending("GLOBAL_NAME", { globalName: preset.globalName });
}
}
if (preset.avatarDecoration !== undefined && !jsonEq(preset.avatarDecoration, current.avatarDecoration)) {
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, {
if (preset.avatarDecoration && ![pendingAvatarDecoration, equippedAvatarDecoration, current.avatarDecoration].some(d => avatarDecorationEq(preset.avatarDecoration, d))) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 0, value: preset.avatarDecoration }
});
}
if (preset.profileEffect !== undefined && !jsonEq(preset.profileEffect, current.profileEffect)) {
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, {
if (preset.profileEffect && ![pendingProfileEffect, equippedProfileEffect, current.profileEffect].some(d => collectibleEqBySku(preset.profileEffect, d))) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 1, value: preset.profileEffect }
});
}
if (preset.nameplate !== undefined && !jsonEq(preset.nameplate, current.nameplate)) {
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, {
if (preset.nameplate && ![pendingNameplate, equippedNameplate, current.nameplate].some(d => nameplateEq(preset.nameplate, d))) {
setPending("COLLECTIBLES_ITEM", {
item: { type: 2, value: preset.nameplate }
});
}
if (preset.displayNameStyles !== undefined && !jsonEq(preset.displayNameStyles, current.displayNameStyles)) {
setPending(`${profilePrefix}_SET_PENDING_DISPLAY_NAME_STYLES`, { displayNameStyles: preset.displayNameStyles });
if (preset.displayNameStyles) {
const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles);
if (!jsonEq(presetDisplayNameStyles, current.displayNameStyles)) {
setPending("DISPLAY_NAME_STYLES", { displayNameStyles: presetDisplayNameStyles });
}
}
if (preset.themeColors !== undefined && !jsonEq(preset.themeColors, current.themeColors)) {
setPending(`${profilePrefix}_SET_PENDING_THEME_COLORS`, { themeColors: preset.themeColors });
if (preset.themeColors && !jsonEq(preset.themeColors, current.themeColors)) {
setPending("THEME_COLORS", { themeColors: preset.themeColors });
}
if (preset.primaryGuildId !== undefined && !isGuild && preset.primaryGuildId !== current.primaryGuildId) {
if (preset.primaryGuildId && !isGuild && preset.primaryGuildId !== current.primaryGuildId) {
dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId });
}
if (preset.customStatus !== undefined && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) {
const customStatusValue = preset.customStatus ?? {
text: "",
expiresAtMs: "0",
emojiId: "0",
emojiName: ""
};
if (preset.customStatus && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) {
CustomStatusSettings.updateSetting({
text: customStatusValue.text || "",
expiresAtMs: customStatusValue.expiresAtMs || "0",
emojiId: customStatusValue.emojiId || "0",
emojiName: customStatusValue.emojiName || ""
text: preset.customStatus?.text ?? "",
expiresAtMs: preset.customStatus?.expiresAtMs ?? "0",
emojiId: preset.customStatus?.emojiId ?? "0",
emojiName: preset.customStatus?.emojiName ?? ""
});
}
} catch (err) {

View file

@ -1007,6 +1007,8 @@ function getQuestAcceptedButtonText(quest: Quest, prepositional: boolean = false
}
function getQuestPanelPercentComplete({ quest, percentCompleteText }: { quest: Quest; percentCompleteText?: string; }): { percentComplete: number; } | { percentComplete: number; percentCompleteText: string; } | null {
if (!quest) { return null; }
const task = getQuestTask(quest);
if (!task) { return null; }
@ -1294,8 +1296,8 @@ export default definePlugin({
// Fixes the progress tracking for auto-completing Quests.
find: ",{progressTextAnimation:",
replacement: {
match: /(?<=children:\i}=)(\i)/,
replace: "Object.assign({},$1,$1.quest?$self.getQuestPanelPercentComplete($1):{})"
match: /(let{percentComplete:.{0,115}?children:\i}=)(\i)/,
replace: "const questifyProgress=$self.getQuestPanelPercentComplete({...$2,quest:$2.children?.props?.quest});$1Object.assign({},$2,questifyProgress??{})"
}
},
{
@ -1603,17 +1605,9 @@ export default definePlugin({
}
]
},
{
// Sets intervals to progress Video Quests in the background.
find: "CAPTCHA_FAILED:",
replacement: {
match: /(?<=SUCCESS:)(\i\({)/,
replace: "!$self.processQuestForAutoComplete(arguments[0])&&$1"
}
},
{
// Adds support for dev://experiment/2025-12-quest-cta-refactor-rollout
find: '"sm",preClickCallback:',
find: '"primary",preClickCallback:',
replacement: [
{
match: /(?=let{quest:)/,
@ -1624,11 +1618,19 @@ export default definePlugin({
replace: "!$self.processQuestForAutoComplete(arguments[0].quest)&&($1)"
},
{
match: /(?<=,text:)(?=\i)/,
replace: "questifyText??"
match: /(?<=,text:)(\i),icon:\i/,
replace: "questifyText??$1"
}
]
},
{
// Sets intervals to progress Video Quests in the background.
find: "CAPTCHA_FAILED:",
replacement: {
match: /(?<=SUCCESS:)(\i\({)/,
replace: "!$self.processQuestForAutoComplete(arguments[0])&&$1"
}
},
{
// Sets intervals to progress Play Game Quests in the background.
// Triggers if a Quest has already been started but was interrupted, such as by a reload.

View file

@ -12,11 +12,11 @@ import { getCurrentChannel } from "@utils/discord";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { Button, Menu, TextInput, UploadHandler, useEffect, useState } from "@webpack/common";
import { Button, IconUtils, Menu, TextInput, UploadHandler, useEffect, useState } from "@webpack/common";
import { QuoteIcon } from "./components/QuoteIcon";
import { QuoteFont } from "./types";
import { createQuoteImage, ensureFontLoaded, generateFileNamePreview, getFileExtension, getMimeType, resetFontLoading, sizeUpgrade } from "./utils";
import { createQuoteImage, ensureFontLoaded, generateFileNamePreview, getFileExtension, getMimeType, resetFontLoading } from "./utils";
const settings = definePluginSettings({
quoteFont: {
@ -105,7 +105,7 @@ function QuoteModal({ message, ...props }: ModalProps & { message: Message; }) {
const generateImage = async () => {
const image = await createQuoteImage({
avatarUrl: sizeUpgrade(message.author.getAvatarURL()),
avatarUrl: IconUtils.getUserAvatarURL(message.author, true, 512),
quote: message.content,
grayScale: gray,
author: message.author,

View file

@ -5,15 +5,20 @@
*/
import { User } from "@vencord/discord-types";
import { UserStore } from "@webpack/common";
import { IconUtils, UserStore } from "@webpack/common";
import { applyPalette, GIFEncoder, quantize } from "gifenc";
import { CANVAS_CONFIG, CanvasConfig, FONT_SIZES, FontSizeCalculation, QuoteFont, QuoteImageOptions, SPACING } from "./types";
export function sizeUpgrade(url: string): string {
const u = new URL(url);
u.searchParams.set("size", "512");
return u.toString();
const CUSTOM_EMOJI_REGEX = /<a?:(\w+):(\d+)>/g;
const CUSTOM_EMOJI_PLACEHOLDER = "\uFFFC";
const CUSTOM_EMOJI_SIZE_MULTIPLIER = 1.15;
const CUSTOM_EMOJI_BASELINE_OFFSET = 0.85;
interface CustomEmojiToken {
id: string;
name: string;
animated: boolean;
}
export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
@ -32,7 +37,7 @@ export async function fetchImageAsBlob(url: string): Promise<Blob> {
}
export function fixUpQuote(quote: string): string {
let result = quote.replace(/<a?:(\w+):(\d+)>/g, "");
let result = quote;
const mentionMatches = result.match(/<@!?\d+>/g);
if (mentionMatches) {
mentionMatches.forEach(match => {
@ -46,7 +51,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 {
@ -140,6 +146,89 @@ function drawGradientOverlay(ctx: CanvasRenderingContext2D, config: CanvasConfig
ctx.fillRect(config.height - SPACING.gradientWidth, 0, SPACING.gradientWidth, config.height);
}
function extractCustomEmojis(text: string): { text: string; emojis: CustomEmojiToken[]; } {
const emojis: CustomEmojiToken[] = [];
const cleanText = text.replace(CUSTOM_EMOJI_REGEX, (match, name: string, id: string) => {
emojis.push({
id,
name,
animated: match.startsWith("<a:")
});
return CUSTOM_EMOJI_PLACEHOLDER;
});
return { text: cleanText, emojis };
}
async function loadImageFromBlob(blob: Blob): Promise<HTMLImageElement> {
const image = new Image();
const blobUrl = URL.createObjectURL(blob);
try {
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("Failed to load image"));
image.src = blobUrl;
});
return image;
} finally {
URL.revokeObjectURL(blobUrl);
}
}
async function loadCustomEmojiImage(emoji: CustomEmojiToken): Promise<HTMLImageElement | null> {
const animatedVariants = emoji.animated ? [true, false] : [false];
for (const animated of animatedVariants) {
try {
const blob = await fetchImageAsBlob(IconUtils.getEmojiURL({
id: emoji.id,
animated,
size: 96
}));
return await loadImageFromBlob(blob);
} catch {
continue;
}
}
return null;
}
async function loadCustomEmojiImages(emojis: CustomEmojiToken[]): Promise<Map<string, HTMLImageElement>> {
if (!emojis.length) return new Map<string, HTMLImageElement>();
const unique = new Map<string, CustomEmojiToken>();
emojis.forEach(emoji => {
const key = `${emoji.id}:${emoji.animated ? "a" : "s"}`;
if (!unique.has(key)) unique.set(key, emoji);
});
const entries = await Promise.all(
Array.from(unique.entries()).map(async ([key, emoji]) => [key, await loadCustomEmojiImage(emoji)] as const)
);
return entries.reduce((acc, [key, image]) => {
if (image) acc.set(key, image);
return acc;
}, new Map<string, HTMLImageElement>());
}
function measureTextWithCustomEmojis(ctx: CanvasRenderingContext2D, text: string, fontSize: number): number {
const emojiSize = fontSize * CUSTOM_EMOJI_SIZE_MULTIPLIER;
let width = 0;
let start = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] !== CUSTOM_EMOJI_PLACEHOLDER) continue;
width += ctx.measureText(text.slice(start, i)).width;
width += emojiSize;
start = i + 1;
}
width += ctx.measureText(text.slice(start)).width;
return width;
}
function calculateTextLines(
ctx: CanvasRenderingContext2D,
text: string,
@ -153,12 +242,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 (measureTextWithCustomEmojis(ctx, word, fontSize) > maxWidth) {
if (currentLine.length) {
lines.push(currentLine.join(" "));
currentLine = [];
}
let chunk = "";
for (const char of word) {
const testChunk = chunk + char;
if (measureTextWithCustomEmojis(ctx, testChunk, fontSize) > 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 (measureTextWithCustomEmojis(ctx, testLine, fontSize) > maxWidth && currentLine.length) {
lines.push(currentLine.join(" "));
currentLine = [word];
} else {
currentLine.push(word);
}
}
});
@ -204,17 +312,52 @@ function drawQuoteText(
ctx: CanvasRenderingContext2D,
calculation: FontSizeCalculation,
font: QuoteFont,
config: CanvasConfig
config: CanvasConfig,
emojis: CustomEmojiToken[],
emojiImages: Map<string, HTMLImageElement>
): number {
ctx.fillStyle = "#fff";
ctx.font = `300 ${calculation.fontSize}px '${font}', sans-serif`;
const emojiSize = calculation.fontSize * CUSTOM_EMOJI_SIZE_MULTIPLIER;
let quoteY = (config.height - calculation.totalHeight) / 2;
let emojiIndex = 0;
calculation.lines.forEach(line => {
const xOffset = (config.quoteAreaWidth - ctx.measureText(line).width) / 2;
const xOffset = (config.quoteAreaWidth - measureTextWithCustomEmojis(ctx, line, calculation.fontSize)) / 2;
let x = config.quoteAreaX + xOffset;
let segmentStart = 0;
quoteY += calculation.lineHeight;
ctx.fillText(line, config.quoteAreaX + xOffset, quoteY);
for (let i = 0; i < line.length; i++) {
if (line[i] !== CUSTOM_EMOJI_PLACEHOLDER) continue;
const segment = line.slice(segmentStart, i);
if (segment) {
ctx.fillText(segment, x, quoteY);
x += ctx.measureText(segment).width;
}
const emoji = emojis[emojiIndex++];
if (emoji) {
const key = `${emoji.id}:${emoji.animated ? "a" : "s"}`;
const image = emojiImages.get(key);
if (image) {
const y = quoteY - emojiSize * CUSTOM_EMOJI_BASELINE_OFFSET;
ctx.drawImage(image, x, y, emojiSize, emojiSize);
} else {
ctx.fillText("□", x, quoteY);
}
}
x += emojiSize;
segmentStart = i + 1;
}
const tail = line.slice(segmentStart);
if (tail) {
ctx.fillText(tail, x, quoteY);
}
});
return quoteY;
@ -263,6 +406,8 @@ export async function createQuoteImage(options: QuoteImageOptions): Promise<Blob
await ensureFontLoaded();
const quote = fixUpQuote(rawQuote);
const { text: quoteText, emojis } = extractCustomEmojis(quote);
const emojiImages = await loadCustomEmojiImages(emojis);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get 2D rendering context");
@ -282,8 +427,8 @@ export async function createQuoteImage(options: QuoteImageOptions): Promise<Blob
drawGradientOverlay(ctx, CANVAS_CONFIG);
const calculation = calculateOptimalFontSize(ctx, quote, quoteFont, CANVAS_CONFIG);
const quoteEndY = drawQuoteText(ctx, calculation, quoteFont, CANVAS_CONFIG);
const calculation = calculateOptimalFontSize(ctx, quoteText, quoteFont, CANVAS_CONFIG);
const quoteEndY = drawQuoteText(ctx, calculation, quoteFont, CANVAS_CONFIG, emojis, emojiImages);
drawAuthorInfo(ctx, author, calculation, CANVAS_CONFIG, quoteEndY);
if (showWatermark && watermark) {

View file

@ -10,8 +10,8 @@ import { PaintbrushIcon } from "@components/Icons";
import { EquicordDevs } from "@utils/constants";
import { closeModal, openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { extractAndLoadChunksLazy, findStoreLazy } from "@webpack";
import { ChannelStore, DraftType, FluxDispatcher, Menu, SelectedChannelStore, UploadHandler } from "@webpack/common";
import { extractAndLoadChunksLazy } from "@webpack";
import { ChannelStore, DraftType, FluxDispatcher, Menu, PendingReplyStore, SelectedChannelStore, UploadHandler } from "@webpack/common";
import RemixModal from "./RemixModal";
import css from "./styles.css?managed";
@ -19,7 +19,6 @@ import css from "./styles.css?managed";
const requireCreateStickerModal = extractAndLoadChunksLazy([".CREATE_STICKER_MODAL,", "isDisplayingIndividualStickers"]);
const requireSettingsMenu = extractAndLoadChunksLazy(['type:"USER_SETTINGS_MODAL_OPEN"']);
const PendingReplyStore = findStoreLazy("PendingReplyStore");
const validMediaTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
const UploadContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {

View file

@ -150,7 +150,7 @@ export default definePlugin({
managedStyle,
patches: [
{
find: '"CustomStatusModalWithPreview"',
find: '="custom-status-input";',
replacement: {
match: /(?<=(\i)\?\.state\?\?.*?)\{text:\i\.\i\.\i\(\i\.\i#{intl::SAVE}\)/,
replace: "$self.renderRememberButton($1),$&"

View file

@ -26,7 +26,7 @@ export default definePlugin({
start() {
SettingsPlugin.customEntries.push({
key: "theme_library",
key: "equicord_theme_library",
title: "Theme Library",
Component: require("./components/ThemeTab").default,
Icon: ColorPaletteIcon

View file

@ -21,7 +21,7 @@ export async function authorizeUser(triggerModal: boolean = true) {
responseType="code"
redirectUri="https://themes.equicord.org/api/user/auth"
permissions={0n}
clientId="1257819493422465235"
clientId="1464006702125940736"
cancelCompletesFlow={false}
callback={async ({ location }: any) => {
if (!location) return logger.error("No redirect location returned");

View file

@ -24,11 +24,6 @@ const settings = definePluginSettings({
description: "Custom tone indicators (format: jk=Joking; srs=Serious)",
default: "",
},
maxIndicators: {
type: OptionType.NUMBER,
description: "Maximum number of tone indicators to show per message",
default: 5,
},
});
function getCustomIndicators(): Record<string, string> {
@ -91,15 +86,17 @@ function buildIndicatorRegex(): RegExp {
function splitTextWithIndicators(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let count = 0;
const regex = buildIndicatorRegex();
const prefix = settings.store.prefix || "/";
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) && count < settings.store.maxIndicators) {
while ((match = regex.exec(text))) {
const indicator = match[1];
const desc = getIndicator(indicator);
const fullMatch = match[0];
const leadingWhitespace = fullMatch.match(/^(\s*)/)?.[1] ?? "";
const matchStart = match.index;
const matchEnd = regex.lastIndex;
@ -108,6 +105,7 @@ function splitTextWithIndicators(text: string): ReactNode[] {
}
if (desc) {
if (leadingWhitespace) nodes.push(leadingWhitespace);
nodes.push(
<ToneIndicator
key={`ti-${matchStart}`}
@ -116,7 +114,6 @@ function splitTextWithIndicators(text: string): ReactNode[] {
desc={desc}
/>,
);
count++;
}
lastIndex = matchEnd;

View file

@ -0,0 +1,66 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { IconComponent } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { findExportedComponentLazy } from "@webpack";
import { ChannelStore, Menu } from "@webpack/common";
import { settings } from "./settings";
import { getResponse, handleResponse, parseMessageContent } from "./utils";
const RobotIconLazy = findExportedComponentLazy("RobotIcon");
const RobotIcon: IconComponent = props => <RobotIconLazy {...props} />;
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
const payload = parseMessageContent(message);
if (!payload) return;
const group = findGroupChildrenByChildId("copy-text", children);
if (!group) return;
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
<Menu.MenuItem
id="vc-trivia-ai"
label="Answer With AI"
icon={RobotIcon}
action={async () => {
const ans = await getResponse(payload);
handleResponse(message, ans);
}}
/>
));
};
export default definePlugin({
name: "TriviaAI",
description: "A plugin that helps you answer trivia questions using AI.",
authors: [EquicordDevs.yash],
settings,
contextMenus: {
"message": messageCtxPatch
},
messagePopoverButton: {
icon: RobotIcon,
render(message: Message) {
const payload = parseMessageContent(message);
if (!payload) return null;
return {
label: "Answer With AI",
icon: RobotIcon,
message,
channel: ChannelStore.getChannel(message.channel_id),
onClick: async () => {
const ans = await getResponse(payload);
handleResponse(message, ans);
}
};
}
}
});

View file

@ -0,0 +1,53 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const settings = definePluginSettings({
apiKey: {
type: OptionType.STRING,
description: "API Key.",
default: "",
placeholder: "Enter API Key here for your AI endpoint.",
componentProps: {
type: "password"
}
},
model: {
type: OptionType.STRING,
description: "AI Model to use.",
default: "google/gemini-3-flash-preview",
placeholder: "e.g. google/gemini-3-flash-preview, inception/mercury, openai/gpt-5.2-chat, etc."
},
systemPrompt: {
type: OptionType.STRING,
description: "System Prompt for the AI.",
default: "You are a helpful assistant who answers questions for the user in a concise and short way while using the least amount of words and punctuation.",
placeholder: "Enter system prompt."
},
maxTokens: {
type: OptionType.NUMBER,
description: "Maximum number of tokens in the response.",
default: 500
},
endpoint: {
type: OptionType.STRING,
description: "OpenAI Compatible AI Endpoint.",
default: "https://openrouter.ai/api/v1/chat/completions",
placeholder: "Enter your OpenAI compatible AI endpoint here."
},
autoRespond: {
type: OptionType.BOOLEAN,
description: "Automatically respond to messages on receiving a response.",
default: false
},
supportImages: {
type: OptionType.BOOLEAN,
description: "Pass images to the AI for context (if any). This is not supported by all models.",
default: true
}
});

View file

@ -0,0 +1,188 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { insertTextIntoChatInputBox, sendMessage } from "@utils/discord";
import { Logger } from "@utils/Logger";
import { Message } from "@vencord/discord-types";
import { showToast, Toasts } from "@webpack/common";
import { settings } from "./settings";
const logger = new Logger("TriviaAI");
type TextPart = {
type: "text";
text: string;
};
type ImagePart = {
type: "image_url";
image_url: {
url: string;
detail?: "auto" | "high" | "low";
};
};
export type ContentPayload = string | (TextPart | ImagePart)[];
export function parseMessageContent(message: Message): ContentPayload | null {
const textParts: string[] = [];
if (message.content && message.content.trim().length > 0) {
textParts.push(message.content);
}
message.embeds.forEach(embed => {
const embedBuffer: string[] = [];
const parts = [
embed.provider?.name ? `> ${embed.provider.name}` : null,
embed.author?.name ? `**${embed.author.name}**` : null,
embed.rawTitle ? `## ${embed.rawTitle}` : null,
embed.rawDescription ?? null,
...(embed.fields?.map(f => (f.rawName && f.rawValue) ? `**${f.rawName}**: ${f.rawValue}` : null) ?? []),
embed.footer?.text ? `_${embed.footer.text}_` : null,
];
parts.forEach(p => {
if (p) embedBuffer.push(p);
});
if (embedBuffer.length > 0) textParts.push(embedBuffer.join("\n"));
});
const combinedText = textParts.join("\n\n");
if (!settings.store.supportImages) {
return combinedText || null;
}
const imageUrls = new Set<string>();
message.attachments
.filter(att => att.content_type?.startsWith("image/"))
.forEach(att => imageUrls.add(att.url));
message.embeds.forEach(embed => {
const potentialUrls = [
embed.image?.url,
embed.thumbnail?.url,
...(embed.images?.map(img => img.url) ?? [])
];
potentialUrls.forEach(url => {
if (url) imageUrls.add(url);
});
});
if (imageUrls.size === 0) {
return combinedText || null;
}
const payload: (TextPart | ImagePart)[] = [];
if (combinedText.length > 0) {
payload.push({
type: "text",
text: combinedText
});
}
imageUrls.forEach(url => {
payload.push({
type: "image_url",
image_url: { url }
});
});
return payload;
}
export async function handleResponse(message: Message, response: string): Promise<string> {
if (settings.store.autoRespond) {
sendMessage(
message.channel_id,
{ content: response },
true,
{ messageReference: { channel_id: message.channel_id, message_id: message.id } }
);
return response;
} else {
insertTextIntoChatInputBox(response);
return response;
}
}
export async function getResponse(payload: ContentPayload): Promise<string> {
if (!settings.store.apiKey) {
showToast("TriviaAI: API Key is not set.", Toasts.Type.FAILURE);
return "";
}
if (!settings.store.endpoint) {
showToast("TriviaAI: Endpoint is not set.", Toasts.Type.FAILURE);
return "";
}
if (!settings.store.model) {
showToast("TriviaAI: Model is not set.", Toasts.Type.FAILURE);
return "";
}
try {
const req = await fetch(settings.store.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${settings.store.apiKey}`
},
body: JSON.stringify({
model: settings.store.model,
messages: [
{
role: "system",
content: settings.store.systemPrompt
},
{
role: "user",
content: payload
}
],
max_tokens: settings.store.maxTokens,
})
});
if (!req.ok) {
const errorBody = await req.text();
const errorMessage = `API request failed with status ${req.status}: ${errorBody}`;
logger.error(errorMessage);
showToast(errorMessage, Toasts.Type.FAILURE);
return "";
}
const data = await req.json();
if (data.error) {
const errorMessage = data.error.message ?? "An unknown error occurred";
logger.error(`API Error: ${errorMessage}`);
showToast(errorMessage, Toasts.Type.FAILURE);
return "";
}
const response = data.choices[0].message.content;
if (!response?.trim()) {
logger.warn("no response from AI model");
return "";
}
return response;
} catch (e) {
logger.error("Error getting response from AI model", e);
showToast("Error getting response from AI model", Toasts.Type.FAILURE);
return "";
}
}

View file

@ -31,8 +31,12 @@ export default definePlugin({
],
renderButtons(user: User) {
if (!user) return null;
const positionClass = settings.store.buttonPosition === "right"
? "voice-user-buttons-right"
: "voice-user-buttons-left";
return (
<Flex flexDirection="row" className="voice-user-buttons">
<Flex flexDirection="row" className={`voice-user-buttons ${positionClass}`}>
{settings.store.showChatButton && <UserChatButton user={user} />}
{settings.store.showMuteButton && <UserMuteButton user={user} />}
{settings.store.showDeafenButton && <UserDeafenButton user={user} />}

View file

@ -30,46 +30,47 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: true,
description: "Toggles their soundboard upon clicking deafen button.",
restartNeeded: false,
},
disableVideo: {
type: OptionType.BOOLEAN,
default: true,
description: "Toggles their video upon clicking deafen button.",
restartNeeded: false,
},
useServer: {
type: OptionType.BOOLEAN,
description: "Use server mute/deafen instead of local when you have permission.",
default: false,
restartNeeded: false,
},
serverSelf: {
type: OptionType.BOOLEAN,
description: "Deafen / Mute yourself on the server when using mute/deafen.",
default: false,
restartNeeded: false,
},
showButtonsSelf: {
type: OptionType.SELECT,
default: "display",
description: "Whether you want to display buttons for your own user. Same functionality as other button(s) except it'll open DMs panel and mute/deafen for yourself upon clicking button(s).",
restartNeeded: true,
options: [
{ label: "Display", value: "display" },
{ label: "Display", value: "display", default: true },
{ label: "Hide", value: "hide" },
{ label: "Disable", value: "disable" },
],
},
whichNameToShow: {
type: OptionType.SELECT,
default: "both",
description: "Choose whether to show nickname or username in tooltip.",
restartNeeded: false,
options: [
{ label: "Both", value: "both", default: true },
{ label: "Global Name", value: "global" },
{ label: "Default Username", value: "username" },
{ label: "Both", value: "both" },
{ label: "Username", value: "username" },
],
},
buttonPosition: {
type: OptionType.SELECT,
description: "Choose where to place voice buttons in the voice user row.",
options: [
{ label: "Left", value: "left", default: true },
{ label: "Right", value: "right" },
],
}
}, {

View file

@ -2,10 +2,19 @@
flex: none !important;
}
.voice-user-buttons-left {
order: -1;
}
.voice-user-buttons-right {
order: 1;
margin-left: 4px;
}
.voice-user-button-container {
padding-right: 4px;
}
div[class*='list'][class*='collapsed'] .voice-user-buttons {
display: none;
}
}

View file

@ -7,6 +7,7 @@
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { insertTextIntoChatInputBox } from "@utils/discord";
import definePlugin, { makeRange, OptionType } from "@utils/types";
import type { Channel } from "@vencord/discord-types";
import { GuildChannelStore, Menu, React, RestAPI, UserStore, VoiceStateStore } from "@webpack/common";
@ -48,6 +49,17 @@ function sendPatch(channel: Channel, body: Record<string, any>, bypass = false)
});
}
function mentionVoiceUsers(channel: Channel) {
const currentUserId = UserStore.getCurrentUser()?.id;
const mentions = Object.values(VoiceStateStore.getVoiceStatesForChannel(channel.id))
.map(state => state.userId)
.filter((userId, index, arr) => userId && userId !== currentUserId && arr.indexOf(userId) === index)
.map(userId => `<@${userId}>`);
if (mentions.length === 0) return;
insertTextIntoChatInputBox(`${mentions.join(" ")} `);
}
interface VoiceChannelContextProps {
channel: Channel;
}
@ -69,6 +81,13 @@ const VoiceChannelContext: NavContextMenuPatchCallback = (children, { channel }:
key="voice-tools"
id="voice-tools"
>
<Menu.MenuItem
key="voice-tools-mention-all"
id="voice-tools-mention-all"
label="Mention all Users"
action={() => mentionVoiceUsers(channel)}
/>
<Menu.MenuItem
key="voice-tools-disconnect-all"
id="voice-tools-disconnect-all"

View file

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

View file

@ -12,7 +12,7 @@ import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import type { Channel } from "@vencord/discord-types";
import { ChannelType } from "@vencord/discord-types/enums";
import { ChannelActions, ChannelStore, Menu, VoiceStateStore } from "@webpack/common";
import { ChannelActions, ChannelStore, Menu, PermissionsBits, PermissionStore, VoiceStateStore } from "@webpack/common";
let waitingChannelId: string | null = null;
@ -33,6 +33,7 @@ const settings = definePluginSettings({
const ChannelContext: NavContextMenuPatchCallback = (children, { channel }) => {
if (channel?.type !== ChannelType.GUILD_VOICE || !channel.userLimit) return;
if (PermissionStore.can(PermissionsBits.MOVE_MEMBERS, channel)) return;
if (Object.keys(VoiceStateStore.getVoiceStatesForChannel(channel.id)).length < channel.userLimit) return;
const isWaiting = waitingChannelId === channel.id;
@ -48,6 +49,7 @@ const ChannelContext: NavContextMenuPatchCallback = (children, { channel }) => {
function promptVoiceChannel(channel: Channel | null | undefined): boolean {
if (!channel || channel.type !== ChannelType.GUILD_VOICE || !channel.userLimit) return false;
if (PermissionStore.can(PermissionsBits.MOVE_MEMBERS, channel)) return false;
if (Object.keys(VoiceStateStore.getVoiceStatesForChannel(channel.id)).length < channel.userLimit) return false;
if (waitingChannelId === channel.id) return true;

View file

@ -22,7 +22,7 @@ import "./settings";
import { debounce } from "@shared/debounce";
import { IpcEvents } from "@shared/IpcEvents";
import { BrowserWindow, ipcMain, nativeTheme, shell, systemPreferences } from "electron";
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, shell, systemPreferences } from "electron";
import monacoHtml from "file://monacoWin.html?minify&base64";
import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs";
import { open, readdir, readFile, unlink } from "fs/promises";
@ -139,16 +139,17 @@ ipcMain.on(IpcEvents.GET_MONACO_THEME, e => {
e.returnValue = nativeTheme.shouldUseDarkColors ? "vs-dark" : "vs-light";
});
let monacoWin: BrowserWindow | null = null;
ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
const title = "Equicord QuickCSS Editor";
const existingWindow = BrowserWindow.getAllWindows().find(w => w.title === title);
if (existingWindow && !existingWindow.isDestroyed()) {
existingWindow.focus();
if (monacoWin && !monacoWin.isDestroyed()) {
monacoWin.show();
monacoWin.focus();
return;
}
const win = new BrowserWindow({
title,
monacoWin = new BrowserWindow({
title: "Equicord QuickCSS Editor",
autoHideMenuBar: true,
darkTheme: true,
webPreferences: {
@ -159,9 +160,28 @@ ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
}
});
makeLinksOpenExternally(win);
monacoWin.once("closed", () => { monacoWin = null; });
await win.loadURL(`data:text/html;base64,${monacoHtml}`);
makeLinksOpenExternally(monacoWin);
await monacoWin.loadURL(`data:text/html;base64,${monacoHtml}`);
});
app.on("before-quit", async event => {
if (monacoWin && !monacoWin.isDestroyed() && !monacoWin.isVisible()) {
const result = await dialog.showMessageBox({
type: "question",
buttons: ["Cancel", "Close Anyway"],
defaultId: 0,
title: "QuickCSS Editor Open",
message: "QuickCSS editor is still open in the background.",
detail: "Do you want to close Discord anyway? This will also close the QuickCSS editor."
});
if (result.response === 1) {
app.exit();
}
}
});
ipcMain.handle(IpcEvents.GET_RENDERER_CSS, () => readFile(RENDERER_CSS_PATH, "utf-8"));

View file

@ -91,7 +91,7 @@ async function loadBadges(url: string, noCache = false) {
async function loadAllBadges(noCache = false) {
const vencordBadges = await loadBadges("https://badges.vencord.dev/badges.json", noCache);
const equicordBadges = await loadBadges("https://equicord.org/badges.json", noCache);
const equicordBadges = await loadBadges("https://badge.equicord.org/badges.json", noCache);
DonorBadges = vencordBadges;
EquicordDonorBadges = equicordBadges;

View file

@ -15,7 +15,7 @@ export default definePlugin({
{
find: "#{intl::USER_PROFILE_PRONOUNS}",
replacement: {
match: /(?<=isVerifiedBot\(\)}\).{0,60})children:(\i)/,
match: /(?<=shouldUnderlineOnHover:null.{0,150})children:(\i)/,
replace: "children:[...Vencord.Api.NicknameIcons._renderIcons({userId:arguments[0].user?.id}),$1]"
}
}

View file

@ -75,7 +75,7 @@ export default definePlugin({
patches: [
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
find: ".WIDGETS_RTC_UPSELL_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::USER_PROFILE_ACCOUNT_POPOUT_BUTTON_A11Y_LABEL})/,
replace: "$&,onContextMenu:($self.grabRef($1),$self.openAccountPanelContextMenu)"
}
]

View file

@ -21,10 +21,10 @@ import "./style.css";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findStoreLazy } from "@webpack";
import { FluxDispatcher } from "@webpack/common";
import type { GuildFolder } from "@vencord/discord-types";
import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { ChannelStore, FluxDispatcher, ReadStateStore } from "@webpack/common";
import { ReactNode } from "react";
import FolderSideBar from "./FolderSideBar";
@ -35,15 +35,42 @@ enum FolderIconDisplay {
MoreThanOneFolderExpanded
}
type GuildTreeNode = {
id: string | number;
type?: string;
parentId?: string | number | null;
name?: string;
children?: GuildTreeNode[];
isBetterFoldersNested?: boolean;
[key: string]: unknown;
};
type FolderRenderProps = {
isBetterFolders?: boolean;
folderNode?: GuildTreeNode;
};
type FolderDragItem = {
type?: string;
nodeId: string | number;
};
type FolderMentionProps = {
mentionCount?: number;
folderNode?: GuildTreeNode;
};
export const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore");
export const SortedGuildStore = findStoreLazy("SortedGuildStore");
const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");
const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "folderButtonSize");
const MAX_TREE_FILTER_DEPTH = 1000;
let lastGuildId = null as string | null;
let dispatchingFoldersClose = false;
function getGuildFolder(id: string) {
return SortedGuildStore.getGuildFolders().find(folder => folder.guildIds.includes(id));
return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id));
}
function closeFolders() {
@ -52,22 +79,31 @@ function closeFolders() {
}
// Nuckyz: Unsure if this should be a general utility or not
function filterTreeWithTargetNode(children: any, predicate: (node: any) => boolean) {
function filterTreeWithTargetNode(children: any, predicate: (node: any) => boolean, visited = new WeakSet<object>(), depth = 0) {
if (children == null) {
return false;
}
if (depth > MAX_TREE_FILTER_DEPTH) {
return false;
}
if (typeof children === "object") {
if (visited.has(children)) return false;
visited.add(children);
}
if (!Array.isArray(children)) {
if (predicate(children)) {
return true;
}
return filterTreeWithTargetNode(children.props?.children, predicate);
return filterTreeWithTargetNode(children.props?.children, predicate, visited, depth + 1);
}
let childIsTargetChild = false;
for (let i = 0; i < children.length; i++) {
const shouldKeep = filterTreeWithTargetNode(children[i], predicate);
const shouldKeep = filterTreeWithTargetNode(children[i], predicate, visited, depth + 1);
if (shouldKeep) {
childIsTargetChild = true;
continue;
@ -79,6 +115,75 @@ function filterTreeWithTargetNode(children: any, predicate: (node: any) => boole
return childIsTargetChild;
}
function getNestedFolderMap(): Record<string, string> {
return settings.store.nestedFolders ?? {};
}
function saveNestedFolderMap(map: Record<string, string>) {
settings.store.nestedFolders = map;
}
export function getParentFolderId(childId: string | number): string | undefined {
return getNestedFolderMap()[childId.toString()];
}
export function getChildFolderIds(parentId: string | number): string[] {
const map = getNestedFolderMap();
return Object.entries(map)
.filter(([, pid]) => pid === String(parentId))
.map(([cid]) => cid);
}
function getDescendantFolderIds(parentId: string | number): string[] {
const descendants: string[] = [];
const queue = [...getChildFolderIds(parentId)];
while (queue.length) {
const current = queue.shift();
if (!current) continue;
descendants.push(current);
queue.push(...getChildFolderIds(current));
}
return descendants;
}
function nestFolder(childId: string, parentId: string) {
if (childId === parentId) return;
if (hasParentInChain(parentId, childId)) return;
const map = { ...getNestedFolderMap() };
delete map[childId];
map[childId] = parentId;
saveNestedFolderMap(map);
}
function unnestFolder(childId: string) {
const map = { ...getNestedFolderMap() };
if (map[childId] == null) return;
delete map[childId];
saveNestedFolderMap(map);
}
function hasParentInChain(childId: string, parentId: string): boolean {
const seen = new Set<string>();
let current = getParentFolderId(childId);
while (current != null && !seen.has(current)) {
if (current === parentId) return true;
seen.add(current);
current = getParentFolderId(current);
}
return false;
}
function areNestedRelated(firstId: string, secondId: string): boolean {
return hasParentInChain(firstId, secondId) || hasParentInChain(secondId, firstId);
}
export const settings = definePluginSettings({
sidebar: {
type: OptionType.BOOLEAN,
@ -132,8 +237,10 @@ export const settings = definePluginSettings({
{ label: "When more than one folder is expanded", value: FolderIconDisplay.MoreThanOneFolderExpanded }
],
restartNeeded: true
}
});
},
}).withPrivateSettings<{
nestedFolders: Record<string, string>;
}>();
const IS_BETTER_FOLDERS_VAR = "typeof isBetterFolders!=='undefined'?isBetterFolders:arguments[0]?.isBetterFolders";
const BETTER_FOLDERS_EXPANDED_IDS_VAR = "typeof betterFoldersExpandedIds!=='undefined'?betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds";
@ -146,6 +253,9 @@ export default definePlugin({
isModified: true,
settings,
start() {
settings.store.nestedFolders ??= {};
},
patches: [
{
@ -190,6 +300,14 @@ export default definePlugin({
}
]
},
{
find: '("guildsnav")',
predicate: () => !settings.store.sidebar,
replacement: {
match: /switch\((\i)\.type\){case \i\.\i\.FOLDER:.{0,800}?case \i\.\i\.GUILD:.{0,800}?default:return null}/,
replace: "return $self.wrapGuildNodeComponent($1,()=>{$&},false,void 0);"
}
},
{
// This is the parent folder component
find: ".toggleGuildFolderExpand(",
@ -248,6 +366,23 @@ export default definePlugin({
}
]
},
{
find: ".FOLDER_ITEM_ANIMATION_DURATION),",
replacement: [
{
match: /mentionCount:(\i),isMentionLowImportance:(\i)/,
replace: "mentionCount:$self.getFolderMentionCountWithNested(arguments[0]),isMentionLowImportance:$self.getFolderIsMentionLowImportanceWithNested(arguments[0],$2)"
},
{
match: /(\{id:\i,name:\i,children:\i\})=(\i),/,
replace: "$1=$self.getFolderNodeForRender($2),"
},
{
match: /(?<=gap:"xs",className:)(\i\.\i)/,
replace: "$self.getFolderGuildsListClassName(arguments[0],$1)"
}
]
},
{
find: "APPLICATION_LIBRARY,render:",
predicate: () => settings.store.sidebar,
@ -263,7 +398,7 @@ export default definePlugin({
replace: (m, conditions, props) => `${m},${conditions}$self.FolderSideBar(${props})`
},
{
// Add grid styles to fix aligment with other visual refresh elements
// Add grid styles to fix alignment with other visual refresh elements
match: /(?<=className:)\i\.\i(?=,"data-fullscreen")/,
replace: `"${GRID_STYLE_NAME} "+$&`
}
@ -277,6 +412,35 @@ export default definePlugin({
match: /(?<=onClick:\(\)=>{)(?=.{0,300}"discodo")/,
replace: "$self.closeFolders();"
}
},
{
find: "[GuildDropTarget]",
all: true,
replacement: [
{
match: /(\i)=!(\i)&&null==(\i)\.parentId/,
replace: "$1=$self.shouldShowCombineTarget($2,$3)"
},
{
match: /canDrop:\i=>\i\.nodeId.{0,100}==\i\.parentId\)/,
replace: "canDrop:e=>$self.canDropOnFolder(e,arguments[1],arguments[3])"
},
{
match: /drop\(\i\)\{(?=.{0,25}!==\i\.\i\.FOLDER)/,
replace: "$&if($self.handleFolderDrop(arguments[0],arguments[1],arguments[2],arguments[3]))return;"
},
{
match: /\[\i\.\i\.GUILD\](?=.{0,250}#{intl::DND_DROP_COMBINE})/,
replace: "[...$self.getFolderAcceptTypes(arguments[0]?.targetNode)]"
}
]
},
{
find: ".hasFetchedRequestToJoinGuilds)",
replacement: {
match: /return \i\.type!==\i\.\i\.GUILD/,
replace: "return $self.renderFolderChild(arguments[0],arguments[1],arguments[2])"
}
}
],
@ -308,10 +472,14 @@ export default definePlugin({
FluxDispatcher.wait(() => {
const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders();
const expandedId = data.folderId?.toString();
if (expandedFolders.size > 1) {
for (const id of expandedFolders) if (id !== data.folderId)
for (const id of expandedFolders) {
const folderId = id?.toString();
if (folderId === expandedId || areNestedRelated(folderId, expandedId)) continue;
FolderUtils.toggleGuildFolderExpand(id);
}
}
dispatchingFoldersClose = false;
@ -326,12 +494,219 @@ export default definePlugin({
FolderSideBar,
closeFolders,
getGuildMentionCount(guildId: string): number {
const mentionChannelIds = ReadStateStore.getMentionChannelIds() ?? [];
let count = 0;
for (const channelId of mentionChannelIds) {
const channel = ChannelStore.getChannel(channelId);
if (channel?.guild_id !== guildId) continue;
count += ReadStateStore.getMentionCount(channelId);
}
return count;
},
getFolderMentionMetaWithNested(props: FolderMentionProps | undefined): { mentionCount: number, hasNestedMention: boolean; } {
const folderNode = props?.folderNode;
const baseMentionCount = props?.mentionCount ?? 0;
if (folderNode?.id == null) {
return {
mentionCount: baseMentionCount,
hasNestedMention: false
};
}
const descendantIds = getDescendantFolderIds(folderNode.id);
if (descendantIds.length === 0) {
return {
mentionCount: baseMentionCount,
hasNestedMention: false
};
}
const tree = SortedGuildStore.getGuildsTree();
if (typeof tree?.getNode !== "function") {
return {
mentionCount: baseMentionCount,
hasNestedMention: false
};
}
const nestedGuildIds = new Set<string>();
for (const childFolderId of descendantIds) {
const stack = [...(tree.getNode(childFolderId)?.children ?? [])];
while (stack.length) {
const node = stack.pop();
if (!node) continue;
if (node.type === "guild") {
nestedGuildIds.add(node.id?.toString());
continue;
}
if (node.type === "folder" && Array.isArray(node.children)) {
stack.push(...node.children);
}
}
}
if (nestedGuildIds.size === 0) {
return {
mentionCount: baseMentionCount,
hasNestedMention: false
};
}
let nestedMentionCount = 0;
for (const guildId of nestedGuildIds) nestedMentionCount += this.getGuildMentionCount(guildId);
return {
mentionCount: baseMentionCount + nestedMentionCount,
hasNestedMention: nestedMentionCount > 0
};
},
getFolderMentionCountWithNested(props: FolderMentionProps | undefined): number {
return this.getFolderMentionMetaWithNested(props).mentionCount;
},
getFolderIsMentionLowImportanceWithNested(props: FolderMentionProps | undefined, originalFlag: boolean): boolean {
return this.getFolderMentionMetaWithNested(props).hasNestedMention ? false : originalFlag;
},
augmentFolderChildren(folderNode: GuildTreeNode, originalChildren: GuildTreeNode[]): GuildTreeNode[] {
const childIds = getChildFolderIds(folderNode.id);
if (childIds.length === 0) return originalChildren;
try {
const tree = SortedGuildStore.getGuildsTree();
if (typeof tree?.getNode !== "function") {
return originalChildren;
}
const existingIds = new Set(originalChildren.map(child => child?.id?.toString()));
const childFolderNodes: GuildTreeNode[] = [];
for (const id of childIds) {
if (existingIds.has(id?.toString())) continue;
const node = tree.getNode(id);
if (node) {
childFolderNodes.push({
...node,
parentId: folderNode.id,
isBetterFoldersNested: true
});
}
}
if (childFolderNodes.length === 0) return originalChildren;
return [...childFolderNodes, ...originalChildren];
} catch {
return originalChildren;
}
},
getFolderNodeForRender(folderNode: GuildTreeNode): GuildTreeNode {
const children = this.augmentFolderChildren(folderNode, folderNode.children ?? []);
if (children === folderNode.children) return folderNode;
return {
...folderNode,
children
};
},
getFolderGuildsListClassName(
props: FolderRenderProps | undefined,
baseClassName: string
): string {
if (props?.isBetterFolders) return baseClassName;
const folderNode = props?.folderNode;
if (folderNode == null) return baseClassName;
const children = this.getFolderNodeForRender(folderNode)?.children;
if (!Array.isArray(children)) return baseClassName;
return children.some(child => child?.type === "folder")
? `${baseClassName} vc-betterFolders-nested-parent-list`
: baseClassName;
},
renderFolderChild(node: GuildTreeNode, posInSet: number, setSize: number): ReactNode | null {
if (node?.type !== "folder") return null;
try {
if (!FolderItem) return null;
return (
<FolderItem
folderNode={node}
aria-setsize={setSize}
aria-posinset={posInSet}
/>
);
} catch {
return null;
}
},
handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean {
if (dragItem.type !== "folder") return false;
if (!isCombine) {
unnestFolder(dragItem.nodeId?.toString());
return false;
}
if (targetNode.type !== "folder") return false;
const childId = dragItem.nodeId?.toString();
const parentId = targetNode.id?.toString();
try {
nestFolder(childId, parentId);
FluxDispatcher.dispatch({ type: "BETTER_FOLDERS_NESTED_UPDATE" });
return true;
} catch {
return false;
}
},
shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean {
if (noCombine) return false;
return targetNode.parentId == null || targetNode.type === "folder";
},
getFolderAcceptTypes(targetNode: GuildTreeNode): string[] {
const GUILD = "guild";
const FOLDER = "folder";
if (targetNode?.type === FOLDER) return [GUILD, FOLDER];
return [GUILD];
},
canDropOnFolder(dragItem: FolderDragItem, targetNode: GuildTreeNode, isCombine: boolean): boolean {
if (dragItem.nodeId === targetNode.id) return false;
if (dragItem.type === "folder" && targetNode.type === "folder") {
if (isCombine) return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString());
return targetNode.parentId == null;
}
if (isCombine && dragItem.type === "folder") return false;
if (dragItem.type === "folder" && targetNode.parentId != null) return false;
return true;
},
wrapGuildNodeComponent(node: GuildTreeNode, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set<string | number>) {
if (node.type === "folder") {
const mappedParentId = getParentFolderId(node.id);
if (mappedParentId != null && (node.parentId?.toString() !== mappedParentId || node.isBetterFoldersNested !== true)) {
return (
<div style={{ display: "none" }}>
{originalComponent()}
</div>
);
}
}
wrapGuildNodeComponent(node: any, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
if (
!isBetterFolders ||
node.type === "folder" && expandedFolderIds?.has(node.id) ||
node.type === "guild" && expandedFolderIds?.has(node.parentId)
node.type === "guild" && node.parentId != null && expandedFolderIds?.has(node.parentId)
) {
return originalComponent();
}
@ -353,12 +728,10 @@ export default definePlugin({
// can cause hang if intl message is not found
const serversIntlMsg = getIntlMessage("SERVERS");
if (!serversIntlMsg) {
new Logger("BetterFolders").error("Failed to get SERVERS intl message");
return true;
}
return child?.props?.["aria-label"] === serversIntlMsg;
} catch (e) {
console.error(e);
} catch {
return true;
}
};
@ -372,14 +745,13 @@ export default definePlugin({
try {
return filterTreeWithTargetNode(child, child => child?.props?.renderTreeNode != null);
} catch (e) {
console.error(e);
} catch {
return true;
}
};
},
shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<string | number>) {
if (!isBetterFolders) {
return true;
}

View file

@ -2,6 +2,11 @@
grid-area: betterFoldersSidebar
}
.vc-betterFolders-nested-parent-list {
height: auto !important;
padding-bottom: 6px !important;
}
/* These area names need to be hardcoded. Only betterFoldersSidebar is added by the plugin. */
.vc-betterFolders-sidebar-grid {
/* stylelint-disable-next-line value-keyword-case */
@ -10,4 +15,4 @@
"titleBar titleBar titleBar titleBar"
"guildsList betterFoldersSidebar notice notice"
"guildsList betterFoldersSidebar channelsList page";
}
}

View file

@ -7,6 +7,7 @@
import { definePluginSettings } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles";
import { buildPluginMenuEntries, buildThemeMenuEntries } from "@equicordplugins/equicordToolbox/menu";
import SettingsPlugin from "@plugins/_core/settings";
import { Devs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger";
@ -17,7 +18,17 @@ import type { HTMLAttributes, ReactElement } from "react";
import fullHeightStyle from "./fullHeightContext.css?managed";
type SettingsEntry = { section: string, label: string; };
type SettingsEntry = {
ariaLabel?: string;
element?: any;
label?: string;
newIndicator?: any;
newIndicatorDismissibleContentTypes?: number[];
predicate?: () => boolean;
searchableTitles?: string[];
section: string,
tabPredicate?: () => boolean;
};
const cl = classNameFactory("");
const Classes = findCssClassesLazy("animating", "baseLayer", "bg", "layer", "layers");
@ -126,26 +137,46 @@ export default definePlugin({
],
predicate: () => settings.store.disableFade
},
{ // Disable fade animations for settings menu
find: '"data-mana-component":"layer-modal"',
replacement: [
{
match: /(\i)\.animated\.div(?=,\{"data-mana-component":"layer-modal")/,
replace: '"div"'
},
{
match: /(?<="data-mana-component":"layer-modal"[^}]*?)style:\i,/,
replace: "style:{},"
}
],
predicate: () => settings.store.disableFade
},
{ // Disable initial and exit delay for settings menu
find: "headerId:void 0,headerIdIsManaged:!1",
replacement: {
match: /let (\i)=300/,
replace: "let $1=0"
},
predicate: () => settings.store.disableFade
},
{ // Load menu TOC eagerly
find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}",
find: "handleOpenSettingsContextMenu=",
replacement: {
match: /(?=handleOpenSettingsContextMenu=.{0,100}?null!=\i&&.{0,100}?(await [^};]*?\)\)))/,
replace: "_vencordBetterSettingsEagerLoad=(async ()=>$1)();"
},
predicate: () => settings.store.eagerLoad
},
{
// Settings cog context menu
{ // Settings cog context menu
find: "#{intl::USER_SETTINGS_ACTIONS_MENU_LABEL}",
replacement: [
{
match: /=\[\];(\i)(?=\.forEach.{0,200}?"logout"===\i.{0,100}?(\i)\.get\(\i\))/,
replace: "=$self.wrapMap([]);$self.transformSettingsEntries($1,$2)",
predicate: () => settings.store.organizeMenu
},
{
match: /case \i\.\i\.DEVELOPER_OPTIONS:return \i;/,
replace: "$&case 'EquicordPlugins':return $self.buildPluginMenuEntries(true);$&case 'EquicordThemes':return $self.buildThemeMenuEntries();"
replace: "$&case 'EquicordPlugins':return $self.buildPluginMenuEntries(true);case 'EquicordThemes':return $self.buildThemeMenuEntries();"
}
]
},
@ -170,21 +201,49 @@ export default definePlugin({
},
transformSettingsEntries(list: SettingsEntry[], keyMap: Map<string, string>) {
const items = [] as TransformedSettingsEntry[];
const items: any[] = [];
const equicordItems: SettingsEntry[] = [
{ section: "EquicordSettings", label: "Settings" },
{ section: "EquicordPlugins", label: "Plugins" },
{ section: "EquicordThemes", label: "Themes" },
{ section: "EquicordUpdater", label: "Updater" },
{ section: "EquicordChangelog", label: "Changelog" },
{ section: "EquicordCloud", label: "Cloud" },
{ section: "EquicordBackupAndRestore", label: "Backup & Restore" },
{ section: "EquicordPatchHelper", label: "Patch Helper" },
];
for (const item of list) {
if (item.section === "HEADER") {
keyMap.set(item.label, item.label);
items.push({ section: item.label, items: [] });
} else if (item.section !== "DIVIDER" && keyMap.has(item.section)) {
items.at(-1)?.items.push(item);
for (const [section, key] of SettingsPlugin.settingsSectionMap) {
const entry = SettingsPlugin.customEntries.find(e => key.endsWith(e.key));
if (entry) equicordItems.push({ section, label: entry.title });
}
if (settings.store.organizeMenu) {
for (const item of list) {
if (!item.label || item.predicate != null && !item.predicate()) continue;
if (item.section === "HEADER") {
keyMap.set(item.label, item.label);
items.push({ section: item.label, items: [] });
} else if (item.section !== "DIVIDER" && keyMap.has(item.section)) {
items.at(-1)?.items.push(item);
}
}
keyMap.set("Equicord", "Equicord");
items.push({
section: "Equicord",
items: equicordItems
});
} else {
items.push(...list, ...equicordItems);
}
return items;
},
wrapMap(toWrap: TransformedSettingsEntry[]) {
if (!settings.store.organizeMenu) return toWrap;
// @ts-expect-error
toWrap.map = function (render: (item: SettingsEntry) => ReactElement<any>) {
return this

View file

@ -87,7 +87,7 @@ export default definePlugin({
},
// Current user area, at bottom of channels/dm list
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
find: ".WIDGETS_RTC_UPSELL_COACHMARK),",
replacement: [
// Use Decor avatar decoration hook
{
@ -99,7 +99,7 @@ export default definePlugin({
...[
'"Message Username"', // Messages
"#{intl::COLLECTIBLES_NAMEPLATE_PREVIEW_A11Y}", // Nameplate preview
"#{intl::ayozFl::raw}", // Avatar preview
"#{intl::COLLECTIBLES_PROFILE_PREVIEW_A11Y}", // Avatar preview
].map(find => ({
find,
replacement: {

View file

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

View file

@ -0,0 +1,170 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { Heading } from "@components/Heading";
import type { IPluginOptionComponentProps } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack";
import { TextInput, useState } from "@webpack/common";
import { settings } from ".";
type EmojiData = {
id?: string | null;
name: string;
};
type EmojiSelectPayload = {
id: string | null;
name: string;
animated?: boolean;
};
const StatusEmojiPicker = findComponentByCodeLazy("setCustomStatusEmoji", "setIsEmojiPickerOpen", "selectedDefaultStatus");
export const MAX_ADDITIONAL_REACT_EMOJIS = 8;
function getEmojiValue(emoji: EmojiData) {
return emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
}
function parseEmojiValue(value: string): EmojiSelectPayload | null {
const trimmed = value.trim();
if (!trimmed) return null;
const customEmoji = trimmed.match(/^:?([\w-]+):(\d+)$/);
if (customEmoji) {
return {
id: customEmoji[2],
name: customEmoji[1],
animated: false
};
}
return {
id: null,
name: trimmed,
animated: false
};
}
function parseEmojiList(value: string) {
return Array.from(new Set(
value
.split(/[\n,]/g)
.map(entry => entry.trim())
.filter(Boolean)
)).slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
}
export function ReactEmojiSetting({ setValue }: IPluginOptionComponentProps) {
const [emoji, setEmoji] = useState(settings.store.reactEmoji ?? "💀");
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
return (
<div>
<Heading>Select Emoji For Reactions</Heading>
<div
className="vc-message-click-actions-primary-emoji-picker"
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
>
<StatusEmojiPicker
customStatusEmoji={parseEmojiValue(emoji)}
setCustomStatusEmoji={(selectedEmoji: EmojiSelectPayload | null) => {
if (!selectedEmoji) {
setEmoji("");
setValue("");
setIsEmojiPickerOpen(false);
return;
}
const value = getEmojiValue(selectedEmoji);
setEmoji(value);
setValue(value);
setIsEmojiPickerOpen(false);
}}
selectedDefaultStatus={isEmojiPickerOpen ? null : "online"}
setIsEmojiPickerOpen={setIsEmojiPickerOpen}
defaultStatusVariant="small"
/>
</div>
</div>
);
}
export function AdditionalReactEmojisSetting({ setValue }: IPluginOptionComponentProps) {
const { addAdditionalReacts } = settings.use(["addAdditionalReacts"]);
const [emojiList, setEmojiList] = useState(settings.store.additionalReactEmojis ?? "");
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [pickerEmoji, setPickerEmoji] = useState<EmojiSelectPayload | null>(parseEmojiValue("😀"));
if (!addAdditionalReacts) return null;
return (
<div>
<Heading>Select Additional Emojis</Heading>
<div
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 12,
maxWidth: 420
}}
>
<TextInput
value={emojiList}
placeholder={`comma/newline separated, max ${MAX_ADDITIONAL_REACT_EMOJIS}`}
onChange={newValue => {
setEmojiList(newValue);
setValue(newValue);
const [first] = parseEmojiList(newValue);
setPickerEmoji(parseEmojiValue(first ?? "😀"));
}}
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
style={{
flex: 1
}}
/>
<div
onClick={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
style={{
display: "inline-flex",
transform: "scale(1.2)",
transformOrigin: "center"
}}
>
<StatusEmojiPicker
customStatusEmoji={pickerEmoji}
setCustomStatusEmoji={(selectedEmoji: EmojiSelectPayload | null) => {
if (!selectedEmoji) {
setIsEmojiPickerOpen(false);
return;
}
const value = getEmojiValue(selectedEmoji);
const parsed = parseEmojiList(emojiList);
const merged = parsed.includes(value)
? parsed
: [...parsed, value].slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
const nextValue = merged.join(", ");
setEmojiList(nextValue);
setValue(nextValue);
setPickerEmoji(selectedEmoji);
setIsEmojiPickerOpen(false);
}}
selectedDefaultStatus={isEmojiPickerOpen ? null : "online"}
setIsEmojiPickerOpen={setIsEmojiPickerOpen}
defaultStatusVariant="small"
/>
</div>
</div>
</div>
);
}

View file

@ -15,9 +15,14 @@ import type { Channel, Message } from "@vencord/discord-types";
import { ApplicationIntegrationType, MessageFlags } from "@vencord/discord-types/enums";
import { AuthenticationStore, Constants, EditMessageStore, FluxDispatcher, MessageActions, MessageTypeSets, PermissionsBits, PermissionStore, PinActions, RestAPI, Toasts, WindowStore } from "@webpack/common";
import { AdditionalReactEmojisSetting, MAX_ADDITIONAL_REACT_EMOJIS, ReactEmojiSetting } from "./ReactEmojiSetting";
type Modifier = "NONE" | "SHIFT" | "CTRL" | "ALT" | "BACKSPACE" | "DELETE";
type ClickAction = "NONE" | "DELETE" | "COPY_LINK" | "COPY_ID" | "COPY_CONTENT" | "COPY_USER_ID" | "EDIT" | "REPLY" | "REACT" | "OPEN_THREAD" | "OPEN_TAB" | "EDIT_REPLY" | "QUOTE" | "PIN";
const logger = new Logger("MessageClickActions");
const ADDITIONAL_REACTION_DELAY_MS = 300; // discord seems to rate limit this for 300ms but that might not be constant
const actions: { label: string; value: ClickAction; }[] = [
{ label: "None", value: "NONE" },
{ label: "Delete", value: "DELETE" },
@ -130,12 +135,7 @@ let mouseDownCount = 0;
let doubleClickDetected = false;
let secondMouseDownTime = 0;
const settings = definePluginSettings({
reactEmoji: {
type: OptionType.STRING,
description: "Emoji to use for react actions",
default: "💀"
},
export const settings = definePluginSettings({
singleClickAction: {
type: OptionType.SELECT,
description: "Action on single click (your messages)",
@ -190,6 +190,26 @@ const settings = definePluginSettings({
options: modifiers,
default: "NONE"
},
reactEmoji: {
type: OptionType.COMPONENT,
description: "Emoji to use for react actions.",
component: ReactEmojiSetting,
default: "💀"
},
addAdditionalReacts: {
type: OptionType.BOOLEAN,
description: "Also add additional configured reaction emojis",
default: false
},
additionalReactEmojis: {
type: OptionType.COMPONENT,
description: `Additional emojis to add when using React action (comma/newline separated, max ${MAX_ADDITIONAL_REACT_EMOJIS})`,
component: AdditionalReactEmojisSetting,
get hidden() {
return !settings.store.addAdditionalReacts;
},
default: ""
},
disableInDms: {
type: OptionType.BOOLEAN,
description: "Disable all click actions in direct messages",
@ -215,7 +235,7 @@ const settings = definePluginSettings({
deferDoubleClickForTriple: {
type: OptionType.BOOLEAN,
description: "Delay double-click to allow triple-click actions (disables triple-click when off)",
default: true
default: false
},
selectionHoldTimeout: {
type: OptionType.NUMBER,
@ -246,6 +266,51 @@ function showWarning(message: string) {
});
}
function clearClickTimeouts() {
if (doubleClickTimeout) {
clearTimeout(doubleClickTimeout);
doubleClickTimeout = null;
}
if (singleClickTimeout) {
clearTimeout(singleClickTimeout);
singleClickTimeout = null;
}
}
function resetClickState() {
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
}
function normalizeEmoji(emoji: string): string | null {
const trimmed = emoji.trim();
if (!trimmed) return null;
const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
if (customMatch) {
return `${customMatch[1]}:${customMatch[2]}`;
}
return trimmed;
}
function getConfiguredReactionEmojis() {
const baseEmoji = normalizeEmoji(settings.store.reactEmoji);
const configured = [baseEmoji];
if (settings.store.addAdditionalReacts) {
const extra = settings.store.additionalReactEmojis
.split(/[\n,]/g)
.map(normalizeEmoji)
.filter((emoji): emoji is string => Boolean(emoji))
.slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
configured.push(...extra);
}
return Array.from(new Set(configured.filter((emoji): emoji is string => Boolean(emoji))));
}
const canSend = (channel: Channel) =>
!channel.guild_id || PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel);
@ -260,19 +325,14 @@ const canReply = (msg: Message) =>
MessageTypeSets.REPLYABLE.has(msg.type) && !msg.hasFlag(MessageFlags.EPHEMERAL);
async function toggleReaction(channelId: string, messageId: string, emoji: string, channel: Channel, msg: Message) {
const trimmed = emoji.trim();
if (!trimmed) return;
const emojiParam = normalizeEmoji(emoji);
if (!emojiParam) return;
if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
showWarning("Cannot react: Missing permissions");
return;
}
const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
const emojiParam = customMatch
? `${customMatch[1]}:${customMatch[2]}`
: trimmed;
const hasReacted = msg.reactions?.some(r => {
const reactionEmoji = r.emoji.id
? `${r.emoji.name}:${r.emoji.id}`
@ -291,12 +351,47 @@ async function toggleReaction(channelId: string, messageId: string, emoji: strin
});
}
} catch (e) {
new Logger("MessageClickActions").error("Failed to toggle reaction:", e);
logger.error("Failed to toggle reaction:", e);
}
}
async function addReaction(channelId: string, messageId: string, emoji: string, channel: Channel) {
const emojiParam = normalizeEmoji(emoji);
if (!emojiParam) return;
if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
showWarning("Cannot react: Missing permissions");
return;
}
try {
await RestAPI.put({
url: Constants.Endpoints.REACTION(channelId, messageId, emojiParam, "@me")
});
} catch (e) {
logger.error("Failed to add reaction:", e);
}
}
async function reactWithConfiguredEmojis(channel: Channel, msg: Message) {
const [primaryEmoji, ...additionalEmojis] = getConfiguredReactionEmojis();
if (!primaryEmoji) return;
await toggleReaction(channel.id, msg.id, primaryEmoji, channel, msg);
for (const emoji of additionalEmojis) {
await new Promise<void>(resolve => setTimeout(resolve, ADDITIONAL_REACTION_DELAY_MS));
await addReaction(channel.id, msg.id, emoji, channel);
}
}
function getMessageLink(msg: Message, channel: Channel) {
const guildId = channel.guild_id ?? "@me";
return `${window.location.origin}/channels/${guildId}/${channel.id}/${msg.id}`;
}
function copyLink(msg: Message, channel: Channel) {
copyWithToast(`https://discord.com/channels/${channel.guild_id ?? "@me"}/${channel.id}/${msg.id}`, "Link copied!");
copyWithToast(getMessageLink(msg, channel), "Link copied!");
}
function togglePin(channel: Channel, msg: Message) {
@ -343,9 +438,7 @@ function quoteMessage(channel: Channel, msg: Message) {
}
function openInNewTab(msg: Message, channel: Channel) {
const guildId = channel.guild_id ?? "@me";
const link = `https://discord.com/channels/${guildId}/${channel.id}/${msg.id}`;
VencordNative.native.openExternal(link);
VencordNative.native.openExternal(getMessageLink(msg, channel));
}
function openInThread(msg: Message, channel: Channel) {
@ -462,7 +555,7 @@ async function executeAction(
break;
case "REACT":
await toggleReaction(channel.id, msg.id, settings.store.reactEmoji, channel, msg);
await reactWithConfiguredEmojis(channel, msg);
event.preventDefault();
break;
@ -484,7 +577,7 @@ async function executeAction(
export default definePlugin({
name: "MessageClickActions",
description: "Customize click actions on messages.",
authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite],
authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite, EquicordDevs.omaw],
isModified: true,
settings,
@ -502,18 +595,9 @@ export default definePlugin({
document.removeEventListener("mousedown", onMouseDown);
WindowStore.removeChangeListener(focusChanged);
if (doubleClickTimeout) {
clearTimeout(doubleClickTimeout);
doubleClickTimeout = null;
}
if (singleClickTimeout) {
clearTimeout(singleClickTimeout);
singleClickTimeout = null;
}
clearClickTimeouts();
pendingDoubleClickAction = null;
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
},
onMessageClick(msg, channel, event) {
@ -547,9 +631,7 @@ export default definePlugin({
if (Date.now() - lastMouseDownTime > settings.store.selectionHoldTimeout) {
pressedModifiers.clear();
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
return;
}
@ -560,9 +642,7 @@ export default definePlugin({
if (isTripleClick) {
if (!settings.store.deferDoubleClickForTriple) {
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
return;
}
if (doubleClickTimeout) {
@ -576,9 +656,7 @@ export default definePlugin({
pressedModifiers.clear();
}
doubleClickFired = false;
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
return;
}
@ -628,9 +706,7 @@ export default definePlugin({
event.preventDefault();
}
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
return;
}
@ -642,9 +718,7 @@ export default definePlugin({
executeAction(singleClickAction, msg, channel, event);
pressedModifiers.clear();
}
mouseDownCount = 0;
doubleClickDetected = false;
secondMouseDownTime = 0;
resetClickState();
};
const canDoubleClickWithCurrentModifier =

View file

@ -0,0 +1,7 @@
.vc-message-click-actions-primary-emoji-picker {
margin-top: 8px;
margin-left: 6px;
display: inline-flex;
transform: scale(1.3);
transform-origin: top left;
}

View file

@ -461,11 +461,11 @@ export default definePlugin({
},
},
{
find: 'className:"channelMention",children:',
find: 'getConfig({location:"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)/,
replace: (_, channel) => `&&!$self.isHiddenChannel(${channel})`
match: /(?<=getChannel\(\i\);if\(null!=(\i)).{0,200}?return void (?=\i\.default\.selectVoiceChannel)/,
replace: (m, channel) => `${m}!$self.isHiddenChannel(${channel})&&`
}
},
{

View file

@ -13,13 +13,14 @@ 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";
import definePlugin, { OptionType } from "@utils/types";
import { GuildMember, Message, User } from "@vencord/discord-types";
import { findByCodeLazy, findStoreLazy } from "@webpack";
import { ChannelStore, GuildMemberStore, GuildStore, Menu, MessageStore, RelationshipStore, StreamerModeStore, TextInput, useEffect, useState } from "@webpack/common";
import { ChannelStore, GuildMemberStore, GuildRoleStore, GuildStore, Menu, MessageStore, RelationshipStore, StreamerModeStore, TextInput, useEffect, useState } from "@webpack/common";
import { JSX } from "react";
const SMYNC = classNameFactory();
@ -28,7 +29,6 @@ const wrapEmojis = findByCodeLazy("lastIndex;return");
const adjustColor = findByCodeLazy("light1", "dark1", "toonStroke");
const AccessibilityStore = findStoreLazy("AccessibilityStore");
const colorPattern = /^#(?:[\da-f]{3}){1,2}$|^#(?:[\da-f]{4}){1,2}$|(rgb|hsl)a?\((\s*-?\d+%?\s*,){2}(\s*-?\d+%?\s*)\)|(rgb|hsl)a?\((\s*-?\d+%?\s*,){3}\s*(0|(0?\.\d+)|1)\)$/iu;
const roleColorPattern = /^role((?:\+|-)\d{0,4})?$/iu;
const symbolPattern = /^[\p{S}\p{P}]{1,3}$/iu;
const templatePattern = /(?:\{(?:custom|friend|nick|display|user)(?:,\s*(?:custom|friend|nick|display|user))*\})/iu;
@ -36,29 +36,59 @@ const templatePattern = /(?:\{(?:custom|friend|nick|display|user)(?:,\s*(?:custo
type CustomNicknameData = Record<string, string>;
let customNicknames: CustomNicknameData = {};
function adjustHex(color: string, percent: number): string {
let hex = color.replace("#", "");
let toCSSCache: Map<string, string | null> | null = null;
let toCSSProbe: HTMLDivElement | null = null;
if (hex.length === 3) {
hex = hex.split("").map(c => c + c).join("");
}
function toCSS(color: string | number | null | undefined): string | null {
if (color == null) return null;
if (typeof color === "number") return `#${color.toString(16).padStart(6, "0")}`;
if (!color) return null;
const num = parseInt(hex, 16);
let r = (num >> 16) & 0xFF;
let g = (num >> 8) & 0xFF;
let b = num & 0xFF;
const cached = toCSSCache?.get(color);
if (cached !== undefined) return cached ?? null;
if (!toCSSProbe) return null;
toCSSProbe.style.color = "";
toCSSProbe.style.color = color;
const result = toCSSProbe.style.color !== "" ? color : null;
toCSSCache?.set(color, result);
return result;
}
let convertToRGBCanvas: HTMLCanvasElement | null = null;
let convertToRGBCtx: CanvasRenderingContext2D | null = null;
let convertToRGBCache: Map<string, [number, number, number] | null> | null = null;
function convertToRGB(color: string): [number, number, number] | null {
const cached = convertToRGBCache?.get(color);
if (cached !== undefined) return cached ?? null;
if (!convertToRGBCanvas || !convertToRGBCtx) return null;
convertToRGBCtx.fillStyle = "#000000";
convertToRGBCtx.clearRect(0, 0, 1, 1);
convertToRGBCtx.fillStyle = color;
convertToRGBCtx.fillRect(0, 0, 1, 1);
const [r, g, b] = convertToRGBCtx.getImageData(0, 0, 1, 1).data;
const result: [number, number, number] = [r, g, b];
convertToRGBCache?.set(color, result);
return result;
}
function adjustBrightness(color: string, percent: number): string {
const rgb = convertToRGB(color);
if (!rgb) return color;
let [r, g, b] = rgb;
r = Math.max(0, Math.min(255, r + Math.round(r * (percent / 100))));
g = Math.max(0, Math.min(255, g + Math.round(g * (percent / 100))));
b = Math.max(0, Math.min(255, b + Math.round(b * (percent / 100))));
const newColor = ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0");
const hex = `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
if (hex === "#ffffff" || hex === "#000000") return color;
if (newColor === "ffffff" || newColor === "000000") {
return color;
}
return `#${newColor}`;
return hex;
}
function validColor(color: string) {
@ -73,66 +103,51 @@ 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
ircColorsEnabled: boolean,
isHovering: boolean,
): Record<string, any> | null {
if (!savedColor.trim()) { return null; }
const defaultColor = getComputedStyle(document.documentElement).getPropertyValue("--text-strong").trim() || null;
if (!defaultColor) { return null; }
savedColor = savedColor.trim() || defaultColor;
const isRoleColor = savedColor.toLowerCase().includes("role");
const forceDefault = !inGuild && !ircColorsEnabled && (isRoleColor ? !isHovering : false);
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")) {
if (isRoleColor) {
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 = 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")}`;
primaryColor = forceDefault ? defaultColor : (toCSS(colorStrings?.primaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[0])) || defaultColor);
secondaryColor = forceDefault ? null : (toCSS(colorStrings?.secondaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[1])) || null);
tertiaryColor = forceDefault ? null : (toCSS(colorStrings?.tertiaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[2])) || null);
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 = forceDefault ? defaultColor : toCSS(savedColor);
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));
}
}
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 +179,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 +315,8 @@ interface mentionProps {
interface messageProps {
message: Message | null | undefined;
colorString?: string;
colorStrings: colorStringsType;
userOverride?: User;
isRepliedMessage?: boolean;
withMentionPrefix?: boolean;
@ -307,12 +324,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 +340,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 +362,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 +381,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: author }, author: author, channel: 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 +437,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 +455,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 +470,39 @@ 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 authorTopRole = (author as GuildMember)?.guildId && (author as any)?.highestRoleId;
const authorTopRoleColor = authorTopRole && GuildRoleStore.getRole((author as GuildMember).guildId, authorTopRole)?.colorStrings;
const authorColorStrings = colorStrings || (author as any)?.colorStrings || authorTopRoleColor || 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 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 hasGradient = !!topRoleStyle?.gradient && Object.keys(topRoleStyle.gradient).length > 0;
const useTopStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
const topStyle = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, "Role", canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const hasGradient = !!topStyle?.gradient && Object.keys(topStyle.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, ircColorsEnabled, isHovering) : null;
const resolvedDisplayNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, displayNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedNicknameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, nicknameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedFriendNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, friendNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedCustomNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, customNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : 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 +598,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 +629,7 @@ function renderUsername(
}
const remainingNames = [first, second, third, fourth, fifth].filter(Boolean);
first = remainingNames.shift();
second = remainingNames.shift();
third = remainingNames.shift();
@ -591,53 +641,56 @@ 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 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;
const hoveringClass = (isHovering ? " smyn-gradient-hovered" : "");
const gradientClasses = useTopStyle
? "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 = (
<span
style={{
...topLevelStyle,
...(topRoleStyle?.normal.original || {})
...(topStyle?.normal.original || {})
}}
className="show-me-your-name-container"
className="smyn-container"
>
{mentionSymbol && <span>{mentionSymbol}</span>}
{(
<span
className={SMYNC(firstGroupClasses, { [gradientClasses]: shouldGradientGlow })}
data-text={shouldGradientGlow ? firstDataText : undefined}
style={(shouldGradientGlow && useTopRoleStyle && topRoleStyle ? topRoleStyle.gradient.animated : undefined) as React.CSSProperties}
style={(shouldGradientGlow && useTopStyle && topStyle ? topStyle.gradient.animated : undefined) as React.CSSProperties}
>
<span
className={SMYNC(firstNameClasses, {
@ -648,12 +701,12 @@ function renderUsername(
data-username-with-effects={needsEffectDataAttr && shouldShowEffect ? first.name : undefined}
style={shouldShowEffect
? undefined
: topRoleStyle ?
shouldAnimateGradients && topRoleStyle.gradient
? topRoleStyle.gradient.animated
: topRoleStyle.gradient
? topRoleStyle.gradient.static.original
: topRoleStyle.normal.original
: topStyle ?
shouldAnimateGradients && topStyle.gradient
? topStyle.gradient.animated
: topStyle.gradient
? topStyle.gradient.static.original
: topStyle.normal.original
: undefined
}>
{first.wrapped}</span>
@ -667,9 +720,7 @@ function renderUsername(
].map(({ name, dataText, groupClass, nameClass, position }) => name && (
<span
key={position}
className={SMYNC(groupClass, { [gradientClasses]: shouldGradientGlow && shouldAnimateSecondaryNames })}
data-text={shouldGradientGlow && dataText ? dataText : undefined}
style={(shouldGradientGlow && shouldAnimateSecondaryNames ? name.style.gradient.animated : undefined) as React.CSSProperties}
className={SMYNC(groupClass)}
>
<span style={affixColor as React.CSSProperties} className={prefixClasses}>
<span>{nameSeparator}</span>
@ -677,10 +728,12 @@ function renderUsername(
<span
// On non-primary names, allow disabling the effects completely, or just their animation & glow.
className={SMYNC(nameClass, {
[gradientClasses]: shouldGradientGlow && shouldAnimateSecondaryNames,
"smyn-effect-container": shouldShowEffect && !ignoreGradients,
[`smyn-effect-${effectType}`]: shouldShowEffect && !ignoreGradients,
"smyn-effect-animated": shouldAnimateEffect && shouldAnimateSecondaryNames
})}
data-text={shouldGradientGlow && dataText ? dataText : undefined}
data-username-with-effects={needsEffectDataAttr && shouldShowEffect && !ignoreGradients ? name.name : undefined}
style={{
...(ignoreFonts ? { "font-family": "var(--font-primary)", "letter-spacing": "normal" } : {}),
@ -814,7 +867,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
style={{ width: "100%" }}
/>
<TextButton
className="show-me-your-name-reset-button"
className="smyn-reset-button"
onClick={async () => {
setValue("");
delete customNicknames[user.id];
@ -826,7 +879,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
</TextButton>
<div style={{ paddingTop: "10px", flexGrow: 0 }}></div>
</ModalContent>
<ModalFooter className="show-me-your-name-modal-footer-container">
<ModalFooter className="smyn-modal-footer-container">
<Button
variant="primary"
onClick={async () => {
@ -895,10 +948,15 @@ const settings = definePluginSettings({
default: true,
description: "Display custom name format in mentions.",
},
typingIndicator: {
type: OptionType.BOOLEAN,
default: true,
description: "Display the first available name listed in your custom name format in the typing indicator.",
},
memberList: {
type: OptionType.BOOLEAN,
default: true,
description: "Display the first available name listed in your custom name format in the member list and DMs list.",
description: "Display the first available name listed in your custom name format in the members list, DMs list, and friends list.",
},
profilePopout: {
type: OptionType.BOOLEAN,
@ -943,7 +1001,7 @@ const settings = definePluginSettings({
ignoreGradients: {
type: OptionType.BOOLEAN,
default: true,
description: "For the non-primary names, if the role has a gradient or nitro effect, ignore it in favor of the color set below.",
description: "For the non-primary names, if the role has a gradient and the color below is set to \"Role+-#\", use the primary color instead of the whole gradient, and if it has a nitro effect, ignore it entirely."
},
animateGradients: {
type: OptionType.BOOLEAN,
@ -973,31 +1031,31 @@ const settings = definePluginSettings({
},
customNameColor: {
type: OptionType.STRING,
description: "The color to use for the custom name you assigned a user if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the custom name you assigned a user if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
friendNameColor: {
type: OptionType.STRING,
description: "The color to use for a friend's nickname if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for a friend's nickname if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
nicknameColor: {
type: OptionType.STRING,
description: "The color to use for the nickname if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the nickname if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
displayNameColor: {
type: OptionType.STRING,
description: "The color to use for the display name if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the display name if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
usernameColor: {
type: OptionType.STRING,
description: "The color to use for the username if it's not the first displayed. Leave blank for default. Accepts hex(a), rgb(a), or hsl(a) input. Use \"Role\" to follow the user's top role color. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
description: "The color to use for the username if it's not the first displayed. Accepts any valid CSS input. Use \"Role\" to follow the user's top role colors, nitro effect colors, or IRCColors color if enabled. Use \"Role+-#\" to adjust the brightness by that percentage (ex: \"Role+15\")",
default: "Role-25",
isValid: validColor,
},
@ -1012,7 +1070,7 @@ const settings = definePluginSettings({
export default definePlugin({
name: "ShowMeYourName",
description: "Display any permutation of custom nicknames, friend nicknames, server nicknames, display names, and usernames in chat.",
authors: [Devs.Rini, Devs.TheKodeToad, EquicordDevs.Etorix, Devs.sadan, Devs.prism],
authors: [EquicordDevs.Etorix, Devs.Rini, Devs.TheKodeToad, Devs.sadan, Devs.prism],
tags: ["SMYN", "Nicknames", "Custom Nicknames",],
isModified: true,
settings,
@ -1026,8 +1084,8 @@ export default definePlugin({
replacement: [
{
// Replace names in messages and replies.
match: /(onContextMenu:\i,children:)(.{0,250}?),"data-text":(\i\+\i)/,
replace: "$1$self.getMessageNameElement(arguments[0])??($2),\"data-text\":$self.getMessageNameText(arguments[0])??($3)"
match: /(?<=colorString:(\i),colorStrings:(\i).{0,900}?)style:.{0,120}?,(onClick:\i,onContextMenu:\i,children:)(.{0,250}?),"data-text":(\i\+\i)/,
replace: "$3$self.getMessageNameElement({...arguments[0],colorString:$1,colorStrings:$2})??($4),\"data-text\":$self.getMessageNameText(arguments[0])??($5)"
},
{
// Pass the message object to the should-animate checker.
@ -1036,12 +1094,37 @@ export default definePlugin({
}
]
},
{
// Replace names in the typing indicator.
find: "activityInviteEducationActivity:",
replacement: {
match: /(?=\i.\i.getName\((\i.guild_id),\i.id,(\i)\))/,
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({user:$2,type:\"typingIndicator\",guildId:$1})??"
},
},
{
// Replace names in DMs list.
find: "ImpressionNames.DM_LIST_RIGHT_CLICK_MENU_SHOWN",
replacement: {
match: /(?<=getMentionCount\(\i.id\)>0\),\i=)/,
replace: "$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??"
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??"
},
},
{
// Replace names in the friends list.
find: "hasUniqueUsername()}),usernameClass",
replacement: {
match: /(?<=nick:)(\i)/,
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,type:\"membersList\"})??$1"
},
},
{
// Don't block name style in friends list just
// because the name is the same as the username.
find: "location:\"DiscordTag\"});",
replacement: {
match: /(?<=forceUsername:(\i),.{0,550}?displayNameStyles:)\i!==\i\?(\i.displayNameStyles):null/,
replace: "!$1?$2:null"
},
},
{
@ -1049,7 +1132,7 @@ export default definePlugin({
find: "channel.isSystemDM(),",
replacement: {
match: /(?<=}\);)(return.{0,500}?{text:)(\i,position:"bottom",children:.{0,40}?children:)(\i\?\?\i.\i.getName\(\i\))/,
replace: "const smynName=arguments[0].channel.recipients.length===1?$self.getMemberListProfilesReactionsVoiceNameText({user:$self.UserStore.getUser(arguments[0].channel.recipients[0]),type:\"profilesPopout\"})??null:null;$1smynName??$2smynName??$3"
replace: "const smynName=arguments[0].channel.recipients.length===1?$self.getTypingMemberListProfilesReactionsVoiceNameText({user:$self.UserStore.getUser(arguments[0].channel.recipients[0]),type:\"profilesPopout\"})??null:null;$1smynName??$2smynName??$3"
},
},
{
@ -1090,7 +1173,7 @@ export default definePlugin({
find: "let{colorRoleName:",
replacement: {
match: /(let{colorRoleName:\i,colorString:\i,colorStrings:\i,)name:(\i)/,
replace: "$1showMeYourNameName:$2=$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??(arguments[0].name)"
replace: "$1showMeYourNameName:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??(arguments[0].name)"
}
},
{
@ -1098,7 +1181,7 @@ export default definePlugin({
find: "shouldWrap:!0,loop:!0,inProfile:!0",
replacement: {
match: /(tags:\i,)nickname:(\i)/,
replace: "$1showMeYourNameNickname:$2=$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"profilesPopout\"})??(arguments[0].nickname)"
replace: "$1showMeYourNameNickname:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"profilesPopout\"})??(arguments[0].nickname)"
},
},
{
@ -1109,11 +1192,11 @@ export default definePlugin({
replacement: [
{
match: /(displayName:)(\i.\i.getName\(void 0,void 0,\i\))/,
replace: "$1$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:null,type:\"profilesTooltip\"})??($2)"
replace: "$1$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:null,type:\"profilesTooltip\"})??($2)"
},
{
match: /(displayName:)(\i.\i.getName\(\i,\i,\i\))/,
replace: "$1$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].guildId,type:\"profilesTooltip\"})??($2)"
replace: "$1$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].guildId,type:\"profilesTooltip\"})??($2)"
}
]
},
@ -1122,7 +1205,7 @@ export default definePlugin({
find: "reactionTooltip1,",
replacement: {
match: /(\i.\i.getName\((\i),\i\?\.id,(\i)\))/,
replace: "$self.getMemberListProfilesReactionsVoiceNameText({user:$3,guildId:$2,type:\"reactionsTooltip\"})??($1)"
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({user:$3,guildId:$2,type:\"reactionsTooltip\"})??($1)"
}
},
{
@ -1137,7 +1220,7 @@ export default definePlugin({
{
// Replace names in reaction popouts.
match: /(?<=Child,{className:\i.\i,children:)/g,
replace: "($self.getMemberListProfilesReactionsVoiceNameElement({user:arguments[0].user,guildId:arguments[0].guildId,type:\"reactionsPopout\"}))??"
replace: "($self.getTypingMemberListProfilesReactionsVoiceNameElement({user:arguments[0].user,guildId:arguments[0].guildId,type:\"reactionsPopout\"}))??"
}
]
},
@ -1146,16 +1229,33 @@ export default definePlugin({
find: ",connectUserDragSource:",
replacement: {
match: /(serverDeaf:\i,)nick:(\i)/,
replace: "$1showMeYourNameVoice:$2=$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].channel.guild_id,type:\"voiceChannel\"})??(arguments[0].nick)"
replace: "$1showMeYourNameVoice:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].channel.guild_id,type:\"voiceChannel\"})??(arguments[0].nick)"
}
}
],
async start() {
toCSSCache = new Map();
toCSSProbe = document.createElement("div");
convertToRGBCanvas = document.createElement("canvas");
convertToRGBCanvas.width = convertToRGBCanvas.height = 1;
convertToRGBCtx = convertToRGBCanvas.getContext("2d", { willReadFrequently: true });
convertToRGBCache = new Map();
const data = await DataStore.get<CustomNicknameData>("SMYNCustomNicknames");
customNicknames = data ?? {};
},
stop() {
toCSSCache?.clear();
toCSSCache = null;
toCSSProbe = null;
convertToRGBCache?.clear();
convertToRGBCache = null;
convertToRGBCanvas = null;
convertToRGBCtx = null;
},
contextMenus: {
"user-context": userContextPatch
},
@ -1191,6 +1291,6 @@ export default definePlugin({
getMessageNameText,
getMessageNameElement,
getMentionNameElement,
getMemberListProfilesReactionsVoiceNameText,
getMemberListProfilesReactionsVoiceNameElement
getTypingMemberListProfilesReactionsVoiceNameText,
getTypingMemberListProfilesReactionsVoiceNameElement
});

View file

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

View file

@ -22,7 +22,7 @@ import { sendMessage } from "@utils/discord";
import definePlugin from "@utils/types";
import { Command } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher, MessageActions } from "@webpack/common";
import { FluxDispatcher, MessageActions, PendingReplyStore } from "@webpack/common";
interface Album {
id: string;
@ -55,7 +55,6 @@ interface Track {
}
const Spotify = findByPropsLazy("getPlayerState");
const PendingReplyStore = findByPropsLazy("getPendingReply");
function makeCommand(name: string, formatUrl: (track: Track) => string): Command {
return {

View file

@ -37,7 +37,7 @@ function toggleStreamerMode({ streamKey }: StreamEvent, value: boolean) {
export default definePlugin({
name: "StreamerModeOn",
description: "Automatically enables streamer mode when you start streaming in Discord",
authors: [Devs.Kodarru],
authors: [Devs.IcedMarina],
flux: {
STREAM_CREATE: d => toggleStreamerMode(d, true),
STREAM_DELETE: d => toggleStreamerMode(d, false)

View file

@ -225,6 +225,7 @@ export const DeeplLanguages = {
"it": "Italian",
"ja": "Japanese",
"jv": "Javanese",
"ko": "Korean",
"pam": "Kapampangan",
"kk": "Kazakh",
"kmr": "Kurdish (Kurmanji)",

View file

@ -122,7 +122,7 @@ export default definePlugin({
patches: [
{
find: "#{intl::Q8lUnE::raw}",
find: "#{intl::SEVERAL_USERS_TYPING_STRONG}",
group: true,
replacement: [
{

View file

@ -15,7 +15,7 @@ export default definePlugin({
authors: [Devs.puv],
patches: [
{
find: "#{intl::LgCPMt::raw}",
find: "#{intl::VOICE_MESSAGES_PLAYBACK_RATE_LABEL}",
replacement: {
match: /(?<=onVolumeHide:\i\}\))/,
replace: ",$self.renderDownload(arguments[0].src)"

View file

@ -55,16 +55,21 @@ export default definePlugin({
patches: [
// Change the max volume for sliders to allow for values above 200
...[
"#{intl::USER_VOLUME}",
"currentVolume:"
].map(find => ({
find,
{
find: "#{intl::USER_VOLUME}",
replacement: {
match: /(?<=maxValue:)\i\.\i\?(\d+?):(\d+?)(?=,)/,
replace: (_, higherMaxVolume, minorMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
match: /(?<=maxValue:)\i\.isPlatformEmbedded\?(\i\.\i):\i\.\i(?=,)/,
replace: (_, higherMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
}
})),
},
// Change the max volume for sliders to allow for values above 200
{
find: "currentVolume:",
replacement: {
match: /(?<=maxValue:)\i\.\i\?(\d+?):\d+?(?=,)/,
replace: (_, higherMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
}
},
// Patches needed for web/vesktop
{
find: "streamSourceNode",

View file

@ -32,7 +32,7 @@ export class Logger {
constructor(public name: string, public color: string = "white") { }
private _log(level: "log" | "error" | "warn" | "info" | "debug", levelColor: string, args: any[], customFmt = "") {
if (IS_REPORTER && IS_WEB && !IS_VESKTOP) {
if (IS_REPORTER && IS_WEB && !IS_VESKTOP && !IS_EQUIBOP) {
console[level]("[Equicord]", this.name + ":", ...args);
return;
}

View file

@ -466,7 +466,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
},
nin0dev: {
name: "nin0dev",
id: 886685857560539176n
id: 1395533040914141235n
},
Elvyra: {
name: "Elvyra",
@ -496,9 +496,9 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "puv",
id: 469441552251355137n
},
Kodarru: {
name: "Kodarru",
id: 785227396218748949n
IcedMarina: {
name: "icedmarina",
id: 594406131670188042n
},
nakoyasha: {
name: "nakoyasha",
@ -1255,7 +1255,11 @@ export const EquicordDevs = Object.freeze({
davri: {
name: "Davri",
id: 457579346282938368n,
}
},
yash: {
name: "yash",
id: 889150838658977874n,
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly

View file

@ -19,7 +19,7 @@
export function relaunch() {
if (IS_DISCORD_DESKTOP)
window.DiscordNative.app.relaunch();
else if (IS_VESKTOP)
else if (IS_VESKTOP || IS_EQUIBOP)
window.VesktopNative.app.relaunch();
else
location.reload();

View file

@ -82,7 +82,7 @@ export let PopoutWindowStore: t.PopoutWindowStore;
export let ApplicationCommandIndexStore: t.ApplicationCommandIndexStore;
export let EditMessageStore: t.EditMessageStore;
export let QuestStore: t.QuestStore;
export let PendingReplyStore: GenericStore;
export let PendingReplyStore: t.PendingReplyStore;
export let ExperimentStore: GenericStore;
/**

View file

@ -142,7 +142,7 @@ export const UserUtils = {
export const UploadManager = findByPropsLazy("clearAll", "addFile");
export const UploadHandler = {
promptToUpload: findByCodeLazy("Unexpected mismatch between files and file metadata") as (files: File[], channel: t.Channel, draftType: Number) => void
promptToUpload: findByCodeLazy("Unexpected mismatch between files and file metadata") as (files: File[], channel: t.Channel, draftType: Number) => Promise<void>
};
export const ApplicationAssetUtils = mapMangledModuleLazy("getAssetImage: size must === [", {
@ -250,3 +250,7 @@ export const MessageTypeSets: t.MessageTypeSets = findByPropsLazy("REPLYABLE", "
export const fetchApplicationsRPC = findByCodeLazy('"Invalid Origin"', ".application");
export const CloudUploader = findLazy(m => m.prototype?.trackUploadFinished) as typeof t.CloudUpload;
export const URLUtils: t.URLUtils = findByPropsLazy("URL_REGEX", "makeUrl", "isDiscordUrl");
export const Humanize: t.Humanize = findByPropsLazy("filesize", "relativeTime", "ordinal");
export const EmojiUtils: t.EmojiUtils = findByPropsLazy("getEmojiColors", "getURL");