Merge branch 'dev' into translation

This commit is contained in:
thororen1234 2026-02-26 11:34:53 -05:00
commit 7166fdc109
No known key found for this signature in database
91 changed files with 6194 additions and 2105 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": {
@ -123,4 +123,4 @@
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
} }

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

@ -55,76 +55,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

@ -224,7 +224,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,"
@ -234,7 +234,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

@ -98,15 +98,21 @@ const settings = definePluginSettings({
accountStandingButton: { accountStandingButton: {
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: t("equicordHelper.settings.accountStandingButton"), description: t("equicordHelper.settings.accountStandingButton"),
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: t("equicordHelper.description"), description: t("equicordHelper.description"),
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: {
@ -165,6 +171,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),
@ -201,6 +208,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,134 +5,160 @@
*/ */
import { Devs, EquicordDevs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
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: t("favouriteAnything.description"), description: t("favouriteAnything.description"),
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

@ -101,7 +101,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

@ -23,7 +23,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";
@ -386,11 +386,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;
@ -548,40 +581,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

@ -18,6 +18,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");
@ -75,12 +89,6 @@ const settings = definePluginSettings({
}, },
}); });
const args = {
ringing: [],
messageId: "",
region: "",
};
export default definePlugin({ export default definePlugin({
name: "IgnoreCalls", name: "IgnoreCalls",
description: t("ignoreCalls.description"), description: t("ignoreCalls.description"),
@ -100,8 +108,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;
} }
@ -114,6 +123,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
}); });
@ -134,6 +144,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,130 +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 { t } from "@utils/translation";
import definePlugin, { OptionType } from "@utils/types";
const MIDDLE_CLICK = 1;
let lastMiddleClickUp = 0;
const settings = definePluginSettings({
scope: {
type: OptionType.SELECT,
description: t("limitMiddleClickPaste.settings.scope"),
options: [
{
label: t("limitMiddleClickPaste.options.alwaysPrevent"),
value: "always",
default: true
},
{
label: t("limitMiddleClickPaste.options.onlyWhenNotFocused"),
value: "focus"
},
]
},
threshold: {
type: OptionType.NUMBER,
description: t("limitMiddleClickPaste.settings.threshold"),
default: 100,
onChange(newValue) { if (newValue < 1) { settings.store.threshold = 1; } },
},
preventLinkOpen: {
type: OptionType.BOOLEAN,
description: t("limitMiddleClickPaste.settings.preventLinkOpen"),
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: t("limitMiddleClickPaste.description"),
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,17 +8,18 @@ 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 { t } from "@utils/translation"; import { t } from "@utils/translation";
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-");
@ -31,6 +32,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";
@ -151,7 +160,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</>;
@ -216,7 +225,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>;
} }
@ -232,6 +241,7 @@ export default definePlugin({
name: "MessagePeek", name: "MessagePeek",
description: t("messagePeek.description"), description: t("messagePeek.description"),
authors: [Devs.prism, EquicordDevs.justjxke], authors: [Devs.prism, EquicordDevs.justjxke],
settings,
patches: [ patches: [
{ {
find: "PrivateChannel.renderAvatar", find: "PrivateChannel.renderAvatar",
@ -253,11 +263,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

@ -53,10 +53,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

@ -42,6 +42,10 @@ const settings = definePluginSettings({
label: t("platformSpoofer.settings.platformOptions.playstation"), label: t("platformSpoofer.settings.platformOptions.playstation"),
value: "playstation", value: "playstation",
}, },
{
label: "VR",
value: "vr",
},
] ]
} }
}); });
@ -49,7 +53,7 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "PlatformSpoofer", name: "PlatformSpoofer",
description: t("platformSpoofer.description"), description: t("platformSpoofer.description"),
authors: [EquicordDevs.Drag], authors: [EquicordDevs.Drag, EquicordDevs.neoarz],
settingsAboutComponent: () => ( settingsAboutComponent: () => (
<Notice.Warning> <Notice.Warning>
{t("platformSpoofer.warning")} {t("platformSpoofer.warning")}
@ -96,6 +100,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

@ -19,9 +19,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: t("profileSets.settings.avatarSize"), description: t("profileSets.settings.avatarSize"),
default: 40, markers: [56, 64, 72, 80, 88, 96],
default: 56,
stickToMarkers: true
}, },
}); });
@ -41,8 +43,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, presets, PresetSection, type ProfilePresetEx,removePreset, replaceAllPresets, savePresetsData, updatePreset } 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

@ -1008,6 +1008,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; }
@ -1295,8 +1297,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??{})"
} }
}, },
{ {
@ -1604,17 +1606,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:)/,
@ -1625,11 +1619,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

@ -13,11 +13,11 @@ import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, Mod
import { t } from "@utils/translation"; import { t } from "@utils/translation";
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: {
@ -106,7 +106,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

@ -11,8 +11,8 @@ import { EquicordDevs } from "@utils/constants";
import { closeModal, openModal } from "@utils/modal"; import { closeModal, openModal } from "@utils/modal";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
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";
@ -20,7 +20,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

@ -151,7 +151,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

@ -27,7 +27,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

@ -25,11 +25,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> {
@ -92,15 +87,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;
@ -109,6 +106,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}`}
@ -117,7 +115,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

@ -32,8 +32,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 { t } from "@utils/translation"; import { t } from "@utils/translation";
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";
@ -49,6 +50,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;
} }
@ -70,6 +82,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

@ -46,6 +46,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",
@ -90,9 +95,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

@ -13,7 +13,7 @@ import { t } from "@utils/translation";
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;
@ -34,6 +34,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;
@ -49,6 +50,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

@ -76,7 +76,7 @@ export default definePlugin({
patches: [ patches: [
{ {
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}", find: ".WIDGETS_RTC_UPSELL_COACHMARK)",
group: true, group: true,
replacement: [ replacement: [
{ {
@ -88,7 +88,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

@ -22,10 +22,10 @@ import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
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";
@ -36,15 +36,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() {
@ -53,22 +80,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;
@ -80,6 +116,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,
@ -133,8 +238,10 @@ export const settings = definePluginSettings({
{ label: t("betterFolders.settings.folderIconMultipleExpanded"), value: FolderIconDisplay.MoreThanOneFolderExpanded } { label: t("betterFolders.settings.folderIconMultipleExpanded"), 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";
@ -147,6 +254,9 @@ export default definePlugin({
isModified: true, isModified: true,
settings, settings,
start() {
settings.store.nestedFolders ??= {};
},
patches: [ patches: [
{ {
@ -191,6 +301,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(",
@ -249,6 +367,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,
@ -264,7 +399,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} "+$&`
} }
@ -278,6 +413,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])"
}
} }
], ],
@ -309,10 +473,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;
@ -327,12 +495,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();
} }
@ -354,12 +729,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;
} }
}; };
@ -373,14 +746,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";
@ -18,7 +19,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");
@ -127,26 +138,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();"
} }
] ]
}, },
@ -171,21 +202,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

@ -88,7 +88,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
{ {
@ -100,7 +100,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

@ -92,7 +92,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

@ -462,11 +462,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,6 +13,7 @@ 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";
@ -20,7 +21,7 @@ import { t } from "@utils/translation";
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();
@ -29,7 +30,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;
@ -37,29 +37,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) {
@ -74,66 +104,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))"
@ -165,7 +180,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: {
@ -301,6 +316,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;
@ -308,12 +325,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;
@ -322,16 +341,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] {
@ -344,7 +363,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 {
@ -363,7 +382,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 {
@ -406,14 +438,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";
@ -421,8 +456,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;
@ -436,27 +471,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);
@ -552,6 +599,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) {
@ -581,6 +630,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();
@ -592,53 +642,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, {
@ -649,12 +702,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>
@ -668,9 +721,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>
@ -678,10 +729,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" } : {}),
@ -815,7 +868,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];
@ -827,7 +880,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 () => {
@ -896,6 +949,11 @@ const settings = definePluginSettings({
default: true, default: true,
description: t("showMeYourName.settings.mentions"), description: t("showMeYourName.settings.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,
@ -1013,7 +1071,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,
@ -1027,8 +1085,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.
@ -1037,12 +1095,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"
}, },
}, },
{ {
@ -1050,7 +1133,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"
}, },
}, },
{ {
@ -1091,7 +1174,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)"
} }
}, },
{ {
@ -1099,7 +1182,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)"
}, },
}, },
{ {
@ -1110,11 +1193,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)"
} }
] ]
}, },
@ -1123,7 +1206,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)"
} }
}, },
{ {
@ -1138,7 +1221,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\"}))??"
} }
] ]
}, },
@ -1147,16 +1230,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
}, },
@ -1192,6 +1292,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

@ -23,7 +23,7 @@ import definePlugin from "@utils/types";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
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;
@ -56,7 +56,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

@ -38,7 +38,7 @@ function toggleStreamerMode({ streamKey }: StreamEvent, value: boolean) {
export default definePlugin({ export default definePlugin({
name: "StreamerModeOn", name: "StreamerModeOn",
description: t("streamerModeOnStream.description"), description: t("streamerModeOnStream.description"),
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

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

View file

@ -16,7 +16,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

@ -56,16 +56,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");