Merge branch 'dev' into translation
This commit is contained in:
commit
7166fdc109
91 changed files with 6194 additions and 2105 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
212
.rules
Normal 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
1
AGENTS.md
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
.rules
|
||||||
1
CLAUDE.md
Symbolic link
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
.rules
|
||||||
1
GEMINI.md
Symbolic link
1
GEMINI.md
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
.rules
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
2
packages/discord-types/src/common/User.d.ts
vendored
2
packages/discord-types/src/common/User.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
25
packages/discord-types/src/components.d.ts
vendored
25
packages/discord-types/src/components.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
26
packages/discord-types/src/stores/PendingReplyStore.d.ts
vendored
Normal file
26
packages/discord-types/src/stores/PendingReplyStore.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
1
packages/discord-types/src/stores/index.d.ts
vendored
1
packages/discord-types/src/stores/index.d.ts
vendored
|
|
@ -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";
|
||||||
|
|
|
||||||
134
packages/discord-types/src/utils.d.ts
vendored
134
packages/discord-types/src/utils.d.ts
vendored
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
294
src/equicordplugins/favouriteAnything/components.tsx
Normal file
294
src/equicordplugins/favouriteAnything/components.tsx
Normal 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")} />;
|
||||||
|
}
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
31
src/equicordplugins/favouriteAnything/native.ts
Normal file
31
src/equicordplugins/favouriteAnything/native.ts
Normal 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 };
|
||||||
|
}
|
||||||
176
src/equicordplugins/favouriteAnything/polyfills.ts
Normal file
176
src/equicordplugins/favouriteAnything/polyfills.ts
Normal 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);
|
||||||
|
}
|
||||||
98
src/equicordplugins/favouriteAnything/stores.ts
Normal file
98
src/equicordplugins/favouriteAnything/stores.ts
Normal 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;
|
||||||
|
});
|
||||||
112
src/equicordplugins/favouriteAnything/style.css
Normal file
112
src/equicordplugins/favouriteAnything/style.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/equicordplugins/favouriteAnything/types.ts
Normal file
140
src/equicordplugins/favouriteAnything/types.ts
Normal 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;
|
||||||
|
}
|
||||||
312
src/equicordplugins/favouriteAnything/utils.ts
Normal file
312
src/equicordplugins/favouriteAnything/utils.ts
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }) => (
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
src/equicordplugins/hideMessages/index.tsx
Normal file
70
src/equicordplugins/hideMessages/index.tsx
Normal 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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;};"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
150
src/equicordplugins/middleClickTweaks/index.ts
Normal file
150
src/equicordplugins/middleClickTweaks/index.ts
Normal 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;};"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -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,"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
33
src/equicordplugins/noPushToTalk/index.ts
Normal file
33
src/equicordplugins/noPushToTalk/index.ts
Normal 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}"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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),$&"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
66
src/equicordplugins/triviaAI/index.tsx
Normal file
66
src/equicordplugins/triviaAI/index.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
53
src/equicordplugins/triviaAI/settings.ts
Normal file
53
src/equicordplugins/triviaAI/settings.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
188
src/equicordplugins/triviaAI/utils.ts
Normal file
188
src/equicordplugins/triviaAI/utils.ts
Normal 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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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} />}
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
170
src/plugins/messageClickActions/ReactEmojiSetting.tsx
Normal file
170
src/plugins/messageClickActions/ReactEmojiSetting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
7
src/plugins/messageClickActions/styles.css
Normal file
7
src/plugins/messageClickActions/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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})&&`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue