Merge branch 'dev' into bun

This commit is contained in:
thororen1234 2026-02-26 11:37:40 -05:00
commit aad290c9a3
No known key found for this signature in database
91 changed files with 6201 additions and 2111 deletions

View file

@ -1,12 +1,22 @@
have_fun: false
memory_config:
disabled: false
code_review: code_review:
disable: false disable: false
comment_severity_threshold: LOW comment_severity_threshold: MEDIUM
max_review_comments: -1 max_review_comments: -1
pull_request_opened: pull_request_opened:
help: false help: false
summary: false summary: false
code_review: false code_review: false
include_drafts: 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: 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.
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"
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: ## Plugin Acceptance (Instant Reject)
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}
pluginInterop: If a plugin breaks ANY of these, reject the entire PR. These are non-negotiable:
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();
reference: 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.
types: Channel, Guild, GuildMember, User, Role, Message from @vencord/discord-types 2. **No simple text replacement plugins.** The built-in TextReplace plugin already covers this.
components: Paragraph, BaseText, Flex, Button, ErrorCard from @components/ 3. **No raw DOM manipulation.** Always use patches and React.
settings: use definePluginSettings from @api/Settings, not inline settings object 4. **No FakeDeafen or FakeMute.**
utilities: 5. **No StereoMic-related plugins.**
clipboard: copyToClipboard from @utils/clipboard 6. **No UI-only hide/redesign plugins.** Use CSS for that. (Negotiable in rare cases.)
discord: insertTextIntoChatInputBox, getCurrentChannel, getCurrentGuild, getIntlMessage, openUserProfile, openPrivateChannel, sendMessage, copyWithToast, getUniqueUsername, fetchUserProfile from @utils/discord 7. **No plugins targeting specific third-party Discord bots.** Official Discord apps are fine.
css: classNameFactory, classNameToSelector from @utils/css 8. **No selfbot or API abuse.** Auto-replies, animated statuses, message pruning, Nitro snipers, etc.
misc: classes, sleep, isObject, isObjectEmpty, pluralise, parseUrl, identity from @utils/misc 9. **No untrusted third-party APIs.** Well-known services (Google, GitHub) are acceptable.
text: formatDuration, formatDurationMs, humanFriendlyJoin, makeCodeblock, toInlineCode, escapeRegExp from @utils/text 10. **No plugins requiring users to provide their own API keys.**
modal: openModal, closeModal, ModalRoot, ModalHeader, ModalContent, ModalCloseButton from @utils/modal 11. **No new dependencies** unless strictly necessary and well justified.
margins: Margins from @utils/margins (Margins.top8, Margins.bottom16, etc.)
guards: isTruthy, isNonNullish from @utils/guards ---
web: saveFile, chooseFile from @utils/web
webpackCommon: ## Plugin Structure
icons: IconUtils from @webpack/common, NEVER hardcode cdn.discordapp.com URLs
getUserAvatarURL: IconUtils.getUserAvatarURL(user, canAnimate?, size?) Default export via `definePlugin` from `@utils/types`. Non-negotiable.
getDefaultAvatarURL: IconUtils.getDefaultAvatarURL(id)
getUserBannerURL: IconUtils.getUserBannerURL({ id, banner, canAnimate?, size }) ```typescript
getGuildIconURL: IconUtils.getGuildIconURL({ id, icon, size?, canAnimate? }) import definePlugin from "@utils/types";
getGuildBannerURL: IconUtils.getGuildBannerURL(guild, canAnimate?) import { EquicordDevs } from "@utils/constants";
getChannelIconURL: IconUtils.getChannelIconURL({ id, icon })
getEmojiURL: IconUtils.getEmojiURL({ id, animated, size }) export default definePlugin({
getApplicationIconURL: IconUtils.getApplicationIconURL(data) name: "PluginName", // PascalCase, matches directory name
getGameAssetURL: IconUtils.getGameAssetURL(data) description: "Does something.", // Capital first, period at end
stores: UserStore, GuildStore, ChannelStore, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, PresenceStore, RelationshipStore, MessageStore, EmojiStore, ThemeStore, PermissionStore, VoiceStateStore from @webpack/common authors: [EquicordDevs.Name], // EquicordDevs for new, Devs for upstream
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 **Settings** must use `definePluginSettings` from `@api/Settings`. Reject inline `options` objects.
imports:
lazy: findByPropsLazy, findStoreLazy, findComponentByCodeLazy, findExportedComponentLazy from @webpack ```typescript
common: useState, useEffect, useCallback, useStateFromStores from @webpack/common // GOOD
neverHardcode: const settings = definePluginSettings({
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 myOption: { type: OptionType.BOOLEAN, description: "Enables the feature.", default: true }
apiEndpoints: NEVER hardcode "/api/v9", "/users/@me" ⟹ use Constants.Endpoints or RestAPI });
console: NEVER use console.log/warn/error ⟹ use Logger from @utils/Logger export default definePlugin({ settings, /* ... */ });
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) // 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, setUpdateState: NOOP,
onCheckUpdates: NOOP, onCheckUpdates: NOOP,
onRepair: NOOP, onRepair: NOOP,
onAbout: NOOP,
}, },
}; };

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -39,7 +39,7 @@ export class User extends DiscordRecord {
get avatarDecoration(): AvatarDecorationData | null; get avatarDecoration(): AvatarDecorationData | null;
get createdAt(): Date; get createdAt(): Date;
get isProvisional(): boolean; 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 premiumGroupRole(): { id: string; name: string; color: number; } | null;
get tag(): string; get tag(): string;

View file

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

View file

@ -411,6 +411,24 @@ export type MaskedLink = ComponentType<PropsWithChildren<{
channelId?: string; 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 { export interface ScrollerBaseProps {
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
@ -418,6 +436,7 @@ export interface ScrollerBaseProps {
paddingFix?: boolean; paddingFix?: boolean;
onClose?(): void; onClose?(): void;
onScroll?(): void; onScroll?(): void;
ref?: Ref<ScrollerBaseRef>;
} }
export type ScrollerThin = ComponentType<PropsWithChildren<ScrollerBaseProps & { export type ScrollerThin = ComponentType<PropsWithChildren<ScrollerBaseProps & {
@ -448,9 +467,9 @@ export type ListScrollerThin = ComponentType<ScrollerBaseProps & {
renderSidebar?: (listVisible: boolean, sidebarVisible: boolean) => React.ReactNode; renderSidebar?: (listVisible: boolean, sidebarVisible: boolean) => React.ReactNode;
wrapSection?: (section: number, children: React.ReactNode) => React.ReactNode; wrapSection?: (section: number, children: React.ReactNode) => React.ReactNode;
sectionHeight: number; sectionHeight: number | ((section: number) => number);
rowHeight: number; rowHeight: number | ((section: number, row: number) => number);
footerHeight?: number; footerHeight?: number | ((section: number) => number);
sidebarHeight?: number; sidebarHeight?: number;
chunkSize?: 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 "./MessageStore";
export * from "./NotificationSettingsStore"; export * from "./NotificationSettingsStore";
export * from "./OverridePremiumTypeStore"; export * from "./OverridePremiumTypeStore";
export * from "./PendingReplyStore";
export * from "./PermissionStore"; export * from "./PermissionStore";
export * from "./PopoutWindowStore"; export * from "./PopoutWindowStore";
export * from "./PresenceStore"; export * from "./PresenceStore";

View file

@ -25,14 +25,29 @@ export type Parser = Record<
| "parse" | "parse"
| "parseTopic" | "parseTopic"
| "parseEmbedTitle" | "parseEmbedTitle"
| "parseEmbedTitleWithoutLinks"
| "parseInlineReply" | "parseInlineReply"
| "parseGuildVerificationFormRule" | "parseGuildVerificationFormRule"
| "parseGuildEventDescription" | "parseGuildEventDescription"
| "parseAutoModerationSystemMessage" | "parseAutoModerationSystemMessage"
| "parseForumPostGuidelines" | "parseForumPostGuidelines"
| "parseForumPostMostRecentMessage", | "parseVoiceChannelStatus",
(content: string, inline?: boolean, state?: Record<string, any>) => ReactNode[] (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 { export interface Alerts {
show(alert: { show(alert: {
@ -48,16 +63,39 @@ export interface Alerts {
onConfirmSecondary?(): void; onConfirmSecondary?(): void;
onCloseCallback?(): void; onCloseCallback?(): void;
}): 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. */ /** This is a noop, it does nothing. */
close(): void; close(): void;
} }
export interface SnowflakeUtils { export interface SnowflakeUtils {
fromTimestamp(timestamp: number): string; fromTimestamp(timestamp: number): string;
fromTimestampWithSequence(timestamp: number, sequence: number): string;
extractTimestamp(snowflake: string): number; extractTimestamp(snowflake: string): number;
age(snowflake: string): number; age(snowflake: string): number;
atPreviousMillisecond(snowflake: string): string; atPreviousMillisecond(snowflake: string): string;
atNextMillisecond(snowflake: string): string;
compare(snowflake1?: string, snowflake2?: string): number; 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 { interface RestRequestData {
@ -249,45 +287,55 @@ export interface UsernameUtils {
useName(user: User): string; useName(user: User): string;
getUserTag(user: User, options?: UserNameUtilsTagOptions): string; getUserTag(user: User, options?: UserNameUtilsTagOptions): string;
useUserTag(user: User, options?: UserNameUtilsTagOptions): string; useUserTag(user: User, options?: UserNameUtilsTagOptions): string;
isNameConcealed(user: User): boolean;
getUserIsStaff(user: User): boolean;
useDirectMessageRecipient: any; useDirectMessageRecipient: any;
humanizeStatus: any; humanizeStatus: any;
} }
// TODO: fix type
export class DisplayProfile { export class DisplayProfile {
userId: string; userId: string;
guildId?: string;
banner?: string; banner?: string;
bio?: string; bio?: string;
pronouns?: string; pronouns?: string;
accentColor?: number; accentColor?: number;
themeColors?: number[]; themeColors?: number[];
profileEffect?: any;
popoutAnimationParticleType?: any; popoutAnimationParticleType?: any;
profileEffectId?: string; fetchStartedAt?: Date | null;
fetchEndedAt?: Date | null;
_userProfile?: any; _userProfile?: any;
_guildMemberProfile?: 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<{ getBadges(): Array<{
id: string; id: string;
description: string; description: string;
icon: string; icon: string;
link?: string; link?: string;
}>; }>;
getBannerURL(options: { canAnimate: boolean; size: number; }): string;
getLegacyUsername(): string | null; getLegacyUsername(): string | null;
hasFullProfile(): boolean;
hasPremiumCustomization(): boolean;
hasThemeColors(): boolean;
isUsingGuildMemberBanner(): boolean;
isUsingGuildMemberBio(): boolean;
isUsingGuildMemberPronouns(): boolean;
} }
export interface DisplayProfileUtils { export interface DisplayProfileUtils {
@ -317,3 +365,51 @@ export interface CommandOptions {
max_value?: number; max_value?: number;
autocomplete?: boolean; 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), setUpdateState: (available: boolean) => ipcRenderer.send(IpcEvents.SET_TRAY_UPDATE_STATE, available),
onCheckUpdates: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_CHECK_UPDATES, cb); }, onCheckUpdates: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_CHECK_UPDATES, cb); },
onRepair: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_REPAIR, cb); }, onRepair: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_REPAIR, cb); },
onAbout: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_ABOUT, cb); },
}, },
pluginHelpers: PluginHelpers pluginHelpers: PluginHelpers

View file

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

View file

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

View file

@ -54,76 +54,77 @@ export default definePlugin({
settings, settings,
patches: [ patches: [
{ {
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}", find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
replacement: {
match: /,nameplate:\i,selectedGuildId:(\i),avatarDecoration/,
replace: ",nameplate:null,selectedGuildId:$1,avatarDecoration"
},
predicate: () => settings.store.removeNameplate
},
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
replacement: [ replacement: [
{ {
match: /(?<=#{intl::MUTE}\),)className:\i\.\i,/, match: /,nameplate:\i,selectedGuildId:(\i),avatarDecoration/,
replace: "" replace: ",nameplate:null,selectedGuildId:$1,avatarDecoration",
predicate: () => settings.store.removeNameplate
}, },
{ {
match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/, match: /hoverText:(\i),forceHover:\i,children:/g,
replace: "" replace: "hoverText:$1,forceHover:!0,children:",
predicate: () => settings.store.alwaysShowUsername
}, },
{ {
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,600}#{intl::ACCOUNT_INPUT_OPTIONS}\)\}\)(?=\])/, match: /avatarDecoration:(.{0,70}),size:/,
replace: "" replace: "avatarDecoration:void 0,size:",
predicate: () => settings.store.removeAvatarDecoration
}, },
{ {
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,650}#{intl::ACCOUNT_OUTPUT_OPTIONS}\)\}\)(?=\])/, match: /displayNameStyles:(\i),/,
replace: "" replace: "displayNameStyles:void 0,",
predicate: () => settings.store.removeUsernameStyles
} }
], ],
predicate: () => settings.store.removeAudioMenus
}, },
{ {
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}", find: '"MicrophoneButton"',
replacement: {
match: /hoverText:(\i),forceHover:\i,children:/g,
replace: "hoverText:$1,forceHover:!0,children:"
},
predicate: () => settings.store.alwaysShowUsername
},
{
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
replacement: [ replacement: [
{ {
match: /:\{tooltipText:\i\};/, 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\}/, match: /(?<=role:"switch",)tooltipText:\i\}/,
replace: "tooltipText:void 0}" replace: "tooltipText:void 0}",
predicate: () => settings.store.removeButtonTooltips
}, },
{ {
match: /tooltipText:\i,tooltipPositionKey/, match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/,
replace: "tooltipText:void 0,tooltipPositionKey" 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 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) // Member list items (server member list)
{ {
find: "MemberListItem", find: ".MEMBER_USER})]",
replacement: { replacement: {
match: /(?<=useRawTargetDimensions.{0,250})onContextMenu:\i,(?=onMouseEnter:)/, 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," 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"', find: ']="BADGES"',
replacement: { 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," 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: { accountStandingButton: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Show an account standing button in the header bar", description: "Show an account standing button in the header bar",
default: false,
restartNeeded: true, 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({ export default definePlugin({
name: "EquicordHelper", name: "EquicordHelper",
description: "Used to provide support, fix discord caused crashes, and other misc features.", 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, required: true,
settings, settings,
headerBarButton: { headerBarButton: {
@ -164,6 +170,7 @@ export default definePlugin({
replace: "true||$&" replace: "true||$&"
}, },
}, },
// Show your own activity buttons because discord removes them for who knows why
{ {
find: ".USER_PROFILE_ACTIVITY_BUTTONS),", find: ".USER_PROFILE_ACTIVITY_BUTTONS),",
predicate: () => settings.store.showYourOwnActivityButtons && !isPluginEnabled(customRPC.name), predicate: () => settings.store.showYourOwnActivityButtons && !isPluginEnabled(customRPC.name),
@ -200,6 +207,15 @@ export default definePlugin({
replace: "1" replace: "1"
} }
}, },
// Restore File Download Button
{
predicate: () => settings.store.restoreFileDownloadButton,
find: '"VISUAL_PLACEHOLDER":',
replacement: {
match: /(\.downloadUrl,showDownload:)\i/,
replace: "$1!0"
}
},
], ],
renderMessageAccessory(props) { renderMessageAccessory(props) {
return ( 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 { Devs, EquicordDevs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { Embed } from "@vencord/discord-types"; import { Embed } from "@vencord/discord-types";
import { findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack"; import { proxyLazyWebpack } from "@webpack";
import { React } from "@webpack/common"; import { React } from "@webpack/common";
import { Component, ReactNode } from "react"; import { ComponentType, ReactNode } from "react";
enum Format { import { AttachmentAccessory, EmbedAccessory, FilePicker } from "./components";
NONE = 0, import { SignedUrlsStore } from "./stores";
IMAGE = 1, import managedStyle from "./style.css?managed";
VIDEO = 2 import { AttachmentItem, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types";
} import { getThumbnailUrl } from "./utils";
interface FavoriteButtonProps { export const EmbedContext = proxyLazyWebpack(() => React.createContext<null | Embed>(null));
width: number; export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext<null | number>(null));
height: number; export const AttachmentContext = proxyLazyWebpack(() => React.createContext<null | AttachmentItem>(null));
// 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 default definePlugin({ export default definePlugin({
name: "FavouriteAnything", name: "FavouriteAnything",
description: "Favourite any image", description: "Favourite any image, video, or file attachment",
authors: [Devs.nin0dev, EquicordDevs.davri], authors: [Devs.nin0dev, EquicordDevs.davri],
managedStyle,
patches: [ patches: [
// EMBEDS
{ {
find: "static isAnimated", find: "#{intl::SUPPRESS_ALL_EMBEDS}",
replacement: [ replacement: [
// .isAnimated is checked in almost every media overlay event listener, so it's easier to patch the source.
{ {
match: /static isAnimated\((\i)\)\{/, // Wrap the embed component's render method in a custom context to avoid having to drill props
replace: match: "render()",
"static isAnimated($1,override){if(!override)return true;" 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/, // Specify the index for individual items in embed.images
replace: match: /\.images\.map\((\i)=>(this.renderImage\(\{[^}]{50,100}\}\))\)/,
"$self.Accessory({...this.props,url:this.props.proxyUrl,video:true})" replace: ".images.map(($1,index)=>$self.renderEmbedMosaicItem($2,index))"
},
// 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)||"
} }
] ]
}, },
// 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: { replacement: {
match: "render()", match: /renderAdjacentContent:(\i)/g,
replace: "$&{return $self.renderEmbed.call(this)}__render()" 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: { replacement: {
match: /\i=>\(\)=>\{.{200,300}?null\}/, match: '.sortBy("order").reverse()',
replace: "props=>()=>$self.Accessory({...props,video:false})" 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: { replacement: {
match: /src:\i(?=,\.\.\.\i)/, // Intercept the onClick callback to replace the placeholder thumbnail with a valid CDN link
replace: "$&,proxyUrl:this.props.src" match: /\(0,(\i\.\i)\)\((\{[^}].{40,60}?\})\)/,
replace: "$self.interceptAddToFavourites($2).then($1)"
} }
} }
], ],
renderEmbed(this: EmbedComponent) { renderTabs(Tab: ComponentType<ExpressionPickerTabProps>, activeView: ExpressionPickerView) {
return ( return (
<EmbedContext.Provider value={this.props.embed}> <>
{this.__render()} <Tab
</EmbedContext.Provider> 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) { renderFilePicker(activeView: ExpressionPickerView, onSelectGIF: (item: { url: string; }) => void) {
const embed = React.useContext(EmbedContext); return activeView === ExpressionPickerView.FILES ? <FilePicker onSelectItem={onSelectGIF} /> : null;
const content = embed?.image ?? embed?.video; },
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 } = SignedUrlsStore.addSigned(item.url);
embed && content
? {
...content,
url: (embed.type === "gifv" && embed.url) || content.url,
proxyUrl: content.proxyURL,
video: !!embed.video
}
: props;
if (!width || !height || !url) return null; if (URL.canParse(item.src)) {
SignedUrlsStore.addSigned(item.src);
return item;
}
return ( const thumbnail = await getThumbnailUrl(item.src, item.width, item.height);
<FavoriteButton if (!thumbnail) return item;
format={video ? Format.VIDEO : Format.IMAGE}
className={Classes?.gifFavoriteButton} thumbnail.search = "";
src={proxyUrl ?? url} thumbnail.hash = item.src;
url={url} return { ...item, src: `${thumbnail}` };
width={width}
height={height}
/>
);
} }
}); });

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 ( return (
<> <>
{settings.store.showIndicator && getGhostedChannels().length && ( {settings.store.showIndicator && getGhostedChannels().length > 0 && (
<div id={cl("container")}> <div id={cl("container")}>
<Tooltip text={getTooltipText()} position="right"> <Tooltip text={getTooltipText()} position="right">
{({ onMouseEnter, onMouseLeave }) => ( {({ onMouseEnter, onMouseLeave }) => (

View file

@ -10,7 +10,8 @@
background-color: var(--background-primary); background-color: var(--background-primary);
height: 30px; height: 30px;
width: 48px; width: 48px;
margin-left: 8px; margin-left: auto;
margin-right: auto;
color: var(--text-default); color: var(--text-default);
fill: var(--text-default); fill: var(--text-default);
} }
@ -153,4 +154,4 @@
.vc-boo-modal-text { .vc-boo-modal-text {
color: var(--text-muted); 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 { 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 { getFormat } from "./utils/getFormat";
import { getGif } from "./utils/getGif"; import { getGif } from "./utils/getGif";
import { refreshGifUrl } from "./utils/refreshUrl"; import { batchRefreshAttachmentUrls, isCdnUrlExpired } from "./utils/refreshUrl";
import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils"; import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils";
import { uuidv4 } from "./utils/uuidv4"; import { uuidv4 } from "./utils/uuidv4";
@ -385,11 +385,44 @@ export default definePlugin({
width: g.width, width: g.width,
height: g.height height: g.height
})).reverse(); })).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) { hidePrefix(name) {
return name.split(":".length > 1) ? name.replace(/.+?:/, "") : name; return name.includes(":") ? name.replace(/.+?:/, "") : name;
}, },
insertCollections(instance) { insertCollections(instance) {
const shouldRemoveAll = settings.store.onlyShowCollections; const shouldRemoveAll = settings.store.onlyShowCollections;
@ -547,40 +580,6 @@ const RemoveItemContextMenu = ({ type, nameOrId, instance }) => (
}} }}
/> />
<Menu.MenuSeparator /> <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 <Menu.MenuItem
key="copy-url" key="copy-url"
id="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), id: uuidv4(settings.store.itemPrefix),
height: embed.image.height, height: embed.image.height,
width: embed.image.width, width: embed.image.width,
src: embed.image.proxyURL, src: embed.image.proxyURL!,
url: embed.image.url, url: embed.image.url,
}; };
// Tennor // Tennor

View file

@ -4,46 +4,31 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { Gif } from "@equicordplugins/gifCollections/types";
import { RestAPI } from "@webpack/common"; import { RestAPI } from "@webpack/common";
async function fetchMessageFromAPI(channelId: string, messageId: string): Promise<any> { export function isCdnUrlExpired(url: string): boolean {
try { try {
const response = await RestAPI.get({ const ex = new URL(url).searchParams.get("ex");
url: `/channels/${channelId}/messages`, if (!ex) return false;
query: { return parseInt(ex, 16) * 1000 < Date.now();
limit: 1, } catch {
around: messageId 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) return {};
if (response.ok && response.body.length > 0) { const map: Record<string, string> = {};
return response.body.find((m: any) => m.id === messageId); for (const { original, refreshed } of response.body.refreshed_urls) {
map[original] = refreshed;
} }
} catch (error) { return map;
console.error("Failed to fetch message for GIF refresh:", error); } 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 { findComponentByCodeLazy } from "@webpack";
import { FluxDispatcher, Menu, React, Tooltip, UserStore } from "@webpack/common"; 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 ignoredChannelIds = new Set<string>();
const cl = classNameFactory("vc-ignore-calls-"); const cl = classNameFactory("vc-ignore-calls-");
const Deafen = findComponentByCodeLazy("0-1.02-.1H3.05a9"); const Deafen = findComponentByCodeLazy("0-1.02-.1H3.05a9");
@ -74,12 +88,6 @@ const settings = definePluginSettings({
}, },
}); });
const args = {
ringing: [],
messageId: "",
region: "",
};
export default definePlugin({ export default definePlugin({
name: "IgnoreCalls", name: "IgnoreCalls",
description: "Allows you to ignore calls from specific users or dm groups.", description: "Allows you to ignore calls from specific users or dm groups.",
@ -99,8 +107,9 @@ export default definePlugin({
"gdm-context": ContextMenuPatch, "gdm-context": ContextMenuPatch,
}, },
flux: { flux: {
async CALL_UPDATE({ ringing, messageId, region }) { async CALL_UPDATE({ ringing, ongoingRings, messageId, region }) {
args.ringing = ringing; args.ringing = ringing || [];
args.ongoingRings = ongoingRings || {};
args.messageId = messageId; args.messageId = messageId;
args.region = region; args.region = region;
} }
@ -113,6 +122,7 @@ export default definePlugin({
type: "CALL_UPDATE", type: "CALL_UPDATE",
channelId: channel.id, channelId: channel.id,
ringing: args.ringing.filter((id: string) => id !== currentUserId), ringing: args.ringing.filter((id: string) => id !== currentUserId),
ongoingRings: args.ongoingRings,
messageId: args.messageId, messageId: args.messageId,
region: args.region region: args.region
}); });
@ -133,6 +143,7 @@ export default definePlugin({
type: "CALL_UPDATE", type: "CALL_UPDATE",
channelId: channel.id, channelId: channel.id,
ringing: args.ringing.filter((id: string) => id !== currentUserId), ringing: args.ringing.filter((id: string) => id !== currentUserId),
ongoingRings: args.ongoingRings,
messageId: args.messageId, messageId: args.messageId,
region: args.region 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 { DecoratorProps } from "@api/MemberListDecorators";
import { isPluginEnabled } from "@api/PluginManager"; import { isPluginEnabled } from "@api/PluginManager";
import { definePluginSettings } from "@api/Settings";
import betterActivities from "@equicordplugins/betterActivities"; import betterActivities from "@equicordplugins/betterActivities";
import showMeYourName from "@plugins/showMeYourName"; import showMeYourName from "@plugins/showMeYourName";
import { Devs, EquicordDevs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc"; 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 { Activity, ApplicationStream, Channel, Message, OnlineStatus, User } from "@vencord/discord-types";
import { MessageFlags } from "@vencord/discord-types/enums"; import { MessageFlags } from "@vencord/discord-types/enums";
import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findCssClassesLazy, findExportedComponentLazy } from "@webpack"; 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-"); const cl = classNameFactory("vc-message-peek-");
@ -30,6 +31,14 @@ const ActivityText: React.ComponentType<ActivityTextProps> = findComponentByCode
const ONE_HOUR_MS = 60 * 60 * 1000; 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 AttachmentType = "image" | "gif" | "video" | "file";
type IconType = AttachmentType | "voice" | "sticker"; type IconType = AttachmentType | "voice" | "sticker";
@ -150,7 +159,7 @@ function MessagePreviewContent({ channel, user }: { channel: Channel; user: User
return <>Official Discord Message</>; 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 (!lastMessage) {
if (channel.isMultiUserDM()) return <>{channel.recipients.length + 1} Members</>; if (channel.isMultiUserDM()) return <>{channel.recipients.length + 1} Members</>;
@ -215,7 +224,7 @@ function Timestamp({ channel }: { channel: Channel; }) {
if (!lastMessage) return null; if (!lastMessage) return null;
const timestamp = SnowflakeUtils.extractTimestamp(lastMessage.id); 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>; return <span className={className}>{formatRelativeTime(timestamp)}</span>;
} }
@ -231,6 +240,7 @@ export default definePlugin({
name: "MessagePeek", name: "MessagePeek",
description: "Shows the last message preview and timestamp in the Direct Messages list.", description: "Shows the last message preview and timestamp in the Direct Messages list.",
authors: [Devs.prism, EquicordDevs.justjxke], authors: [Devs.prism, EquicordDevs.justjxke],
settings,
patches: [ patches: [
{ {
find: "PrivateChannel.renderAvatar", find: "PrivateChannel.renderAvatar",
@ -252,11 +262,28 @@ export default definePlugin({
renderMemberListDecorator({ channel }: DecoratorProps) { renderMemberListDecorator({ channel }: DecoratorProps) {
if (!channel) return null; if (!channel) return null;
if (settings.store.hideMuted && UserGuildSettingsStore.isChannelMuted(null!, channel.id)) return null;
return <Timestamp channel={channel} />; return <Timestamp channel={channel} />;
}, },
getSubText(props: PrivateChannelProps) { getSubText(props: PrivateChannelProps) {
const { channel, user, activities, status, applicationStream, voiceChannel } = props; 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 lastMessage = MessageStore.getLastMessage(channel.id) as Message | undefined;
const hasActivity = hasRelevantActivity({ activities, status, applicationStream, voiceChannel }); const hasActivity = hasRelevantActivity({ activities, status, applicationStream, voiceChannel });
const showActivity = shouldShowActivity(lastMessage, hasActivity); 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: [ patches: [
{ {
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}", find: ".WIDGETS_RTC_UPSELL_COACHMARK),",
replacement: { replacement: {
// react.jsx)(AccountPanel, { ..., showTaglessAccountPanel: blah }) // react.jsx)(AccountPanel, { ..., showTaglessAccountPanel: blah })
match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,hidePrivateData:)/, match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,occluded:)/,
// react.jsx(WrapperComponent, { VencordOriginal: AccountPanel, ... // react.jsx(WrapperComponent, { VencordOriginal: AccountPanel, ...
replace: "$self.PanelWrapper,{VencordOriginal:$1," 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", label: "Playstation",
value: "playstation", value: "playstation",
}, },
{
label: "VR",
value: "vr",
},
] ]
} }
}); });
@ -48,7 +52,7 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "PlatformSpoofer", name: "PlatformSpoofer",
description: "Spoof what platform or device you're on", description: "Spoof what platform or device you're on",
authors: [EquicordDevs.Drag], authors: [EquicordDevs.Drag, EquicordDevs.neoarz],
settingsAboutComponent: () => ( settingsAboutComponent: () => (
<Notice.Warning> <Notice.Warning>
We can't guarantee this plugin won't get you warned or banned. 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 }; return { browser: "Discord Embedded", vcIcon: 2 };
case "playstation": case "playstation":
return { browser: "Discord Embedded", vcIcon: 3 }; return { browser: "Discord Embedded", vcIcon: 3 };
case "vr":
return { browser: "Discord VR", vcIcon: 4 };
default: default:
return null; return null;
} }

View file

@ -4,7 +4,9 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { isNonNullish } from "@utils/guards";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { ProfilePreset } from "@vencord/discord-types";
import { ContextMenuApi, Menu, React, TextInput } from "@webpack/common"; import { ContextMenuApi, Menu, React, TextInput } from "@webpack/common";
import { cl } from ".."; import { cl } from "..";
@ -37,6 +39,7 @@ export function PresetList({
currentPage, currentPage,
onPageChange onPageChange
}: PresetListProps) { }: PresetListProps) {
type EditableProfile = Omit<ProfilePreset, "name" | "timestamp">;
const [renaming, setRenaming] = React.useState<number>(-1); const [renaming, setRenaming] = React.useState<number>(-1);
const [renameText, setRenameText] = React.useState(""); const [renameText, setRenameText] = React.useState("");
const isGuildProfile = section === "server"; const isGuildProfile = section === "server";
@ -149,11 +152,11 @@ export function PresetList({
label="Update" label="Update"
action={async () => { action={async () => {
const profile = await getCurrentProfile(guildId, { isGuildProfile }); const profile = await getCurrentProfile(guildId, { isGuildProfile });
Object.entries(profile).forEach(([key, value]) => { await Promise.all(
if (value !== null && value !== undefined) { (Object.entries(profile) as [keyof EditableProfile, EditableProfile[keyof EditableProfile]][])
updatePresetField(actualIndex, key as any, value, section, guildId); .filter(([, value]) => isNonNullish(value))
} .map(([key, value]) => updatePresetField(actualIndex, key, value, section, guildId))
}); );
onUpdate(); 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 hasPresets = presets.length > 0;
const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE; 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 cl = classNameFactory("vc-profile-presets-");
export const settings = definePluginSettings({ export const settings = definePluginSettings({
avatarSize: { avatarSize: {
type: OptionType.NUMBER, type: OptionType.SLIDER,
description: "Avatar size in preset list.", 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)", find: "USER_SETTINGS_GUILD_PROFILE)",
replacement: { replacement: {
match: /onChange:\i\}\)(?=.{0,25}profilePreviewTitle:)/, match: /guildId:(\i\.id),onChange:(\i)\}\)(?=.{0,25}profilePreviewTitle:)/,
replace: '$&,$self.renderPresetSection("server")' replace: 'guildId:$1,onChange:$2}),$self.renderPresetSection("server",$1)'
} }
} }
], ],

View file

@ -4,24 +4,64 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { isNonNullish } from "@utils/guards";
import { ProfilePreset } from "@vencord/discord-types"; import { ProfilePreset } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { showToast, Toasts } from "@webpack/common";
import { getCurrentProfile } from "./profile"; 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) { export async function savePreset(name: string, section: PresetSection, guildId?: string) {
const profile = await getCurrentProfile(guildId, { isGuildProfile: section === "server" }); const profile = await getCurrentProfile(guildId, { isGuildProfile: section === "server" });
const freshPendingAvatar = getFreshPendingAvatar(section, guildId);
const effectiveAvatar = freshPendingAvatar ?? profile.avatarDataUrl ?? null;
const newPreset: ProfilePresetEx = { const newPreset: ProfilePresetEx = {
name, name,
timestamp: Date.now(), timestamp: Date.now(),
...profile, ...profile,
avatarDataUrl: effectiveAvatar,
}; };
addPreset(newPreset); addPreset(newPreset);
await savePresetsData(section); 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; if (index < 0 || index >= presets.length) return;
void guildId;
const updatedPreset = { const updatedPreset = {
...presets[index], ...presets[index],
@ -55,17 +95,14 @@ export async function renamePreset(index: number, newName: string, section: Pres
} }
export function exportPresets(section: PresetSection) { export function exportPresets(section: PresetSection) {
try { const dataStr = JSON.stringify(presets, null, 2);
const dataStr = JSON.stringify(presets, null, 2); const dataBlob = new Blob([dataStr], { type: "application/json" });
const dataBlob = new Blob([dataStr], { type: "application/json" }); const url = URL.createObjectURL(dataBlob);
const url = URL.createObjectURL(dataBlob); const link = document.createElement("a");
const link = document.createElement("a"); link.href = url;
link.href = url; link.download = `profile-presets-${section}-${Date.now()}.json`;
link.download = `profile-presets-${section}-${Date.now()}.json`; link.click();
link.click(); URL.revokeObjectURL(url);
URL.revokeObjectURL(url);
} catch {
}
} }
export type ImportDecision = "override" | "merge" | "cancel"; export type ImportDecision = "override" | "merge" | "cancel";
@ -108,6 +145,7 @@ export async function importPresets(
await savePresetsData(section); await savePresetsData(section);
forceUpdate(); forceUpdate();
} catch { } catch {
showToast("Failed to import presets. The file might be invalid.", Toasts.Type.FAILURE);
} }
}; };
input.click(); input.click();

View file

@ -7,12 +7,16 @@
import { getUserSettingLazy } from "@api/UserSettings"; import { getUserSettingLazy } from "@api/UserSettings";
import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types"; import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack"; 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 UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!; const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
type PendingChanges = Record<string, unknown> & { type PendingChanges = Record<string, unknown> & {
selectedAvatarRaw?: ImageInput;
presetAvatarRaw?: ImageInput;
selectedAvatarProcessed?: ImageInput;
presetAvatarProcessed?: ImageInput;
pendingAvatar?: ImageInput; pendingAvatar?: ImageInput;
avatar?: ImageInput; avatar?: ImageInput;
pendingBanner?: ImageInput; pendingBanner?: ImageInput;
@ -30,34 +34,22 @@ type PendingChanges = Record<string, unknown> & {
pendingPrimaryGuildId?: string | null; 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 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>) { function dispatch(type: string, payload: Record<string, unknown>) {
FluxDispatcher.dispatch({ type, ...payload }); FluxDispatcher.dispatch({ type, ...payload });
@ -70,7 +62,23 @@ function isNonEmptyString(value: unknown): value is string {
function hasImageInput(value: ImageInput): boolean { function hasImageInput(value: ImageInput): boolean {
if (!value) return false; if (!value) return false;
if (typeof value === "string") return value.length > 0; 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> { 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> { async function processImage(imageData: ImageInput, userId: string, type: "avatar" | "banner", guildId?: string, useGuildPath?: boolean): Promise<string | null> {
if (!imageData) return 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; return imageData.imageUri;
} }
@ -117,17 +125,6 @@ async function processImage(imageData: ImageInput, userId: string, type: "avatar
return null; 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">> { export async function getCurrentProfile(guildId?: string, options: CurrentProfileOptions = {}): Promise<Omit<ProfilePreset, "name" | "timestamp">> {
const currentUser = UserStore.getCurrentUser(); const currentUser = UserStore.getCurrentUser();
const baseProfile = UserProfileStore.getUserProfile(currentUser.id); const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
@ -135,19 +132,17 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
const effectiveGuildId = isGuildProfile ? guildId : undefined; const effectiveGuildId = isGuildProfile ? guildId : undefined;
const guildProfile = effectiveGuildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, effectiveGuildId) : null; const guildProfile = effectiveGuildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, effectiveGuildId) : null;
const userProfile = guildProfile ?? baseProfile; const userProfile = guildProfile ?? baseProfile;
const userAny = currentUser as UserExtras; const userAny = currentUser;
const guildMember = effectiveGuildId ? GuildMemberStore.getMember(effectiveGuildId, currentUser.id) : null; const guildMember = effectiveGuildId ? GuildMemberStore.getMember(effectiveGuildId, currentUser.id) : null;
const pendingChangesDefault = (UserProfileSettingsStore.getPendingChanges() ?? {}) as PendingChanges; const pendingChangesDefault: PendingChanges = UserProfileSettingsStore.getPendingChanges() ?? {};
const pendingChangesForGuild = (effectiveGuildId const pendingChangesForGuild: PendingChanges = UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {};
? (UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {})
: {}) as PendingChanges;
const pendingChanges: PendingChanges = isGuildProfile && Object.keys(pendingChangesForGuild).length > 0 const pendingChanges: PendingChanges = isGuildProfile && Object.keys(pendingChangesForGuild).length > 0
? pendingChangesForGuild ? pendingChangesForGuild
: pendingChangesDefault; : pendingChangesDefault;
const customStatusSetting = CustomStatusSettings.getSetting(); const customStatusSetting = CustomStatusSettings.getSetting();
const customStatus: CustomStatus | null = isGuildProfile const customStatus = isGuildProfile
? null ? null
: { : {
text: customStatusSetting?.text ?? "", text: customStatusSetting?.text ?? "",
@ -180,7 +175,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
type: effectToUse.type || 1 type: effectToUse.type || 1
}; };
} else if (effectToUse.skuId) { } 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); const collectible = collectibles?.find(c => c?.skuId === effectToUse.skuId);
if (collectible) { if (collectible) {
profileEffect = { profileEffect = {
@ -208,12 +203,11 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
type: nameplateToUse.type || 2 type: nameplateToUse.type || 2
} : null; } : null;
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? userAny.displayNameStyles; const savedDisplayNameStyles = isGuildProfile
const displayNameStyles = displayNameStylesToUse ? { ? (guildMember?.displayNameStyles ?? userAny.displayNameStyles)
font_id: displayNameStylesToUse.font_id ?? 0, : userAny.displayNameStyles;
effect_id: displayNameStylesToUse.effect_id ?? 0, const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? savedDisplayNameStyles;
colors: Array.isArray(displayNameStylesToUse.colors) ? displayNameStylesToUse.colors : [] const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse);
} : null;
const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar; const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar;
const avatarToUse: ImageInput = hasImageInput(pendingAvatar) const avatarToUse: ImageInput = hasImageInput(pendingAvatar)
@ -245,7 +239,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
globalName: isGuildProfile globalName: isGuildProfile
? (pendingChanges.pendingNickname ?? guildMember?.nick ?? null) ? (pendingChanges.pendingNickname ?? guildMember?.nick ?? null)
: (pendingChanges.pendingGlobalName ?? currentUser.globalName ?? null), : (pendingChanges.pendingGlobalName ?? currentUser.globalName ?? null),
pronouns: pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null, pronouns: isGuildProfile ? null : (pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null),
avatarDecoration, avatarDecoration,
profileEffect, profileEffect,
nameplate, nameplate,
@ -263,98 +257,156 @@ function jsonEq(a: unknown, b: unknown): boolean {
} }
function customStatusEq(a: CustomStatus | null | undefined, b: CustomStatus | null | undefined): 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 a == null && b == null;
if (a == null || b == null) return false;
return a.text === b.text return a.text === b.text
&& String(a.emojiId ?? "") === String(b.emojiId ?? "") && String(a.emojiId ?? "") === String(b.emojiId ?? "")
&& a.emojiName === b.emojiName && a.emojiName === b.emojiName
&& String(a.expiresAtMs ?? "0") === String(b.expiresAtMs ?? "0"); && 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 = {}) { export async function loadPresetAsPending(preset: ProfilePreset, guildId?: string, options: LoadPresetOptions = {}) {
try { try {
const isGuild = options.isGuildProfile ?? Boolean(guildId); const isGuild = options.isGuildProfile ?? Boolean(guildId);
if (isGuild && !guildId) return; 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, { const current = await getCurrentProfile(guildId, {
isGuildProfile: isGuild isGuildProfile: isGuild
}); });
const profilePrefix = "USER_SETTINGS_ACCOUNT"; const pendingChanges = (isGuild && guildId
const dispatchProfile = (type: string, payload: Record<string, unknown>) => { ? UserProfileSettingsStore.getPendingChanges(guildId)
dispatch(type, isGuild ? { ...payload, guildId } : payload); : 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) { if ("avatarDataUrl" in preset) {
const avatarValue = preset.avatarDataUrl; const avatarValue = preset.avatarDataUrl;
if (isGuild || avatarValue !== current.avatarDataUrl) { const presetAvatar = normalizeImageValue(avatarValue);
setPending(`${profilePrefix}_SET_PENDING_AVATAR`, { avatar: 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) { 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; 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; 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) { if (isGuild) {
setPending("USER_SETTINGS_ACCOUNT_SET_PENDING_NICKNAME", { nickname: preset.globalName }); setPending("NICKNAME", { nickname: preset.globalName });
} else { } 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)) { if (preset.avatarDecoration && ![pendingAvatarDecoration, equippedAvatarDecoration, current.avatarDecoration].some(d => avatarDecorationEq(preset.avatarDecoration, d))) {
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, { setPending("COLLECTIBLES_ITEM", {
item: { type: 0, value: preset.avatarDecoration } item: { type: 0, value: preset.avatarDecoration }
}); });
} }
if (preset.profileEffect !== undefined && !jsonEq(preset.profileEffect, current.profileEffect)) { if (preset.profileEffect && ![pendingProfileEffect, equippedProfileEffect, current.profileEffect].some(d => collectibleEqBySku(preset.profileEffect, d))) {
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, { setPending("COLLECTIBLES_ITEM", {
item: { type: 1, value: preset.profileEffect } item: { type: 1, value: preset.profileEffect }
}); });
} }
if (preset.nameplate !== undefined && !jsonEq(preset.nameplate, current.nameplate)) { if (preset.nameplate && ![pendingNameplate, equippedNameplate, current.nameplate].some(d => nameplateEq(preset.nameplate, d))) {
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, { setPending("COLLECTIBLES_ITEM", {
item: { type: 2, value: preset.nameplate } item: { type: 2, value: preset.nameplate }
}); });
} }
if (preset.displayNameStyles !== undefined && !jsonEq(preset.displayNameStyles, current.displayNameStyles)) { if (preset.displayNameStyles) {
setPending(`${profilePrefix}_SET_PENDING_DISPLAY_NAME_STYLES`, { displayNameStyles: 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)) { if (preset.themeColors && !jsonEq(preset.themeColors, current.themeColors)) {
setPending(`${profilePrefix}_SET_PENDING_THEME_COLORS`, { themeColors: preset.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 }); dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId });
} }
if (preset.customStatus !== undefined && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) { if (preset.customStatus && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) {
const customStatusValue = preset.customStatus ?? {
text: "",
expiresAtMs: "0",
emojiId: "0",
emojiName: ""
};
CustomStatusSettings.updateSetting({ CustomStatusSettings.updateSetting({
text: customStatusValue.text || "", text: preset.customStatus?.text ?? "",
expiresAtMs: customStatusValue.expiresAtMs || "0", expiresAtMs: preset.customStatus?.expiresAtMs ?? "0",
emojiId: customStatusValue.emojiId || "0", emojiId: preset.customStatus?.emojiId ?? "0",
emojiName: customStatusValue.emojiName || "" emojiName: preset.customStatus?.emojiName ?? ""
}); });
} }
} catch (err) { } 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 { function getQuestPanelPercentComplete({ quest, percentCompleteText }: { quest: Quest; percentCompleteText?: string; }): { percentComplete: number; } | { percentComplete: number; percentCompleteText: string; } | null {
if (!quest) { return null; }
const task = getQuestTask(quest); const task = getQuestTask(quest);
if (!task) { return null; } if (!task) { return null; }
@ -1294,8 +1296,8 @@ export default definePlugin({
// Fixes the progress tracking for auto-completing Quests. // Fixes the progress tracking for auto-completing Quests.
find: ",{progressTextAnimation:", find: ",{progressTextAnimation:",
replacement: { replacement: {
match: /(?<=children:\i}=)(\i)/, match: /(let{percentComplete:.{0,115}?children:\i}=)(\i)/,
replace: "Object.assign({},$1,$1.quest?$self.getQuestPanelPercentComplete($1):{})" 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 // Adds support for dev://experiment/2025-12-quest-cta-refactor-rollout
find: '"sm",preClickCallback:', find: '"primary",preClickCallback:',
replacement: [ replacement: [
{ {
match: /(?=let{quest:)/, match: /(?=let{quest:)/,
@ -1624,11 +1618,19 @@ export default definePlugin({
replace: "!$self.processQuestForAutoComplete(arguments[0].quest)&&($1)" replace: "!$self.processQuestForAutoComplete(arguments[0].quest)&&($1)"
}, },
{ {
match: /(?<=,text:)(?=\i)/, match: /(?<=,text:)(\i),icon:\i/,
replace: "questifyText??" 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. // 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. // 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 { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { Message } from "@vencord/discord-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 { QuoteIcon } from "./components/QuoteIcon";
import { QuoteFont } from "./types"; 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({ const settings = definePluginSettings({
quoteFont: { quoteFont: {
@ -105,7 +105,7 @@ function QuoteModal({ message, ...props }: ModalProps & { message: Message; }) {
const generateImage = async () => { const generateImage = async () => {
const image = await createQuoteImage({ const image = await createQuoteImage({
avatarUrl: sizeUpgrade(message.author.getAvatarURL()), avatarUrl: IconUtils.getUserAvatarURL(message.author, true, 512),
quote: message.content, quote: message.content,
grayScale: gray, grayScale: gray,
author: message.author, author: message.author,

View file

@ -5,15 +5,20 @@
*/ */
import { User } from "@vencord/discord-types"; import { User } from "@vencord/discord-types";
import { UserStore } from "@webpack/common"; import { IconUtils, UserStore } from "@webpack/common";
import { applyPalette, GIFEncoder, quantize } from "gifenc"; import { applyPalette, GIFEncoder, quantize } from "gifenc";
import { CANVAS_CONFIG, CanvasConfig, FONT_SIZES, FontSizeCalculation, QuoteFont, QuoteImageOptions, SPACING } from "./types"; import { CANVAS_CONFIG, CanvasConfig, FONT_SIZES, FontSizeCalculation, QuoteFont, QuoteImageOptions, SPACING } from "./types";
export function sizeUpgrade(url: string): string { const CUSTOM_EMOJI_REGEX = /<a?:(\w+):(\d+)>/g;
const u = new URL(url); const CUSTOM_EMOJI_PLACEHOLDER = "\uFFFC";
u.searchParams.set("size", "512"); const CUSTOM_EMOJI_SIZE_MULTIPLIER = 1.15;
return u.toString(); const CUSTOM_EMOJI_BASELINE_OFFSET = 0.85;
interface CustomEmojiToken {
id: string;
name: string;
animated: boolean;
} }
export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> { 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 { export function fixUpQuote(quote: string): string {
let result = quote.replace(/<a?:(\w+):(\d+)>/g, ""); let result = quote;
const mentionMatches = result.match(/<@!?\d+>/g); const mentionMatches = result.match(/<@!?\d+>/g);
if (mentionMatches) { if (mentionMatches) {
mentionMatches.forEach(match => { mentionMatches.forEach(match => {
@ -46,7 +51,8 @@ export function fixUpQuote(quote: string): string {
export function generateFileNamePreview(message: string): string { export function generateFileNamePreview(message: string): string {
const words = message.split(" "); 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 { 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); 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( function calculateTextLines(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
text: string, text: string,
@ -153,12 +242,31 @@ function calculateTextLines(
let currentLine: string[] = []; let currentLine: string[] = [];
words.forEach(word => { words.forEach(word => {
const testLine = [...currentLine, word].join(" "); if (measureTextWithCustomEmojis(ctx, word, fontSize) > maxWidth) {
if (ctx.measureText(testLine).width > maxWidth && currentLine.length) { if (currentLine.length) {
lines.push(currentLine.join(" ")); lines.push(currentLine.join(" "));
currentLine = [word]; 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 { } 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, ctx: CanvasRenderingContext2D,
calculation: FontSizeCalculation, calculation: FontSizeCalculation,
font: QuoteFont, font: QuoteFont,
config: CanvasConfig config: CanvasConfig,
emojis: CustomEmojiToken[],
emojiImages: Map<string, HTMLImageElement>
): number { ): number {
ctx.fillStyle = "#fff"; ctx.fillStyle = "#fff";
ctx.font = `300 ${calculation.fontSize}px '${font}', sans-serif`; 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 quoteY = (config.height - calculation.totalHeight) / 2;
let emojiIndex = 0;
calculation.lines.forEach(line => { 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; 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; return quoteY;
@ -263,6 +406,8 @@ export async function createQuoteImage(options: QuoteImageOptions): Promise<Blob
await ensureFontLoaded(); await ensureFontLoaded();
const quote = fixUpQuote(rawQuote); const quote = fixUpQuote(rawQuote);
const { text: quoteText, emojis } = extractCustomEmojis(quote);
const emojiImages = await loadCustomEmojiImages(emojis);
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get 2D rendering context"); 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); drawGradientOverlay(ctx, CANVAS_CONFIG);
const calculation = calculateOptimalFontSize(ctx, quote, quoteFont, CANVAS_CONFIG); const calculation = calculateOptimalFontSize(ctx, quoteText, quoteFont, CANVAS_CONFIG);
const quoteEndY = drawQuoteText(ctx, calculation, quoteFont, CANVAS_CONFIG); const quoteEndY = drawQuoteText(ctx, calculation, quoteFont, CANVAS_CONFIG, emojis, emojiImages);
drawAuthorInfo(ctx, author, calculation, CANVAS_CONFIG, quoteEndY); drawAuthorInfo(ctx, author, calculation, CANVAS_CONFIG, quoteEndY);
if (showWatermark && watermark) { if (showWatermark && watermark) {

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ export async function authorizeUser(triggerModal: boolean = true) {
responseType="code" responseType="code"
redirectUri="https://themes.equicord.org/api/user/auth" redirectUri="https://themes.equicord.org/api/user/auth"
permissions={0n} permissions={0n}
clientId="1257819493422465235" clientId="1464006702125940736"
cancelCompletesFlow={false} cancelCompletesFlow={false}
callback={async ({ location }: any) => { callback={async ({ location }: any) => {
if (!location) return logger.error("No redirect location returned"); 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)", description: "Custom tone indicators (format: jk=Joking; srs=Serious)",
default: "", default: "",
}, },
maxIndicators: {
type: OptionType.NUMBER,
description: "Maximum number of tone indicators to show per message",
default: 5,
},
}); });
function getCustomIndicators(): Record<string, string> { function getCustomIndicators(): Record<string, string> {
@ -91,15 +86,17 @@ function buildIndicatorRegex(): RegExp {
function splitTextWithIndicators(text: string): ReactNode[] { function splitTextWithIndicators(text: string): ReactNode[] {
const nodes: ReactNode[] = []; const nodes: ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
let count = 0;
const regex = buildIndicatorRegex(); const regex = buildIndicatorRegex();
const prefix = settings.store.prefix || "/"; const prefix = settings.store.prefix || "/";
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = regex.exec(text)) && count < settings.store.maxIndicators) { while ((match = regex.exec(text))) {
const indicator = match[1]; const indicator = match[1];
const desc = getIndicator(indicator); const desc = getIndicator(indicator);
const fullMatch = match[0];
const leadingWhitespace = fullMatch.match(/^(\s*)/)?.[1] ?? "";
const matchStart = match.index; const matchStart = match.index;
const matchEnd = regex.lastIndex; const matchEnd = regex.lastIndex;
@ -108,6 +105,7 @@ function splitTextWithIndicators(text: string): ReactNode[] {
} }
if (desc) { if (desc) {
if (leadingWhitespace) nodes.push(leadingWhitespace);
nodes.push( nodes.push(
<ToneIndicator <ToneIndicator
key={`ti-${matchStart}`} key={`ti-${matchStart}`}
@ -116,7 +114,6 @@ function splitTextWithIndicators(text: string): ReactNode[] {
desc={desc} desc={desc}
/>, />,
); );
count++;
} }
lastIndex = matchEnd; 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) { renderButtons(user: User) {
if (!user) return null; if (!user) return null;
const positionClass = settings.store.buttonPosition === "right"
? "voice-user-buttons-right"
: "voice-user-buttons-left";
return ( return (
<Flex flexDirection="row" className="voice-user-buttons"> <Flex flexDirection="row" className={`voice-user-buttons ${positionClass}`}>
{settings.store.showChatButton && <UserChatButton user={user} />} {settings.store.showChatButton && <UserChatButton user={user} />}
{settings.store.showMuteButton && <UserMuteButton user={user} />} {settings.store.showMuteButton && <UserMuteButton user={user} />}
{settings.store.showDeafenButton && <UserDeafenButton user={user} />} {settings.store.showDeafenButton && <UserDeafenButton user={user} />}

View file

@ -30,46 +30,47 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
default: true, default: true,
description: "Toggles their soundboard upon clicking deafen button.", description: "Toggles their soundboard upon clicking deafen button.",
restartNeeded: false,
}, },
disableVideo: { disableVideo: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
default: true, default: true,
description: "Toggles their video upon clicking deafen button.", description: "Toggles their video upon clicking deafen button.",
restartNeeded: false,
}, },
useServer: { useServer: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Use server mute/deafen instead of local when you have permission.", description: "Use server mute/deafen instead of local when you have permission.",
default: false, default: false,
restartNeeded: false,
}, },
serverSelf: { serverSelf: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Deafen / Mute yourself on the server when using mute/deafen.", description: "Deafen / Mute yourself on the server when using mute/deafen.",
default: false, default: false,
restartNeeded: false,
}, },
showButtonsSelf: { showButtonsSelf: {
type: OptionType.SELECT, 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).", 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, restartNeeded: true,
options: [ options: [
{ label: "Display", value: "display" }, { label: "Display", value: "display", default: true },
{ label: "Hide", value: "hide" }, { label: "Hide", value: "hide" },
{ label: "Disable", value: "disable" }, { label: "Disable", value: "disable" },
], ],
}, },
whichNameToShow: { whichNameToShow: {
type: OptionType.SELECT, type: OptionType.SELECT,
default: "both",
description: "Choose whether to show nickname or username in tooltip.", description: "Choose whether to show nickname or username in tooltip.",
restartNeeded: false,
options: [ options: [
{ label: "Both", value: "both", default: true },
{ label: "Global Name", value: "global" }, { label: "Global Name", value: "global" },
{ label: "Default Username", value: "username" }, { label: "Username", value: "username" },
{ label: "Both", value: "both" }, ],
},
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; flex: none !important;
} }
.voice-user-buttons-left {
order: -1;
}
.voice-user-buttons-right {
order: 1;
margin-left: 4px;
}
.voice-user-button-container { .voice-user-button-container {
padding-right: 4px; padding-right: 4px;
} }
div[class*='list'][class*='collapsed'] .voice-user-buttons { div[class*='list'][class*='collapsed'] .voice-user-buttons {
display: none; display: none;
} }

View file

@ -7,6 +7,7 @@
import { NavContextMenuPatchCallback } from "@api/ContextMenu"; import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { insertTextIntoChatInputBox } from "@utils/discord";
import definePlugin, { makeRange, OptionType } from "@utils/types"; import definePlugin, { makeRange, OptionType } from "@utils/types";
import type { Channel } from "@vencord/discord-types"; import type { Channel } from "@vencord/discord-types";
import { GuildChannelStore, Menu, React, RestAPI, UserStore, VoiceStateStore } from "@webpack/common"; 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 { interface VoiceChannelContextProps {
channel: Channel; channel: Channel;
} }
@ -69,6 +81,13 @@ const VoiceChannelContext: NavContextMenuPatchCallback = (children, { channel }:
key="voice-tools" key="voice-tools"
id="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 <Menu.MenuItem
key="voice-tools-disconnect-all" key="voice-tools-disconnect-all"
id="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", description: "Comma or space separated list of friends' user IDs you want to recieve join messages from",
default: "" 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: { ignoreBlockedUsers: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Do not send messages about blocked users joining/leaving/moving voice channels", 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) { function isFriendAllowlisted(friendId: string) {
if (!RelationshipStore.isFriend(friendId)) return false; if (!RelationshipStore.isFriend(friendId)) return false;
const list = settings.store.allowedFriends.split(",").join(" ").split(" ").filter(i => i.length > 0); const ignoreList = settings.store.ignoredFriends.split(",").join(" ").split(" ").filter(i => i.length > 0);
if (list.join(" ").length < 1) return true; if (ignoreList.includes(friendId)) return false;
return list.includes(friendId); 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 // Blatantly stolen from VcNarrator plugin

View file

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

View file

@ -22,7 +22,7 @@ import "./settings";
import { debounce } from "@shared/debounce"; import { debounce } from "@shared/debounce";
import { IpcEvents } from "@shared/IpcEvents"; 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 monacoHtml from "file://monacoWin.html?minify&base64";
import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs"; import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs";
import { open, readdir, readFile, unlink } from "fs/promises"; 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"; e.returnValue = nativeTheme.shouldUseDarkColors ? "vs-dark" : "vs-light";
}); });
let monacoWin: BrowserWindow | null = null;
ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => { ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
const title = "Equicord QuickCSS Editor"; if (monacoWin && !monacoWin.isDestroyed()) {
const existingWindow = BrowserWindow.getAllWindows().find(w => w.title === title); monacoWin.show();
if (existingWindow && !existingWindow.isDestroyed()) { monacoWin.focus();
existingWindow.focus();
return; return;
} }
const win = new BrowserWindow({ monacoWin = new BrowserWindow({
title, title: "Equicord QuickCSS Editor",
autoHideMenuBar: true, autoHideMenuBar: true,
darkTheme: true, darkTheme: true,
webPreferences: { 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")); 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) { async function loadAllBadges(noCache = false) {
const vencordBadges = await loadBadges("https://badges.vencord.dev/badges.json", noCache); 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; DonorBadges = vencordBadges;
EquicordDonorBadges = equicordBadges; EquicordDonorBadges = equicordBadges;

View file

@ -15,7 +15,7 @@ export default definePlugin({
{ {
find: "#{intl::USER_PROFILE_PRONOUNS}", find: "#{intl::USER_PROFILE_PRONOUNS}",
replacement: { 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]" replace: "children:[...Vencord.Api.NicknameIcons._renderIcons({userId:arguments[0].user?.id}),$1]"
} }
} }

View file

@ -75,7 +75,7 @@ export default definePlugin({
patches: [ patches: [
{ {
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}", find: ".WIDGETS_RTC_UPSELL_COACHMARK)",
group: true, group: true,
replacement: [ replacement: [
{ {
@ -87,7 +87,7 @@ export default definePlugin({
replace: "$&$self.onPopoutClose();" 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)" replace: "$&,onContextMenu:($self.grabRef($1),$self.openAccountPanelContextMenu)"
} }
] ]

View file

@ -21,10 +21,10 @@ import "./style.css";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord"; import { getIntlMessage } from "@utils/discord";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findStoreLazy } from "@webpack"; import type { GuildFolder } from "@vencord/discord-types";
import { FluxDispatcher } from "@webpack/common"; import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { ChannelStore, FluxDispatcher, ReadStateStore } from "@webpack/common";
import { ReactNode } from "react"; import { ReactNode } from "react";
import FolderSideBar from "./FolderSideBar"; import FolderSideBar from "./FolderSideBar";
@ -35,15 +35,42 @@ enum FolderIconDisplay {
MoreThanOneFolderExpanded 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 ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore");
export const SortedGuildStore = findStoreLazy("SortedGuildStore"); export const SortedGuildStore = findStoreLazy("SortedGuildStore");
const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand"); const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");
const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "folderButtonSize");
const MAX_TREE_FILTER_DEPTH = 1000;
let lastGuildId = null as string | null; let lastGuildId = null as string | null;
let dispatchingFoldersClose = false; let dispatchingFoldersClose = false;
function getGuildFolder(id: string) { 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() { function closeFolders() {
@ -52,22 +79,31 @@ function closeFolders() {
} }
// Nuckyz: Unsure if this should be a general utility or not // 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) { if (children == null) {
return false; 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 (!Array.isArray(children)) {
if (predicate(children)) { if (predicate(children)) {
return true; return true;
} }
return filterTreeWithTargetNode(children.props?.children, predicate); return filterTreeWithTargetNode(children.props?.children, predicate, visited, depth + 1);
} }
let childIsTargetChild = false; let childIsTargetChild = false;
for (let i = 0; i < children.length; i++) { 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) { if (shouldKeep) {
childIsTargetChild = true; childIsTargetChild = true;
continue; continue;
@ -79,6 +115,75 @@ function filterTreeWithTargetNode(children: any, predicate: (node: any) => boole
return childIsTargetChild; 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({ export const settings = definePluginSettings({
sidebar: { sidebar: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
@ -132,8 +237,10 @@ export const settings = definePluginSettings({
{ label: "When more than one folder is expanded", value: FolderIconDisplay.MoreThanOneFolderExpanded } { label: "When more than one folder is expanded", value: FolderIconDisplay.MoreThanOneFolderExpanded }
], ],
restartNeeded: true restartNeeded: true
} },
}); }).withPrivateSettings<{
nestedFolders: Record<string, string>;
}>();
const IS_BETTER_FOLDERS_VAR = "typeof isBetterFolders!=='undefined'?isBetterFolders:arguments[0]?.isBetterFolders"; 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"; const BETTER_FOLDERS_EXPANDED_IDS_VAR = "typeof betterFoldersExpandedIds!=='undefined'?betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds";
@ -146,6 +253,9 @@ export default definePlugin({
isModified: true, isModified: true,
settings, settings,
start() {
settings.store.nestedFolders ??= {};
},
patches: [ 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 // This is the parent folder component
find: ".toggleGuildFolderExpand(", 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:", find: "APPLICATION_LIBRARY,render:",
predicate: () => settings.store.sidebar, predicate: () => settings.store.sidebar,
@ -263,7 +398,7 @@ export default definePlugin({
replace: (m, conditions, props) => `${m},${conditions}$self.FolderSideBar(${props})` 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")/, match: /(?<=className:)\i\.\i(?=,"data-fullscreen")/,
replace: `"${GRID_STYLE_NAME} "+$&` replace: `"${GRID_STYLE_NAME} "+$&`
} }
@ -277,6 +412,35 @@ export default definePlugin({
match: /(?<=onClick:\(\)=>{)(?=.{0,300}"discodo")/, match: /(?<=onClick:\(\)=>{)(?=.{0,300}"discodo")/,
replace: "$self.closeFolders();" 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(() => { FluxDispatcher.wait(() => {
const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders(); const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders();
const expandedId = data.folderId?.toString();
if (expandedFolders.size > 1) { 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); FolderUtils.toggleGuildFolderExpand(id);
}
} }
dispatchingFoldersClose = false; dispatchingFoldersClose = false;
@ -326,12 +494,219 @@ export default definePlugin({
FolderSideBar, FolderSideBar,
closeFolders, 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 ( if (
!isBetterFolders || !isBetterFolders ||
node.type === "folder" && expandedFolderIds?.has(node.id) || 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(); return originalComponent();
} }
@ -353,12 +728,10 @@ export default definePlugin({
// can cause hang if intl message is not found // can cause hang if intl message is not found
const serversIntlMsg = getIntlMessage("SERVERS"); const serversIntlMsg = getIntlMessage("SERVERS");
if (!serversIntlMsg) { if (!serversIntlMsg) {
new Logger("BetterFolders").error("Failed to get SERVERS intl message");
return true; return true;
} }
return child?.props?.["aria-label"] === serversIntlMsg; return child?.props?.["aria-label"] === serversIntlMsg;
} catch (e) { } catch {
console.error(e);
return true; return true;
} }
}; };
@ -372,14 +745,13 @@ export default definePlugin({
try { try {
return filterTreeWithTargetNode(child, child => child?.props?.renderTreeNode != null); return filterTreeWithTargetNode(child, child => child?.props?.renderTreeNode != null);
} catch (e) { } catch {
console.error(e);
return true; return true;
} }
}; };
}, },
shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<any>) { shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<string | number>) {
if (!isBetterFolders) { if (!isBetterFolders) {
return true; return true;
} }

View file

@ -2,6 +2,11 @@
grid-area: betterFoldersSidebar 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. */ /* These area names need to be hardcoded. Only betterFoldersSidebar is added by the plugin. */
.vc-betterFolders-sidebar-grid { .vc-betterFolders-sidebar-grid {
/* stylelint-disable-next-line value-keyword-case */ /* stylelint-disable-next-line value-keyword-case */
@ -10,4 +15,4 @@
"titleBar titleBar titleBar titleBar" "titleBar titleBar titleBar titleBar"
"guildsList betterFoldersSidebar notice notice" "guildsList betterFoldersSidebar notice notice"
"guildsList betterFoldersSidebar channelsList page"; "guildsList betterFoldersSidebar channelsList page";
} }

View file

@ -7,6 +7,7 @@
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles"; import { disableStyle, enableStyle } from "@api/Styles";
import { buildPluginMenuEntries, buildThemeMenuEntries } from "@equicordplugins/equicordToolbox/menu"; import { buildPluginMenuEntries, buildThemeMenuEntries } from "@equicordplugins/equicordToolbox/menu";
import SettingsPlugin from "@plugins/_core/settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
@ -17,7 +18,17 @@ import type { HTMLAttributes, ReactElement } from "react";
import fullHeightStyle from "./fullHeightContext.css?managed"; 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 cl = classNameFactory("");
const Classes = findCssClassesLazy("animating", "baseLayer", "bg", "layer", "layers"); const Classes = findCssClassesLazy("animating", "baseLayer", "bg", "layer", "layers");
@ -126,26 +137,46 @@ export default definePlugin({
], ],
predicate: () => settings.store.disableFade 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 { // Load menu TOC eagerly
find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}", find: "handleOpenSettingsContextMenu=",
replacement: { replacement: {
match: /(?=handleOpenSettingsContextMenu=.{0,100}?null!=\i&&.{0,100}?(await [^};]*?\)\)))/, match: /(?=handleOpenSettingsContextMenu=.{0,100}?null!=\i&&.{0,100}?(await [^};]*?\)\)))/,
replace: "_vencordBetterSettingsEagerLoad=(async ()=>$1)();" replace: "_vencordBetterSettingsEagerLoad=(async ()=>$1)();"
}, },
predicate: () => settings.store.eagerLoad predicate: () => settings.store.eagerLoad
}, },
{ { // Settings cog context menu
// Settings cog context menu
find: "#{intl::USER_SETTINGS_ACTIONS_MENU_LABEL}", find: "#{intl::USER_SETTINGS_ACTIONS_MENU_LABEL}",
replacement: [ replacement: [
{ {
match: /=\[\];(\i)(?=\.forEach.{0,200}?"logout"===\i.{0,100}?(\i)\.get\(\i\))/, match: /=\[\];(\i)(?=\.forEach.{0,200}?"logout"===\i.{0,100}?(\i)\.get\(\i\))/,
replace: "=$self.wrapMap([]);$self.transformSettingsEntries($1,$2)", replace: "=$self.wrapMap([]);$self.transformSettingsEntries($1,$2)",
predicate: () => settings.store.organizeMenu
}, },
{ {
match: /case \i\.\i\.DEVELOPER_OPTIONS:return \i;/, 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>) { 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) { for (const [section, key] of SettingsPlugin.settingsSectionMap) {
if (item.section === "HEADER") { const entry = SettingsPlugin.customEntries.find(e => key.endsWith(e.key));
keyMap.set(item.label, item.label); if (entry) equicordItems.push({ section, label: entry.title });
items.push({ section: item.label, items: [] }); }
} else if (item.section !== "DIVIDER" && keyMap.has(item.section)) {
items.at(-1)?.items.push(item); 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; return items;
}, },
wrapMap(toWrap: TransformedSettingsEntry[]) { wrapMap(toWrap: TransformedSettingsEntry[]) {
if (!settings.store.organizeMenu) return toWrap;
// @ts-expect-error // @ts-expect-error
toWrap.map = function (render: (item: SettingsEntry) => ReactElement<any>) { toWrap.map = function (render: (item: SettingsEntry) => ReactElement<any>) {
return this return this

View file

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

View file

@ -91,7 +91,7 @@ export default definePlugin({
{ {
find: "useCanFavoriteChannel", find: "useCanFavoriteChannel",
replacement: { replacement: {
match: /(?<=\i\.id\)\),\i=)\i\.isThread\(\)/, match: /(?<=isFavorite\(\i\.id\).{0,5})\i\.isThread\(\)/,
replace: "false", 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 { ApplicationIntegrationType, MessageFlags } from "@vencord/discord-types/enums";
import { AuthenticationStore, Constants, EditMessageStore, FluxDispatcher, MessageActions, MessageTypeSets, PermissionsBits, PermissionStore, PinActions, RestAPI, Toasts, WindowStore } from "@webpack/common"; 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 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"; 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; }[] = [ const actions: { label: string; value: ClickAction; }[] = [
{ label: "None", value: "NONE" }, { label: "None", value: "NONE" },
{ label: "Delete", value: "DELETE" }, { label: "Delete", value: "DELETE" },
@ -130,12 +135,7 @@ let mouseDownCount = 0;
let doubleClickDetected = false; let doubleClickDetected = false;
let secondMouseDownTime = 0; let secondMouseDownTime = 0;
const settings = definePluginSettings({ export const settings = definePluginSettings({
reactEmoji: {
type: OptionType.STRING,
description: "Emoji to use for react actions",
default: "💀"
},
singleClickAction: { singleClickAction: {
type: OptionType.SELECT, type: OptionType.SELECT,
description: "Action on single click (your messages)", description: "Action on single click (your messages)",
@ -190,6 +190,26 @@ const settings = definePluginSettings({
options: modifiers, options: modifiers,
default: "NONE" 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: { disableInDms: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Disable all click actions in direct messages", description: "Disable all click actions in direct messages",
@ -215,7 +235,7 @@ const settings = definePluginSettings({
deferDoubleClickForTriple: { deferDoubleClickForTriple: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Delay double-click to allow triple-click actions (disables triple-click when off)", description: "Delay double-click to allow triple-click actions (disables triple-click when off)",
default: true default: false
}, },
selectionHoldTimeout: { selectionHoldTimeout: {
type: OptionType.NUMBER, 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) => const canSend = (channel: Channel) =>
!channel.guild_id || PermissionStore.can(PermissionsBits.SEND_MESSAGES, 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); MessageTypeSets.REPLYABLE.has(msg.type) && !msg.hasFlag(MessageFlags.EPHEMERAL);
async function toggleReaction(channelId: string, messageId: string, emoji: string, channel: Channel, msg: Message) { async function toggleReaction(channelId: string, messageId: string, emoji: string, channel: Channel, msg: Message) {
const trimmed = emoji.trim(); const emojiParam = normalizeEmoji(emoji);
if (!trimmed) return; if (!emojiParam) return;
if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) { if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
showWarning("Cannot react: Missing permissions"); showWarning("Cannot react: Missing permissions");
return; return;
} }
const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
const emojiParam = customMatch
? `${customMatch[1]}:${customMatch[2]}`
: trimmed;
const hasReacted = msg.reactions?.some(r => { const hasReacted = msg.reactions?.some(r => {
const reactionEmoji = r.emoji.id const reactionEmoji = r.emoji.id
? `${r.emoji.name}:${r.emoji.id}` ? `${r.emoji.name}:${r.emoji.id}`
@ -291,12 +351,47 @@ async function toggleReaction(channelId: string, messageId: string, emoji: strin
}); });
} }
} catch (e) { } 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) { 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) { function togglePin(channel: Channel, msg: Message) {
@ -343,9 +438,7 @@ function quoteMessage(channel: Channel, msg: Message) {
} }
function openInNewTab(msg: Message, channel: Channel) { function openInNewTab(msg: Message, channel: Channel) {
const guildId = channel.guild_id ?? "@me"; VencordNative.native.openExternal(getMessageLink(msg, channel));
const link = `https://discord.com/channels/${guildId}/${channel.id}/${msg.id}`;
VencordNative.native.openExternal(link);
} }
function openInThread(msg: Message, channel: Channel) { function openInThread(msg: Message, channel: Channel) {
@ -462,7 +555,7 @@ async function executeAction(
break; break;
case "REACT": case "REACT":
await toggleReaction(channel.id, msg.id, settings.store.reactEmoji, channel, msg); await reactWithConfiguredEmojis(channel, msg);
event.preventDefault(); event.preventDefault();
break; break;
@ -484,7 +577,7 @@ async function executeAction(
export default definePlugin({ export default definePlugin({
name: "MessageClickActions", name: "MessageClickActions",
description: "Customize click actions on messages.", description: "Customize click actions on messages.",
authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite], authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite, EquicordDevs.omaw],
isModified: true, isModified: true,
settings, settings,
@ -502,18 +595,9 @@ export default definePlugin({
document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("mousedown", onMouseDown);
WindowStore.removeChangeListener(focusChanged); WindowStore.removeChangeListener(focusChanged);
if (doubleClickTimeout) { clearClickTimeouts();
clearTimeout(doubleClickTimeout);
doubleClickTimeout = null;
}
if (singleClickTimeout) {
clearTimeout(singleClickTimeout);
singleClickTimeout = null;
}
pendingDoubleClickAction = null; pendingDoubleClickAction = null;
mouseDownCount = 0; resetClickState();
doubleClickDetected = false;
secondMouseDownTime = 0;
}, },
onMessageClick(msg, channel, event) { onMessageClick(msg, channel, event) {
@ -547,9 +631,7 @@ export default definePlugin({
if (Date.now() - lastMouseDownTime > settings.store.selectionHoldTimeout) { if (Date.now() - lastMouseDownTime > settings.store.selectionHoldTimeout) {
pressedModifiers.clear(); pressedModifiers.clear();
mouseDownCount = 0; resetClickState();
doubleClickDetected = false;
secondMouseDownTime = 0;
return; return;
} }
@ -560,9 +642,7 @@ export default definePlugin({
if (isTripleClick) { if (isTripleClick) {
if (!settings.store.deferDoubleClickForTriple) { if (!settings.store.deferDoubleClickForTriple) {
mouseDownCount = 0; resetClickState();
doubleClickDetected = false;
secondMouseDownTime = 0;
return; return;
} }
if (doubleClickTimeout) { if (doubleClickTimeout) {
@ -576,9 +656,7 @@ export default definePlugin({
pressedModifiers.clear(); pressedModifiers.clear();
} }
doubleClickFired = false; doubleClickFired = false;
mouseDownCount = 0; resetClickState();
doubleClickDetected = false;
secondMouseDownTime = 0;
return; return;
} }
@ -628,9 +706,7 @@ export default definePlugin({
event.preventDefault(); event.preventDefault();
} }
mouseDownCount = 0; resetClickState();
doubleClickDetected = false;
secondMouseDownTime = 0;
return; return;
} }
@ -642,9 +718,7 @@ export default definePlugin({
executeAction(singleClickAction, msg, channel, event); executeAction(singleClickAction, msg, channel, event);
pressedModifiers.clear(); pressedModifiers.clear();
} }
mouseDownCount = 0; resetClickState();
doubleClickDetected = false;
secondMouseDownTime = 0;
}; };
const canDoubleClickWithCurrentModifier = 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: { replacement: {
// Show inside voice channel instead of trying to join them when clicking on a channel mention // Show inside voice channel instead of trying to join them when clicking on a channel mention
match: /(?<=getChannel\(\i\);null!=(\i))(?=.{0,100}?selectVoiceChannel)/, match: /(?<=getChannel\(\i\);if\(null!=(\i)).{0,200}?return void (?=\i\.default\.selectVoiceChannel)/,
replace: (_, channel) => `&&!$self.isHiddenChannel(${channel})` 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 { Button, TextButton } from "@components/Button";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Heading } from "@components/Heading"; import { Heading } from "@components/Heading";
import ircColors from "@plugins/ircColors";
import mentionAvatars from "@plugins/mentionAvatars"; import mentionAvatars from "@plugins/mentionAvatars";
import { Devs, EquicordDevs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/index"; import { classNameFactory, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/index";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { GuildMember, Message, User } from "@vencord/discord-types"; import { GuildMember, Message, User } from "@vencord/discord-types";
import { findByCodeLazy, findStoreLazy } from "@webpack"; 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"; import { JSX } from "react";
const SMYNC = classNameFactory(); const SMYNC = classNameFactory();
@ -28,7 +29,6 @@ const wrapEmojis = findByCodeLazy("lastIndex;return");
const adjustColor = findByCodeLazy("light1", "dark1", "toonStroke"); const adjustColor = findByCodeLazy("light1", "dark1", "toonStroke");
const AccessibilityStore = findStoreLazy("AccessibilityStore"); 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 roleColorPattern = /^role((?:\+|-)\d{0,4})?$/iu;
const symbolPattern = /^[\p{S}\p{P}]{1,3}$/iu; const symbolPattern = /^[\p{S}\p{P}]{1,3}$/iu;
const templatePattern = /(?:\{(?:custom|friend|nick|display|user)(?:,\s*(?:custom|friend|nick|display|user))*\})/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>; type CustomNicknameData = Record<string, string>;
let customNicknames: CustomNicknameData = {}; let customNicknames: CustomNicknameData = {};
function adjustHex(color: string, percent: number): string { let toCSSCache: Map<string, string | null> | null = null;
let hex = color.replace("#", ""); let toCSSProbe: HTMLDivElement | null = null;
if (hex.length === 3) { function toCSS(color: string | number | null | undefined): string | null {
hex = hex.split("").map(c => c + c).join(""); 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); const cached = toCSSCache?.get(color);
let r = (num >> 16) & 0xFF; if (cached !== undefined) return cached ?? null;
let g = (num >> 8) & 0xFF;
let b = num & 0xFF;
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)))); 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)))); 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)))); 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 hex;
return color;
}
return `#${newColor}`;
} }
function validColor(color: string) { function validColor(color: string) {
@ -73,66 +103,51 @@ function validColor(color: string) {
return !isNaN(percentage) && percentage <= 100 && percentage >= -100; return !isNaN(percentage) && percentage <= 100 && percentage >= -100;
} }
const colorTestDiv = document.createElement("div"); return !!toCSS(color);
colorTestDiv.style.borderColor = color;
const isValid = colorTestDiv.style.borderColor !== "" && colorPattern.test(color);
colorTestDiv.remove();
return isValid;
} }
function resolveColor( function resolveColor(
user: User | GuildMember, colorStrings: colorStringsType,
displayNameStyles: { effectId: number; colors: number[]; } | null | undefined,
savedColor: string, savedColor: string,
canUseGradient: boolean, canUseGradient: boolean,
inGuild: boolean, inGuild: boolean,
forceWhite: boolean ircColorsEnabled: boolean,
isHovering: boolean,
): Record<string, any> | null { ): 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 gradient: any = null;
let primaryColor: any = savedColor; let primaryColor: any = null;
let secondaryColor: any = savedColor; let secondaryColor: any = null;
let tertiaryColor: any = savedColor; let tertiaryColor: any = null;
let primaryAdjusted: any = savedColor; let primaryAdjusted: any = null;
let secondaryAdjusted: any = savedColor; let secondaryAdjusted: any = null;
let tertiaryAdjusted: any = savedColor; let tertiaryAdjusted: any = null;
if (savedColor.toLowerCase().includes("role")) { if (isRoleColor) {
const percentage = roleColorPattern.exec(savedColor)?.[1] || ""; const percentage = roleColorPattern.exec(savedColor)?.[1] || "";
if (percentage && isNaN(parseInt(percentage))) return null; if (percentage && isNaN(parseInt(percentage))) return null;
const colorStrings = (user as any)?.colorStrings || {}; primaryColor = forceDefault ? defaultColor : (toCSS(colorStrings?.primaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[0])) || defaultColor);
const displayStyles = !inGuild ? (user as any)?.displayNameStyles || {} : {}; secondaryColor = forceDefault ? null : (toCSS(colorStrings?.secondaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[1])) || null);
tertiaryColor = forceDefault ? null : (toCSS(colorStrings?.tertiaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[2])) || null);
primaryColor = forceWhite ? "#ffffff" : colorStrings.primaryColor || displayStyles?.colors?.[0] || "#ffffff";
if (typeof primaryColor === "number") primaryColor = `#${primaryColor.toString(16).padStart(6, "0")}`;
secondaryColor = forceWhite ? null : colorStrings.secondaryColor || displayStyles?.colors?.[1] || null;
if (typeof secondaryColor === "number") secondaryColor = `#${secondaryColor.toString(16).padStart(6, "0")}`;
tertiaryColor = forceWhite ? null : colorStrings.tertiaryColor || displayStyles?.colors?.[2] || null;
if (typeof tertiaryColor === "number") tertiaryColor = `#${tertiaryColor.toString(16).padStart(6, "0")}`;
primaryAdjusted = 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; 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 ? null
: tertiaryColor : 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))" ? "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-2": secondaryColor || primaryColor,
"--custom-gradient-color-3": tertiaryColor || primaryColor, "--custom-gradient-color-3": tertiaryColor || primaryColor,
"background-image": gradient, "background-image": gradient,
"animation": "smyn-animation 1.5s linear infinite" "animation": "smyn-animation var(--smyn-gradient-duration) linear infinite"
}, },
static: { static: {
original: { original: {
@ -300,6 +315,8 @@ interface mentionProps {
interface messageProps { interface messageProps {
message: Message | null | undefined; message: Message | null | undefined;
colorString?: string;
colorStrings: colorStringsType;
userOverride?: User; userOverride?: User;
isRepliedMessage?: boolean; isRepliedMessage?: boolean;
withMentionPrefix?: boolean; withMentionPrefix?: boolean;
@ -307,12 +324,14 @@ interface messageProps {
interface memberListProfileReactionProps { interface memberListProfileReactionProps {
user: User | null | undefined; user: User | null | undefined;
type: "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel"; type: "typingIndicator" | "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel";
guildId?: string; guildId?: string;
tags?: any; tags?: any;
} }
function getMemberListProfilesReactionsVoiceName( type colorStringsType = { primaryColor: string | null, secondaryColor: string | null, tertiaryColor: string | null; } | null | undefined;
function getTypingMemberListProfilesReactionsVoiceName(
props: memberListProfileReactionProps, props: memberListProfileReactionProps,
): [string | null, JSX.Element | null, string | null] { ): [string | null, JSX.Element | null, string | null] {
const { user, type } = props; const { user, type } = props;
@ -321,16 +340,16 @@ function getMemberListProfilesReactionsVoiceName(
const guildId = props.guildId || props.tags?.props?.displayProfile?.guildId || null; const guildId = props.guildId || props.tags?.props?.displayProfile?.guildId || null;
const member = guildId && user ? GuildMemberStore.getMember(guildId, user.id) : null; const member = guildId && user ? GuildMemberStore.getMember(guildId, user.id) : null;
const author = user && member ? { ...user, ...member } : user || member || 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); return renderUsername(author, null, null, type, "", shouldHookless, !!guildId);
} }
function getMemberListProfilesReactionsVoiceNameText(props: memberListProfileReactionProps): string | null { function getTypingMemberListProfilesReactionsVoiceNameText(props: memberListProfileReactionProps): string | null {
return getMemberListProfilesReactionsVoiceName(props)[2]; return getTypingMemberListProfilesReactionsVoiceName(props)[2];
} }
function getMemberListProfilesReactionsVoiceNameElement(props: memberListProfileReactionProps): JSX.Element | null { function getTypingMemberListProfilesReactionsVoiceNameElement(props: memberListProfileReactionProps): JSX.Element | null {
return getMemberListProfilesReactionsVoiceName(props)[1]; return getTypingMemberListProfilesReactionsVoiceName(props)[1];
} }
function getMessageName(props: messageProps): [string | null, JSX.Element | null, string | null] { 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 member = isWebhook ? null : target && channel ? GuildMemberStore.getMember(channel.guild_id, target.id) : null;
const author = user && member ? { ...user, ...member } : user || member || null; const author = user && member ? { ...user, ...member } : user || member || null;
const mentionSymbol = hideDefaultAtSign && (!isRepliedMessage || replies) ? "" : withMentionPrefix ? "@" : ""; 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 { 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 member = channel ? GuildMemberStore.getMember(channel.guild_id, userId) : null;
const author = user && member ? { ...user, ...member } : user || member || null; const author = user && member ? { ...user, ...member } : user || member || null;
const mentionSymbol = hideDefaultAtSign && mentions ? "" : "@"; 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 { function getEffectType(effectId: number | null | undefined): string | null {
@ -405,14 +437,17 @@ function renderUsername(
author: User | GuildMember | null, author: User | GuildMember | null,
channelId: string | null, channelId: string | null,
messageId: 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, mentionSymbol: string,
hookless: boolean, 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] { ): [string | null, JSX.Element | null, string | null] {
const isMessage = type === "messages"; const isMessage = type === "messages";
const isReply = type === "replies"; const isReply = type === "replies";
const isMention = type === "mentions"; const isMention = type === "mentions";
const isTyping = type === "typingIndicator";
const isMember = type === "membersList"; const isMember = type === "membersList";
const isProfile = type === "profilesPopout"; const isProfile = type === "profilesPopout";
const isReactionsPopout = type === "reactionsPopout"; const isReactionsPopout = type === "reactionsPopout";
@ -420,8 +455,8 @@ function renderUsername(
const isReaction = isReactionsTooltip || isReactionsPopout; const isReaction = isReactionsTooltip || isReactionsPopout;
const isVoice = type === "voiceChannel"; 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 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, memberList, profilePopout, reactions, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages, discriminators, truncateAllNamesWithStreamerMode, removeDuplicates, ignoreGradients, ignoreFonts, animateGradients, includedNames, customNameColor, friendNameColor, nicknameColor, displayNameColor, usernameColor, nameSeparator, triggerNameRerender } = config; 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 channel = channelId ? ChannelStore.getChannel(channelId) || null : null;
const message = channelId && messageId ? MessageStore.getMessage(channelId, messageId) : null; const message = channelId && messageId ? MessageStore.getMessage(channelId, messageId) : null;
@ -435,27 +470,39 @@ function renderUsername(
? hoveringReactionPopoutSet.has((author as User).id) ? hoveringReactionPopoutSet.has((author as User).id)
: false; : 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 effectType = authorDisplayNameStyles ? getEffectType(authorDisplayNameStyles.effectId) : null;
const effectCSSVars = authorDisplayNameStyles ? computeEffectCSSVars(authorDisplayNameStyles) : {}; const effectCSSVars = authorDisplayNameStyles ? computeEffectCSSVars(authorDisplayNameStyles) : {};
const hasEffect = !!effectType; const hasEffect = !!effectType;
const needsEffectDataAttr = effectType === "neon" || effectType === "toon" || effectType === "pop"; const needsEffectDataAttr = effectType === "neon" || effectType === "toon" || effectType === "pop";
const shouldShowEffect = hasEffect && isHovering; const shouldShowEffect = hasEffect && isHovering;
const shouldAnimateEffect = shouldShowEffect && !AccessibilityStore.useReducedMotion; 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 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 useTopStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
const topRoleStyle = author ? resolveColor(author, "Role", canUseGradient, inGuild, shouldUseDMDefault) : null; const topStyle = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, "Role", canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const hasGradient = !!topRoleStyle?.gradient && Object.keys(topRoleStyle.gradient).length > 0; const hasGradient = !!topStyle?.gradient && Object.keys(topStyle.gradient).length > 0;
const textMutedValue = getComputedStyle(document.documentElement)?.getPropertyValue("--text-muted")?.trim() || "#72767d"; const textMutedValue = getComputedStyle(document.documentElement)?.getPropertyValue("--text-muted")?.trim() || "#72767d";
const options = splitTemplate(includedNames); const options = splitTemplate(includedNames);
const resolvedUsernameColor = author ? resolveColor(author, usernameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null; const resolvedUsernameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, usernameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedDisplayNameColor = author ? resolveColor(author, displayNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null; const resolvedDisplayNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, displayNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedNicknameColor = author ? resolveColor(author, nicknameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null; const resolvedNicknameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, nicknameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedFriendNameColor = author ? resolveColor(author, friendNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null; const resolvedFriendNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, friendNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
const resolvedCustomNameColor = author ? resolveColor(author, customNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : 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 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); const { username, display, nick, friend, custom } = getProcessedNames(author, truncateAllNamesWithStreamerMode, discriminators, inGuild, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages);
@ -551,6 +598,8 @@ function renderUsername(
return [null, null, null]; return [null, null, null];
} else if (isMention && !mentions) { } else if (isMention && !mentions) {
return [null, null, null]; return [null, null, null];
} else if (isTyping && !typingIndicator) {
return [null, null, null];
} else if (isMember && !memberList) { } else if (isMember && !memberList) {
return [null, null, null]; return [null, null, null];
} else if (isProfile && !profilePopout) { } else if (isProfile && !profilePopout) {
@ -580,6 +629,7 @@ function renderUsername(
} }
const remainingNames = [first, second, third, fourth, fifth].filter(Boolean); const remainingNames = [first, second, third, fourth, fifth].filter(Boolean);
first = remainingNames.shift(); first = remainingNames.shift();
second = remainingNames.shift(); second = remainingNames.shift();
third = remainingNames.shift(); third = remainingNames.shift();
@ -591,53 +641,56 @@ function renderUsername(
const shouldAnimateSecondaryNames = animateGradients && !ignoreGradients; const shouldAnimateSecondaryNames = animateGradients && !ignoreGradients;
const firstDataText = mentionSymbol + first.name; const firstDataText = mentionSymbol + first.name;
const secondDataText = second && shouldAnimateSecondaryNames ? (second.prefix + second.name + second.suffix) : ""; const secondDataText = second && shouldAnimateSecondaryNames ? second.name : "";
const thirdDataText = third && shouldAnimateSecondaryNames ? (third.prefix + third.name + third.suffix) : ""; const thirdDataText = third && shouldAnimateSecondaryNames ? third.name : "";
const fourthDataText = fourth && shouldAnimateSecondaryNames ? (fourth.prefix + fourth.name + fourth.suffix) : ""; const fourthDataText = fourth && shouldAnimateSecondaryNames ? fourth.name : "";
const fifthDataText = fifth && shouldAnimateSecondaryNames ? (fifth.prefix + fifth.name + fifth.suffix) : ""; const fifthDataText = fifth && shouldAnimateSecondaryNames ? fifth.name : "";
const allDataText = [firstDataText, secondDataText, thirdDataText, fourthDataText, fifthDataText].filter(Boolean).join(nameSeparator).trim(); 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. // Only mentions and reactions popouts should patch in the gradient glow or else a double glow will appear on messages.
const hoveringClass = (isHovering ? " show-me-your-name-gradient-hovered" : ""); const hoveringClass = (isHovering ? " smyn-gradient-hovered" : "");
const gradientClasses = useTopRoleStyle const gradientClasses = useTopStyle
? "show-me-your-name-gradient show-me-your-name-gradient-inherit-bg" + hoveringClass ? "smyn-gradient smyn-gradient-inherit-bg" + hoveringClass
: "show-me-your-name-gradient show-me-your-name-gradient-unset-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 firstGroupClasses = "smyn-name-group smyn-first-name-group";
const secondGroupClasses = "show-me-your-name-name-group show-me-your-name-second-name-group"; const secondGroupClasses = "smyn-name-group smyn-second-name-group";
const thirdGroupClasses = "show-me-your-name-name-group show-me-your-name-third-name-group"; const thirdGroupClasses = "smyn-name-group smyn-third-name-group";
const fourthGroupClasses = "show-me-your-name-name-group show-me-your-name-fourth-name-group"; const fourthGroupClasses = "smyn-name-group smyn-fourth-name-group";
const fifthGroupClasses = "show-me-your-name-name-group show-me-your-name-fifth-name-group"; const fifthGroupClasses = "smyn-name-group smyn-fifth-name-group";
const firstNameClasses = "show-me-your-name-name show-me-your-name-first-name"; const firstNameClasses = "smyn-name smyn-first-name";
const secondNameClasses = "show-me-your-name-name show-me-your-name-second-name"; const secondNameClasses = "smyn-name smyn-second-name";
const thirdNameClasses = "show-me-your-name-name show-me-your-name-third-name"; const thirdNameClasses = "smyn-name smyn-third-name";
const fourthNameClasses = "show-me-your-name-name show-me-your-name-fourth-name"; const fourthNameClasses = "smyn-name smyn-fourth-name";
const fifthNameClasses = "show-me-your-name-name show-me-your-name-fifth-name"; const fifthNameClasses = "smyn-name smyn-fifth-name";
const prefixClasses = "show-me-your-name-affix show-me-your-name-prefix"; const prefixClasses = "smyn-affix smyn-prefix";
const suffixClasses = "show-me-your-name-affix show-me-your-name-suffix"; const suffixClasses = "smyn-affix smyn-suffix";
const animationDuration = Math.max(1, 1.5 * (first.name.length / 12));
const topLevelStyle = { const topLevelStyle = {
// Allows names to wrap in reaction popouts. // Allows names to wrap in reaction popouts.
...(isReactionsPopout ...(isReactionsPopout
? { display: "flex", flexWrap: "wrap", lineHeight: "1.1em", fontSize: "0.9em" } ? { display: "flex", flexWrap: "wrap", lineHeight: "1.1em", fontSize: "0.9em" }
: {}), : {}),
...(hasEffect ? effectCSSVars : {}) ...(hasEffect ? effectCSSVars : {}),
"--smyn-gradient-duration": `${animationDuration}s`
} as React.CSSProperties; } as React.CSSProperties;
const nameElement = ( const nameElement = (
<span <span
style={{ style={{
...topLevelStyle, ...topLevelStyle,
...(topRoleStyle?.normal.original || {}) ...(topStyle?.normal.original || {})
}} }}
className="show-me-your-name-container" className="smyn-container"
> >
{mentionSymbol && <span>{mentionSymbol}</span>} {mentionSymbol && <span>{mentionSymbol}</span>}
{( {(
<span <span
className={SMYNC(firstGroupClasses, { [gradientClasses]: shouldGradientGlow })} className={SMYNC(firstGroupClasses, { [gradientClasses]: shouldGradientGlow })}
data-text={shouldGradientGlow ? firstDataText : undefined} 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 <span
className={SMYNC(firstNameClasses, { className={SMYNC(firstNameClasses, {
@ -648,12 +701,12 @@ function renderUsername(
data-username-with-effects={needsEffectDataAttr && shouldShowEffect ? first.name : undefined} data-username-with-effects={needsEffectDataAttr && shouldShowEffect ? first.name : undefined}
style={shouldShowEffect style={shouldShowEffect
? undefined ? undefined
: topRoleStyle ? : topStyle ?
shouldAnimateGradients && topRoleStyle.gradient shouldAnimateGradients && topStyle.gradient
? topRoleStyle.gradient.animated ? topStyle.gradient.animated
: topRoleStyle.gradient : topStyle.gradient
? topRoleStyle.gradient.static.original ? topStyle.gradient.static.original
: topRoleStyle.normal.original : topStyle.normal.original
: undefined : undefined
}> }>
{first.wrapped}</span> {first.wrapped}</span>
@ -667,9 +720,7 @@ function renderUsername(
].map(({ name, dataText, groupClass, nameClass, position }) => name && ( ].map(({ name, dataText, groupClass, nameClass, position }) => name && (
<span <span
key={position} key={position}
className={SMYNC(groupClass, { [gradientClasses]: shouldGradientGlow && shouldAnimateSecondaryNames })} className={SMYNC(groupClass)}
data-text={shouldGradientGlow && dataText ? dataText : undefined}
style={(shouldGradientGlow && shouldAnimateSecondaryNames ? name.style.gradient.animated : undefined) as React.CSSProperties}
> >
<span style={affixColor as React.CSSProperties} className={prefixClasses}> <span style={affixColor as React.CSSProperties} className={prefixClasses}>
<span>{nameSeparator}</span> <span>{nameSeparator}</span>
@ -677,10 +728,12 @@ function renderUsername(
<span <span
// On non-primary names, allow disabling the effects completely, or just their animation & glow. // On non-primary names, allow disabling the effects completely, or just their animation & glow.
className={SMYNC(nameClass, { className={SMYNC(nameClass, {
[gradientClasses]: shouldGradientGlow && shouldAnimateSecondaryNames,
"smyn-effect-container": shouldShowEffect && !ignoreGradients, "smyn-effect-container": shouldShowEffect && !ignoreGradients,
[`smyn-effect-${effectType}`]: shouldShowEffect && !ignoreGradients, [`smyn-effect-${effectType}`]: shouldShowEffect && !ignoreGradients,
"smyn-effect-animated": shouldAnimateEffect && shouldAnimateSecondaryNames "smyn-effect-animated": shouldAnimateEffect && shouldAnimateSecondaryNames
})} })}
data-text={shouldGradientGlow && dataText ? dataText : undefined}
data-username-with-effects={needsEffectDataAttr && shouldShowEffect && !ignoreGradients ? name.name : undefined} data-username-with-effects={needsEffectDataAttr && shouldShowEffect && !ignoreGradients ? name.name : undefined}
style={{ style={{
...(ignoreFonts ? { "font-family": "var(--font-primary)", "letter-spacing": "normal" } : {}), ...(ignoreFonts ? { "font-family": "var(--font-primary)", "letter-spacing": "normal" } : {}),
@ -814,7 +867,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
style={{ width: "100%" }} style={{ width: "100%" }}
/> />
<TextButton <TextButton
className="show-me-your-name-reset-button" className="smyn-reset-button"
onClick={async () => { onClick={async () => {
setValue(""); setValue("");
delete customNicknames[user.id]; delete customNicknames[user.id];
@ -826,7 +879,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
</TextButton> </TextButton>
<div style={{ paddingTop: "10px", flexGrow: 0 }}></div> <div style={{ paddingTop: "10px", flexGrow: 0 }}></div>
</ModalContent> </ModalContent>
<ModalFooter className="show-me-your-name-modal-footer-container"> <ModalFooter className="smyn-modal-footer-container">
<Button <Button
variant="primary" variant="primary"
onClick={async () => { onClick={async () => {
@ -895,10 +948,15 @@ const settings = definePluginSettings({
default: true, default: true,
description: "Display custom name format in mentions.", 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: { memberList: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
default: true, 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: { profilePopout: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
@ -943,7 +1001,7 @@ const settings = definePluginSettings({
ignoreGradients: { ignoreGradients: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
default: true, 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: { animateGradients: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
@ -973,31 +1031,31 @@ const settings = definePluginSettings({
}, },
customNameColor: { customNameColor: {
type: OptionType.STRING, 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", default: "Role-25",
isValid: validColor, isValid: validColor,
}, },
friendNameColor: { friendNameColor: {
type: OptionType.STRING, 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", default: "Role-25",
isValid: validColor, isValid: validColor,
}, },
nicknameColor: { nicknameColor: {
type: OptionType.STRING, 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", default: "Role-25",
isValid: validColor, isValid: validColor,
}, },
displayNameColor: { displayNameColor: {
type: OptionType.STRING, 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", default: "Role-25",
isValid: validColor, isValid: validColor,
}, },
usernameColor: { usernameColor: {
type: OptionType.STRING, 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", default: "Role-25",
isValid: validColor, isValid: validColor,
}, },
@ -1012,7 +1070,7 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "ShowMeYourName", name: "ShowMeYourName",
description: "Display any permutation of custom nicknames, friend nicknames, server nicknames, display names, and usernames in chat.", 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",], tags: ["SMYN", "Nicknames", "Custom Nicknames",],
isModified: true, isModified: true,
settings, settings,
@ -1026,8 +1084,8 @@ export default definePlugin({
replacement: [ replacement: [
{ {
// Replace names in messages and replies. // Replace names in messages and replies.
match: /(onContextMenu:\i,children:)(.{0,250}?),"data-text":(\i\+\i)/, match: /(?<=colorString:(\i),colorStrings:(\i).{0,900}?)style:.{0,120}?,(onClick:\i,onContextMenu:\i,children:)(.{0,250}?),"data-text":(\i\+\i)/,
replace: "$1$self.getMessageNameElement(arguments[0])??($2),\"data-text\":$self.getMessageNameText(arguments[0])??($3)" 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. // 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. // Replace names in DMs list.
find: "ImpressionNames.DM_LIST_RIGHT_CLICK_MENU_SHOWN", find: "ImpressionNames.DM_LIST_RIGHT_CLICK_MENU_SHOWN",
replacement: { replacement: {
match: /(?<=getMentionCount\(\i.id\)>0\),\i=)/, 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(),", find: "channel.isSystemDM(),",
replacement: { replacement: {
match: /(?<=}\);)(return.{0,500}?{text:)(\i,position:"bottom",children:.{0,40}?children:)(\i\?\?\i.\i.getName\(\i\))/, 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:", find: "let{colorRoleName:",
replacement: { replacement: {
match: /(let{colorRoleName:\i,colorString:\i,colorStrings:\i,)name:(\i)/, 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", find: "shouldWrap:!0,loop:!0,inProfile:!0",
replacement: { replacement: {
match: /(tags:\i,)nickname:(\i)/, 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: [ replacement: [
{ {
match: /(displayName:)(\i.\i.getName\(void 0,void 0,\i\))/, 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\))/, 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,", find: "reactionTooltip1,",
replacement: { replacement: {
match: /(\i.\i.getName\((\i),\i\?\.id,(\i)\))/, 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. // Replace names in reaction popouts.
match: /(?<=Child,{className:\i.\i,children:)/g, 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:", find: ",connectUserDragSource:",
replacement: { replacement: {
match: /(serverDeaf:\i,)nick:(\i)/, 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() { 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"); const data = await DataStore.get<CustomNicknameData>("SMYNCustomNicknames");
customNicknames = data ?? {}; customNicknames = data ?? {};
}, },
stop() {
toCSSCache?.clear();
toCSSCache = null;
toCSSProbe = null;
convertToRGBCache?.clear();
convertToRGBCache = null;
convertToRGBCanvas = null;
convertToRGBCtx = null;
},
contextMenus: { contextMenus: {
"user-context": userContextPatch "user-context": userContextPatch
}, },
@ -1191,6 +1291,6 @@ export default definePlugin({
getMessageNameText, getMessageNameText,
getMessageNameElement, getMessageNameElement,
getMentionNameElement, getMentionNameElement,
getMemberListProfilesReactionsVoiceNameText, getTypingMemberListProfilesReactionsVoiceNameText,
getMemberListProfilesReactionsVoiceNameElement getTypingMemberListProfilesReactionsVoiceNameElement
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,7 +32,7 @@ export class Logger {
constructor(public name: string, public color: string = "white") { } constructor(public name: string, public color: string = "white") { }
private _log(level: "log" | "error" | "warn" | "info" | "debug", levelColor: string, args: any[], customFmt = "") { 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); console[level]("[Equicord]", this.name + ":", ...args);
return; return;
} }

View file

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

View file

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

View file

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

View file

@ -142,7 +142,7 @@ export const UserUtils = {
export const UploadManager = findByPropsLazy("clearAll", "addFile"); export const UploadManager = findByPropsLazy("clearAll", "addFile");
export const UploadHandler = { 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 === [", { 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 fetchApplicationsRPC = findByCodeLazy('"Invalid Origin"', ".application");
export const CloudUploader = findLazy(m => m.prototype?.trackUploadFinished) as typeof t.CloudUpload; 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");