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:
|
||||
disable: false
|
||||
comment_severity_threshold: LOW
|
||||
comment_severity_threshold: MEDIUM
|
||||
max_review_comments: -1
|
||||
pull_request_opened:
|
||||
help: false
|
||||
summary: false
|
||||
code_review: false
|
||||
include_drafts: false
|
||||
|
||||
ignore_patterns:
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "dist/**"
|
||||
- "browser/**"
|
||||
- "packages/**"
|
||||
- "patches/**"
|
||||
- "misc/**"
|
||||
- "scripts/**"
|
||||
- ".vscode/**"
|
||||
- ".github/**"
|
||||
- "*.map"
|
||||
|
|
|
|||
|
|
@ -1,134 +1,383 @@
|
|||
title: Equicord Review Guidelines
|
||||
# Equicord Code Review Style Guide
|
||||
|
||||
rules:
|
||||
critical:
|
||||
licenseHeader: NEVER edit existing license headers, new files use 2026
|
||||
checkDeprecated: always verify components/functions arent deprecated before using
|
||||
noAnyTypes: NEVER use any for Discord objects, import proper types from @vencord/discord-types
|
||||
minimalChanges: fix with smallest possible change, no refactoring unless explicitly asked
|
||||
noOverengineering: solve the problem at hand only, no patterns for patterns sake
|
||||
humanText: write natural human text in errors, descriptions, messages. No dashes or robotic formatting. Write like "Module not found" not "Module - not found". Keep it simple and conversational
|
||||
code:
|
||||
deleteNotComment: DELETE dead code, dont comment it, git preserves history
|
||||
noHardcoded: no hardcoded values, unused imports, magic numbers
|
||||
useClassNameFactory: import classNameFactory from @utils/css (NOT @api/Styles which is deprecated), ALWAYS use cl() for class names, never hardcode strings like "vc-plugin-name-class"
|
||||
useClasses: when combining multiple class names use classes() from @utils/misc, not template strings like `${a} ${b}`
|
||||
useExisting: use utilities from @utils/, @api/, @components/
|
||||
logger: use Logger from @utils/Logger, not console.log
|
||||
descriptions: capital first letter, end with period
|
||||
minimal: less code with same functionality is ALWAYS better
|
||||
noComments: NEVER add comments unless explicitly asked
|
||||
preserveComments: keep existing comments, they contain important context
|
||||
performance: use Map/Set, .some/.find, no spread in loops, Promise.all
|
||||
simple: KISS over clever, flat over nested, explicit over implicit
|
||||
typescript:
|
||||
prefer: optional chaining (?.), nullish coalescing (??), const, arrow functions
|
||||
use: destructuring, template literals, object shorthand, array methods
|
||||
style: early returns, trust inference, inline single-use variables
|
||||
react:
|
||||
errorBoundary: wrap complex components with ErrorBoundary.wrap(Component, { noop: true })
|
||||
nullNotUndefined: return null for conditional rendering not undefined
|
||||
forbidden: React.cloneElement, React.isValidElement, React.memo(), React.lazy, React.Children
|
||||
cleanup: always return cleanup functions in useEffect
|
||||
forbidden:
|
||||
DOM: direct DOM manipulation (use patches)
|
||||
cssOnly: CSS-only plugins
|
||||
emptyCatch: empty catch blocks
|
||||
hardcodedVars: hardcoded minified vars in patches (e,t,n)
|
||||
settingsArrays: NEVER use settings.use() with arrays, mutate then reassign
|
||||
antiPatterns{bad ⟹ good}:
|
||||
"value !== null && value !== undefined" ⟹ "value"
|
||||
"array && array.length > 0" ⟹ "array.length"
|
||||
"settings?.store?.value" ⟹ "settings.store.value"
|
||||
"value || defaultValue" ⟹ "value ?? defaultValue"
|
||||
You are reviewing PRs for **Equicord**, a Discord client mod built on Vencord. It uses TypeScript, React, and webpack patching to inject into Discord. The codebase has extensive built-in utilities that contributors MUST use. Be direct, actionable, no pleasantries. Use natural, human language. Be blunt when code is bad.
|
||||
|
||||
patching:
|
||||
core:
|
||||
surgical: minimum code touched, stability over cleverness, one patch per concern
|
||||
iterate: try multiple approaches, keep simplifying until you find the cleanest solution
|
||||
minimize: remove any regex character that doesnt change the match result
|
||||
balanced: not overengineered, not overlooked, just clean and future-proof
|
||||
find:
|
||||
preferIntl: use #{intl::KEY} in find when possible, most stable anchor
|
||||
makeUnique: if find matches multiple modules, add nearby stable string like "),icon:" or function name
|
||||
combine: "#{intl::PIN_MESSAGE}),icon:" is better than generic strings
|
||||
match:
|
||||
shortestPath: match only what you need to replace, let find do the targeting
|
||||
intlInMatch: can use #{intl::KEY} in match too, gets canonicalized to hash
|
||||
simpleWins: /#{intl::KEY}\)/ beats /label:\i\.pinned\?.{0,60}#{intl::KEY}\)/
|
||||
useAmpersand: $& keeps original, append/prepend to it
|
||||
boundedGaps: .{0,50} not .+? or .*?
|
||||
captureOnlyIfNeeded: only use capture groups if reusing in replace
|
||||
NEVER:
|
||||
hardcodedMinified: e,t,n,r,i,o,s,l,c,u,$_,xx,eD,eH,eW ⟹ use \i instead
|
||||
minifiedChains: \i\.\i alone ⟹ surround with stable strings
|
||||
unboundedGaps: .+? or .*? ⟹ use .{0,N}
|
||||
genericPatterns: /className:\i/ alone ⟹ add anchor
|
||||
rawIntlHash: .aA4Vce ⟹ #{intl::KEY_NAME}
|
||||
examples:
|
||||
clean: |
|
||||
find: "#{intl::PIN_MESSAGE}),icon:"
|
||||
match: /#{intl::PIN_MESSAGE}\)/
|
||||
replace: "$self.getPinLabel(arguments[0]))"
|
||||
append: |
|
||||
find: "#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}"
|
||||
match: /#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}.{0,100}(?=])/
|
||||
replace: "$&,$self.renderTooltip(arguments[0].guild)"
|
||||
wrap: |
|
||||
find: "#{intl::THREE_USERS_TYPING}"
|
||||
match: /(?<="aria-atomic":!0,children:)\i/
|
||||
replace: "$self.renderTypingUsers({ children: $& })"
|
||||
---
|
||||
|
||||
intl:
|
||||
syntax: "#{intl::KEY}" or "#{intl::HASH::raw}" if key unknown
|
||||
workflow: hash in code → reverse to get key → use #{intl::KEY} or fallback to #{intl::HASH::raw}
|
||||
## Plugin Acceptance (Instant Reject)
|
||||
|
||||
pluginInterop:
|
||||
checkEnabled: isPluginEnabled(name) from @api/PluginManager takes a string, checks required/isDependency/enabled
|
||||
import: import the plugin directly from its path to access .name and functions
|
||||
avoid: dont use Vencord.Plugins.plugins or plugin.started, dont use "as unknown as" casting
|
||||
example: |
|
||||
import { isPluginEnabled } from "@api/PluginManager";
|
||||
import otherPlugin from "@equicordplugins/otherPlugin";
|
||||
if (!isPluginEnabled(otherPlugin.name)) return null;
|
||||
otherPlugin.someFunction();
|
||||
If a plugin breaks ANY of these, reject the entire PR. These are non-negotiable:
|
||||
|
||||
reference:
|
||||
types: Channel, Guild, GuildMember, User, Role, Message from @vencord/discord-types
|
||||
components: Paragraph, BaseText, Flex, Button, ErrorCard from @components/
|
||||
settings: use definePluginSettings from @api/Settings, not inline settings object
|
||||
utilities:
|
||||
clipboard: copyToClipboard from @utils/clipboard
|
||||
discord: insertTextIntoChatInputBox, getCurrentChannel, getCurrentGuild, getIntlMessage, openUserProfile, openPrivateChannel, sendMessage, copyWithToast, getUniqueUsername, fetchUserProfile from @utils/discord
|
||||
css: classNameFactory, classNameToSelector from @utils/css
|
||||
misc: classes, sleep, isObject, isObjectEmpty, pluralise, parseUrl, identity from @utils/misc
|
||||
text: formatDuration, formatDurationMs, humanFriendlyJoin, makeCodeblock, toInlineCode, escapeRegExp from @utils/text
|
||||
modal: openModal, closeModal, ModalRoot, ModalHeader, ModalContent, ModalCloseButton from @utils/modal
|
||||
margins: Margins from @utils/margins (Margins.top8, Margins.bottom16, etc.)
|
||||
guards: isTruthy, isNonNullish from @utils/guards
|
||||
web: saveFile, chooseFile from @utils/web
|
||||
webpackCommon:
|
||||
icons: IconUtils from @webpack/common, NEVER hardcode cdn.discordapp.com URLs
|
||||
getUserAvatarURL: IconUtils.getUserAvatarURL(user, canAnimate?, size?)
|
||||
getDefaultAvatarURL: IconUtils.getDefaultAvatarURL(id)
|
||||
getUserBannerURL: IconUtils.getUserBannerURL({ id, banner, canAnimate?, size })
|
||||
getGuildIconURL: IconUtils.getGuildIconURL({ id, icon, size?, canAnimate? })
|
||||
getGuildBannerURL: IconUtils.getGuildBannerURL(guild, canAnimate?)
|
||||
getChannelIconURL: IconUtils.getChannelIconURL({ id, icon })
|
||||
getEmojiURL: IconUtils.getEmojiURL({ id, animated, size })
|
||||
getApplicationIconURL: IconUtils.getApplicationIconURL(data)
|
||||
getGameAssetURL: IconUtils.getGameAssetURL(data)
|
||||
stores: UserStore, GuildStore, ChannelStore, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, PresenceStore, RelationshipStore, MessageStore, EmojiStore, ThemeStore, PermissionStore, VoiceStateStore from @webpack/common
|
||||
actions: RestAPI, FluxDispatcher, MessageActions, NavigationRouter, ChannelRouter, ChannelActionCreators, SettingsRouter from @webpack/common
|
||||
utils: Constants (Constants.Endpoints), SnowflakeUtils, Parser, PermissionsBits, moment, lodash, ColorUtils, ImageUtils, DateUtils, UsernameUtils, DisplayProfileUtils from @webpack/common
|
||||
components: Tooltip, TextInput, TextArea, Select, Slider, Avatar, Menu, Popout, ScrollerThin, Timestamp from @webpack/common
|
||||
toasts: Toasts, showToast from @webpack/common
|
||||
imports:
|
||||
lazy: findByPropsLazy, findStoreLazy, findComponentByCodeLazy, findExportedComponentLazy from @webpack
|
||||
common: useState, useEffect, useCallback, useStateFromStores from @webpack/common
|
||||
neverHardcode:
|
||||
cdnURLs: NEVER use "cdn.discordapp.com/avatars/", "cdn.discordapp.com/banners/", "cdn.discordapp.com/emojis/", "cdn.discordapp.com/icons/", "cdn.discordapp.com/channel-icons/" ⟹ use IconUtils
|
||||
apiEndpoints: NEVER hardcode "/api/v9", "/users/@me" ⟹ use Constants.Endpoints or RestAPI
|
||||
console: NEVER use console.log/warn/error ⟹ use Logger from @utils/Logger
|
||||
classConcat: NEVER use `${a} ${b}` for class names ⟹ use classes(a, b) from @utils/misc
|
||||
defaultAvatars: NEVER hardcode "/assets/*.png" for default avatars ⟹ use IconUtils.getDefaultAvatarURL(id)
|
||||
1. **No simple slash-command plugins** (e.g. `/cat`). Should be a [user-installable Discord app](https://discord.com/developers/docs/change-log#userinstallable-apps-preview) instead.
|
||||
2. **No simple text replacement plugins.** The built-in TextReplace plugin already covers this.
|
||||
3. **No raw DOM manipulation.** Always use patches and React.
|
||||
4. **No FakeDeafen or FakeMute.**
|
||||
5. **No StereoMic-related plugins.**
|
||||
6. **No UI-only hide/redesign plugins.** Use CSS for that. (Negotiable in rare cases.)
|
||||
7. **No plugins targeting specific third-party Discord bots.** Official Discord apps are fine.
|
||||
8. **No selfbot or API abuse.** Auto-replies, animated statuses, message pruning, Nitro snipers, etc.
|
||||
9. **No untrusted third-party APIs.** Well-known services (Google, GitHub) are acceptable.
|
||||
10. **No plugins requiring users to provide their own API keys.**
|
||||
11. **No new dependencies** unless strictly necessary and well justified.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
Default export via `definePlugin` from `@utils/types`. Non-negotiable.
|
||||
|
||||
```typescript
|
||||
import definePlugin from "@utils/types";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
|
||||
export default definePlugin({
|
||||
name: "PluginName", // PascalCase, matches directory name
|
||||
description: "Does something.", // Capital first, period at end
|
||||
authors: [EquicordDevs.Name], // EquicordDevs for new, Devs for upstream
|
||||
});
|
||||
```
|
||||
|
||||
**Settings** must use `definePluginSettings` from `@api/Settings`. Reject inline `options` objects.
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
const settings = definePluginSettings({
|
||||
myOption: { type: OptionType.BOOLEAN, description: "Enables the feature.", default: true }
|
||||
});
|
||||
export default definePlugin({ settings, /* ... */ });
|
||||
|
||||
// BAD
|
||||
export default definePlugin({ options: { myOption: { /* ... */ } } });
|
||||
```
|
||||
|
||||
**Prefer declarative APIs** over manual registration:
|
||||
|
||||
- `flux: { EVENT_NAME(data) {} }` not manual FluxDispatcher subscribe
|
||||
- `contextMenus: { "nav-id": fn }` not manual addContextMenuPatch
|
||||
- `chatBarButton: { render, icon }` not addChatBarButton
|
||||
- `messagePopoverButton: { render, icon }` not addMessagePopoverButton
|
||||
- `managedStyle` for CSS not manual enableStyle/disableStyle
|
||||
|
||||
**Reject deprecated fields:** `renderChatBarButton`, `renderMessagePopoverButton`, `options`
|
||||
|
||||
**Lifecycle balance:** anything registered in `start()` must be cleaned up in `stop()`.
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
Flag these and suggest the fix:
|
||||
|
||||
| Bad | Good | Reason |
|
||||
|-----|------|--------|
|
||||
| `value !== null && value !== undefined` | `value` or `isNonNullish(value)` | Verbose |
|
||||
| `array && array.length > 0` | `array.length` | Redundant check |
|
||||
| `settings?.store?.value` | `settings.store.value` | Store is always defined |
|
||||
| `value \|\| defaultValue` | `value ?? defaultValue` | `\|\|` falsifies `0`, `""`, `false` |
|
||||
| `` `${classA} ${classB}` `` | `classes(classA, classB)` | Handles null/false gracefully |
|
||||
| `"vc-plugin-class"` hardcoded | `cl("class")` via `classNameFactory` | Consistent, typo-proof |
|
||||
| `console.log/warn/error` | Remove it | No logging in plugin code |
|
||||
| `new Logger(...)` / `logger.log(...)` | Remove it | No logging in plugin code |
|
||||
| `cdn.discordapp.com/avatars/...` | `IconUtils.getUserAvatarURL(user)` | Handles animated, sizing, CDN |
|
||||
| `cdn.discordapp.com/icons/...` | `IconUtils.getGuildIconURL(...)` | Same |
|
||||
| `cdn.discordapp.com/banners/...` | `IconUtils.getUserBannerURL(...)` | Same |
|
||||
| `cdn.discordapp.com/emojis/...` | `IconUtils.getEmojiURL(...)` | Same |
|
||||
| `/api/v9/...` or `/users/@me` | `Constants.Endpoints.*` or `RestAPI` | Endpoints change |
|
||||
| `@api/Styles` for classNameFactory | `@utils/css` | `@api/Styles` is deprecated |
|
||||
| `any` for Discord objects | Import from `@vencord/discord-types` | Type safety |
|
||||
| `as unknown as` casting | Find the correct type | Unsafe |
|
||||
| `React.memo()` | Remove it | Not needed |
|
||||
| `React.cloneElement` / `React.isValidElement` / `React.Children` | Find another approach | Forbidden |
|
||||
| `React.lazy(() => import(...))` | `LazyComponent` from `@utils/lazyReact` | Framework-integrated |
|
||||
| Empty `catch {}` | Handle the error (toast, fallback, rethrow) | Silent failures |
|
||||
| CSS-only plugins | Must have actual logic/patches | Not allowed |
|
||||
| Commented-out dead code | Delete it | Git has history |
|
||||
| `document.querySelector(...)` | Use webpack patches | DOM manipulation forbidden |
|
||||
| `Vencord.Plugins.plugins["X"]` | `isPluginEnabled` + direct import | Proper interop |
|
||||
| `plugin.started` check | `isPluginEnabled(plugin.name)` | Proper interop |
|
||||
| Unexplained magic numbers | Named constants | Readability |
|
||||
| Unused imports | Remove them | Cleanliness |
|
||||
|
||||
---
|
||||
|
||||
## TypeScript and Code Style
|
||||
|
||||
**Prefer:** `?.` optional chaining, `??` nullish coalescing, `const`, arrow functions, destructuring, template literals, object shorthand, array methods (`.map`, `.filter`, `.find`, `.some`).
|
||||
|
||||
**Style:** early returns over nested conditions, trust TypeScript inference (don't annotate obvious types), inline single-use variables, flat over nested, explicit over implicit.
|
||||
|
||||
**Less is more.** Less code with the same functionality is always better. KISS over clever.
|
||||
|
||||
**No comments.** Never add comments unless explicitly asked. However, preserve existing comments as they contain important context.
|
||||
|
||||
```typescript
|
||||
// GOOD — early return, destructuring, inference, no comments
|
||||
const getUser = (id: string) => {
|
||||
const user = UserStore.getUser(id);
|
||||
if (!user) return null;
|
||||
const { username, discriminator } = user;
|
||||
return `${username}#${discriminator}`;
|
||||
};
|
||||
|
||||
// BAD — nested, verbose, unnecessary annotations
|
||||
function getUser(id: string): string | null {
|
||||
// Get the user from the store
|
||||
const user = UserStore.getUser(id);
|
||||
if (user) {
|
||||
const username: string = user.username;
|
||||
const discriminator: string = user.discriminator;
|
||||
return username + "#" + discriminator;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: ESLint already enforces `prefer-const`, `prefer-destructuring`, `eqeqeq`, strict equality, unused imports, and import sorting. Focus review on patterns ESLint cannot catch: code philosophy, unnecessary complexity, missing early returns, and unnecessary comments.
|
||||
|
||||
---
|
||||
|
||||
## React
|
||||
|
||||
**Conditional rendering:** return `null`, never `undefined` or bare `return;`
|
||||
|
||||
**ErrorBoundary:** wrap complex components: `ErrorBoundary.wrap(MyComponent, { noop: true })`
|
||||
|
||||
**useEffect cleanup:** always return cleanup when subscribing to events, timers, or resources:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const handler = () => { /* ... */ };
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Discord Types
|
||||
|
||||
Never use `any` for Discord objects. Import proper types:
|
||||
|
||||
```typescript
|
||||
import { User, Channel, Guild, GuildMember, Message, Role } from "@vencord/discord-types";
|
||||
```
|
||||
|
||||
Store types, component prop types, and action types are all available. Never use `as unknown as` casting as a workaround.
|
||||
|
||||
---
|
||||
|
||||
## CSS and Class Names
|
||||
|
||||
**Every plugin** must use `classNameFactory` from `@utils/css` for class names:
|
||||
|
||||
```typescript
|
||||
import { classNameFactory } from "@utils/css";
|
||||
const cl = classNameFactory("vc-my-plugin-");
|
||||
cl("container") // "vc-my-plugin-container"
|
||||
```
|
||||
|
||||
**Combining classes** from different sources: use `classes()` from `@utils/misc`:
|
||||
|
||||
```typescript
|
||||
className={classes(cl("wrapper"), someDiscordClass, isActive && cl("active"))}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
- `settings.store.key` — reactive, auto-persists (React components)
|
||||
- `settings.plain.key` — non-reactive raw value (performance-critical code)
|
||||
- **Never** `settings.use()` with arrays in variables. Mutate then reassign instead.
|
||||
|
||||
---
|
||||
|
||||
## Patch Quality
|
||||
|
||||
Patches modify Discord's minified webpack modules. Stability is paramount.
|
||||
|
||||
- **One patch per concern.** Each replacement does one thing.
|
||||
- **Surgical.** Match only what needs replacing, let `find` target the module.
|
||||
- **No hardcoded minified vars.** Never `e`, `t`, `n`, `r`, `i`, `eD`, `eH` etc. Use `\i`.
|
||||
- **Bounded gaps.** `.{0,50}` not `.+?` or `.*?` (unbounded = cross-match bugs).
|
||||
- **No generic patterns.** `/className:\i/` alone is too broad. Add stable anchors.
|
||||
- **No raw intl hashes.** Use `#{intl::KEY_NAME}` not `.aA4Vce`.
|
||||
- **Capture groups only when reused** in replace via `$1`, `$2`.
|
||||
- **`$&`** for append/prepend, **`$self`** for plugin method calls.
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
patches: [{
|
||||
find: "#{intl::PIN_MESSAGE}),icon:",
|
||||
replacement: {
|
||||
match: /#{intl::PIN_MESSAGE}\)/,
|
||||
replace: "$self.getPinLabel(arguments[0]))"
|
||||
}
|
||||
}]
|
||||
|
||||
// BAD
|
||||
patches: [{
|
||||
find: "pinMessage",
|
||||
replacement: {
|
||||
match: /label:e\.pinned\?.+?pinMessage\)/,
|
||||
replace: "$self.getPinLabel(e))"
|
||||
}
|
||||
}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Interop
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
import { isPluginEnabled } from "@api/PluginManager";
|
||||
import otherPlugin from "@equicordplugins/otherPlugin";
|
||||
if (!isPluginEnabled(otherPlugin.name)) return null;
|
||||
otherPlugin.someFunction();
|
||||
|
||||
// BAD
|
||||
Vencord.Plugins.plugins["OtherPlugin"].someFunction();
|
||||
(somePlugin as unknown as { method(): void }).method();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Text and Descriptions
|
||||
|
||||
- Plugin and setting descriptions: capital first letter, end with period.
|
||||
- Error messages and toasts: natural human text, no dashes or robotic formatting.
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
description: "Adds a button to copy message content."
|
||||
showToast("Module not found");
|
||||
|
||||
// BAD
|
||||
description: "adds a button to copy message content"
|
||||
showToast("Module - not found");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- `Map`/`Set` for frequent lookups, not arrays
|
||||
- `.find()` / `.some()` not `.filter()[0]`
|
||||
- No spread `...` in loops
|
||||
- `Promise.all()` for parallel async
|
||||
|
||||
---
|
||||
|
||||
## Memory Leaks and Cleanup
|
||||
|
||||
Plugins that don't use patches don't require a Discord restart to apply. Their `start()` and `stop()` must work cleanly so users can toggle them on/off without leaking resources.
|
||||
|
||||
**Every `start()` must have a matching `stop()`.** If `start()` subscribes, registers, or creates anything, `stop()` must undo it. Common leaks to flag:
|
||||
|
||||
- Event listeners added but never removed (FluxDispatcher, DOM events, MessageEvents)
|
||||
- Intervals/timeouts set but never cleared
|
||||
- MutationObservers created but never disconnected
|
||||
- Context menu patches added but never removed (use declarative `contextMenus` instead)
|
||||
- Chat bar buttons added but never removed (use declarative `chatBarButton` instead)
|
||||
- Styles enabled but never disabled (use `managedStyle` instead)
|
||||
- Flux subscriptions without corresponding unsubscriptions
|
||||
|
||||
**Prefer declarative APIs** that handle cleanup automatically: `flux`, `contextMenus`, `chatBarButton`, `messagePopoverButton`, `managedStyle`. These don't need manual cleanup.
|
||||
|
||||
**useEffect must return cleanup** when it creates subscriptions, timers, observers, or any persistent resource. No exceptions.
|
||||
|
||||
**Watch for stale closures and references.** If a plugin stores references to DOM nodes, channels, or users in module scope, check that they're nulled out in `stop()`.
|
||||
|
||||
```typescript
|
||||
// GOOD — clean start/stop, no leaks
|
||||
start() {
|
||||
this.interval = setInterval(this.update, 5000);
|
||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
},
|
||||
stop() {
|
||||
clearInterval(this.interval);
|
||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
}
|
||||
|
||||
// BAD — leaks interval and listener forever
|
||||
start() {
|
||||
setInterval(this.update, 5000);
|
||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
},
|
||||
stop() {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Hygiene
|
||||
|
||||
- **Delete dead code**, don't comment it out. Git preserves history.
|
||||
- **No overengineering.** No premature abstractions, no "just in case" handling, no features not requested. Three similar lines beats a one-use helper. If code can be half the size and do the same thing, it should be.
|
||||
- **No bloat.** Flag unnecessary wrapper functions, redundant type annotations, excessive error handling for things that can't fail, useless try/catch around synchronous code, or 50 lines doing what 10 could do. Code should be lean.
|
||||
- **No unused imports.** Every import must be referenced.
|
||||
- **No logging in plugin code.** No `console.log/warn/error`, no `Logger`. Remove all logging statements.
|
||||
- **No empty catch blocks.** Handle every error (show toast, return fallback, rethrow, etc.).
|
||||
|
||||
---
|
||||
|
||||
## AI Slop Detection
|
||||
|
||||
If a PR shows signs of being AI-generated, mention it but **still review the code fully**. Do not reject or stop reviewing just because it looks AI-generated. Note it as an observation, then continue reviewing every issue as you normally would. Common AI slop patterns:
|
||||
|
||||
- Excessive comments explaining obvious code ("// get the user", "// check if null", "// return the result")
|
||||
- Overly verbose variable names that read like sentences (`isUserCurrentlyLoggedInToTheApplication`)
|
||||
- Unnecessary abstractions and wrapper functions for trivial operations
|
||||
- "Just in case" error handling everywhere, including places that can never fail
|
||||
- Reimplementing utilities that already exist in the codebase (check the Built-in Utilities section)
|
||||
- Generic, boilerplate-heavy code that looks like it was prompted with "write a Discord plugin that..."
|
||||
- Excessive JSDoc/TSDoc on every function, even internal one-liners
|
||||
- Overly defensive coding: null checks on things that are never null, type guards on known types
|
||||
- Cookie-cutter patterns repeated without understanding (e.g. wrapping every single line in try/catch)
|
||||
- Descriptions and messages that sound robotic or templated rather than human-written
|
||||
|
||||
If you suspect it, say something like: "This looks like it might be AI-generated. There's a lot of unnecessary comments, over-engineering, and reimplemented utilities." Then proceed with the full review, flagging every concrete issue as usual.
|
||||
|
||||
---
|
||||
|
||||
## Built-in Utilities Reference
|
||||
|
||||
If a PR reimplements any of these, flag it. They already exist:
|
||||
|
||||
**@utils/misc:** `classes`, `sleep`, `isObject`, `isObjectEmpty`, `parseUrl`, `pluralise`, `identity`
|
||||
**@utils/guards:** `isTruthy`, `isNonNullish`
|
||||
**@utils/text:** `formatDuration`, `formatDurationMs`, `humanFriendlyJoin`, `makeCodeblock`, `toInlineCode`, `escapeRegExp`
|
||||
**@utils/discord:** `getCurrentChannel`, `getCurrentGuild`, `getIntlMessage`, `openPrivateChannel`, `insertTextIntoChatInputBox`, `sendMessage`, `copyWithToast`, `openUserProfile`, `fetchUserProfile`, `getUniqueUsername`, `openInviteModal`
|
||||
**@utils/css:** `classNameFactory`, `classNameToSelector`
|
||||
**@utils/clipboard:** `copyToClipboard`
|
||||
**@utils/modal:** `openModal`, `closeModal`, `ModalRoot`, `ModalHeader`, `ModalContent`, `ModalFooter`, `ModalCloseButton`
|
||||
**@utils/margins:** `Margins.top8`, `.top16`, `.bottom8`, `.bottom16` etc.
|
||||
**@utils/web:** `saveFile`, `chooseFile`
|
||||
**@utils/lazy:** `proxyLazy`, `makeLazy`
|
||||
**@utils/lazyReact:** `LazyComponent`
|
||||
**@utils/react:** `useAwaiter`, `useForceUpdater`, `useTimer`
|
||||
**@api/DataStore:** `get`, `set`, `del` (IndexedDB, async)
|
||||
**@api/Commands:** `sendBotMessage`, `findOption`
|
||||
|
||||
**@webpack/common:**
|
||||
|
||||
- Stores: `UserStore`, `GuildStore`, `ChannelStore`, `GuildMemberStore`, `SelectedChannelStore`, `SelectedGuildStore`, `PresenceStore`, `RelationshipStore`, `MessageStore`, `EmojiStore`, `ThemeStore`, `PermissionStore`, `VoiceStateStore`, 30+ more
|
||||
- Actions: `RestAPI`, `FluxDispatcher`, `MessageActions`, `NavigationRouter`, `ChannelRouter`, `ChannelActionCreators`, `SettingsRouter`
|
||||
- Utils: `Constants` (`.Endpoints`), `SnowflakeUtils`, `Parser`, `PermissionsBits`, `moment`, `lodash`, `IconUtils`, `ColorUtils`, `ImageUtils`, `DateUtils`, `UsernameUtils`, `DisplayProfileUtils`
|
||||
- Components: `Tooltip`, `TextInput`, `TextArea`, `Select`, `Slider`, `Avatar`, `Menu`, `Popout`, `ScrollerThin`, `Timestamp`, `MaskedLink`, `ColorPicker`
|
||||
- Toasts: `Toasts`, `showToast`
|
||||
- React: `useState`, `useEffect`, `useCallback`, `useStateFromStores`
|
||||
|
||||
**@webpack finders:** `findByPropsLazy`, `findByCodeLazy`, `findStoreLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy`
|
||||
|
||||
**@components/:** `ErrorBoundary`, `Flex`, `Button`, `Paragraph`, `Heading`, `BaseText`, `Span`, `ErrorCard`, `Link`, `CodeBlock`, `FormSwitch`
|
||||
|
||||
---
|
||||
|
||||
## Review Severity
|
||||
|
||||
1. **CRITICAL** — `any` types, hardcoded CDN/API URLs, direct DOM manipulation, security issues, massive overengineering/bloat, suspected AI slop (note it, but still review everything)
|
||||
2. **HIGH** — Missing useEffect cleanup, forbidden React APIs, hardcoded class names, logging statements (console or Logger), missing definePluginSettings, unnecessary abstractions
|
||||
3. **MEDIUM** — Anti-patterns (`||` instead of `??`), utility reimplementations, commented-out code, bad description format, excessive comments
|
||||
4. **LOW** — Style preferences, minor performance wins, organization
|
||||
|
|
|
|||
212
.rules
Normal file
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,
|
||||
onCheckUpdates: NOOP,
|
||||
onRepair: NOOP,
|
||||
onAbout: NOOP,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "equicord",
|
||||
"private": "true",
|
||||
"version": "1.14.2.0",
|
||||
"version": "1.14.3.0",
|
||||
"description": "The other cutest Discord client mod",
|
||||
"homepage": "https://github.com/Equicord/Equicord#readme",
|
||||
"bugs": {
|
||||
|
|
@ -123,4 +123,4 @@
|
|||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ export interface PermissionOverwrite {
|
|||
export interface AvatarDecorationData {
|
||||
asset: string;
|
||||
expires_at: string | null;
|
||||
sku_id: string;
|
||||
skuId: string;
|
||||
}
|
||||
|
||||
/** Clan identity information. */
|
||||
export interface ClanData {
|
||||
badge: string;
|
||||
identity_enabled: boolean;
|
||||
identity_guild_id: string;
|
||||
tag: string;
|
||||
identityEnabled: boolean;
|
||||
identityGuildId: string;
|
||||
}
|
||||
|
||||
/** Display name style configuration. */
|
||||
|
|
@ -38,7 +38,8 @@ export interface CollectibleItem {
|
|||
expires_at: string | null;
|
||||
label: string;
|
||||
palette: string;
|
||||
sku_id: string;
|
||||
type: number;
|
||||
skuId: string;
|
||||
}
|
||||
|
||||
/** User collectibles configuration. */
|
||||
|
|
|
|||
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 createdAt(): Date;
|
||||
get isProvisional(): boolean;
|
||||
get nameplate(): { asset: string; sku_id: string; } | null;
|
||||
get nameplate(): { asset: string; skuId: string; } | null;
|
||||
get premiumGroupRole(): { id: string; name: string; color: number; } | null;
|
||||
get tag(): string;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ export interface EmbedMedia {
|
|||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
proxyURL: string;
|
||||
proxyURL?: string;
|
||||
placeholder: string;
|
||||
placeholderVersion: number;
|
||||
description: string;
|
||||
description?: string;
|
||||
srcIsAnimated: boolean;
|
||||
flags: number;
|
||||
contentType: string;
|
||||
|
|
|
|||
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;
|
||||
}>>;
|
||||
|
||||
interface ScrollToOptions {
|
||||
animate?: boolean;
|
||||
callback?: (() => unknown);
|
||||
}
|
||||
|
||||
/** Full type can be found at {@link https://github.com/fedeericodl/discord-client-types/blob/main/src/discord_common/js/packages/design/components/Scroller/utils/core/getAnimatedScrollHelpers.ts} */
|
||||
export interface ScrollerBaseRef {
|
||||
scrollTo: (props: { to: number; } & ScrollToOptions) => void;
|
||||
scrollPageUp: (props?: ScrollToOptions) => void;
|
||||
scrollPageDown: (props?: ScrollToOptions) => void;
|
||||
scrollToTop: (props?: ScrollToOptions) => void;
|
||||
scrollToBottom: (props?: ScrollToOptions) => void;
|
||||
isScrolledToTop: () => boolean;
|
||||
isScrolledToBottom: () => boolean;
|
||||
getDistanceFromTop: () => number;
|
||||
getDistanceFromBottom: () => number;
|
||||
}
|
||||
|
||||
export interface ScrollerBaseProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
|
|
@ -418,6 +436,7 @@ export interface ScrollerBaseProps {
|
|||
paddingFix?: boolean;
|
||||
onClose?(): void;
|
||||
onScroll?(): void;
|
||||
ref?: Ref<ScrollerBaseRef>;
|
||||
}
|
||||
|
||||
export type ScrollerThin = ComponentType<PropsWithChildren<ScrollerBaseProps & {
|
||||
|
|
@ -448,9 +467,9 @@ export type ListScrollerThin = ComponentType<ScrollerBaseProps & {
|
|||
renderSidebar?: (listVisible: boolean, sidebarVisible: boolean) => React.ReactNode;
|
||||
wrapSection?: (section: number, children: React.ReactNode) => React.ReactNode;
|
||||
|
||||
sectionHeight: number;
|
||||
rowHeight: number;
|
||||
footerHeight?: number;
|
||||
sectionHeight: number | ((section: number) => number);
|
||||
rowHeight: number | ((section: number, row: number) => number);
|
||||
footerHeight?: number | ((section: number) => number);
|
||||
sidebarHeight?: number;
|
||||
|
||||
chunkSize?: number;
|
||||
|
|
|
|||
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 "./NotificationSettingsStore";
|
||||
export * from "./OverridePremiumTypeStore";
|
||||
export * from "./PendingReplyStore";
|
||||
export * from "./PermissionStore";
|
||||
export * from "./PopoutWindowStore";
|
||||
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"
|
||||
| "parseTopic"
|
||||
| "parseEmbedTitle"
|
||||
| "parseEmbedTitleWithoutLinks"
|
||||
| "parseInlineReply"
|
||||
| "parseGuildVerificationFormRule"
|
||||
| "parseGuildEventDescription"
|
||||
| "parseAutoModerationSystemMessage"
|
||||
| "parseForumPostGuidelines"
|
||||
| "parseForumPostMostRecentMessage",
|
||||
| "parseVoiceChannelStatus",
|
||||
(content: string, inline?: boolean, state?: Record<string, any>) => ReactNode[]
|
||||
> & Record<"defaultRules" | "guildEventRules", Record<string, Record<"react" | "html" | "parse" | "match" | "order", any>>>;
|
||||
> & Record<
|
||||
| "parseToAST"
|
||||
| "parseTopicToAST"
|
||||
| "parseEmbedTitleToAST"
|
||||
| "parseEmbedTitleWithoutLinksToAST"
|
||||
| "parseInlineReplyToAST"
|
||||
| "parseAutoModerationSystemMessageToAST",
|
||||
(content: string, inline?: boolean, state?: Record<string, any>) => any[]
|
||||
> & Record<"defaultRules" | "guildEventRules" | "notifCenterV2MessagePreviewRules" | "lockscreenWidgetMessageRules", Record<string, Record<"react" | "html" | "parse" | "match" | "order", any>>> & {
|
||||
reactParserFor(rules: Record<string, any>): (content: string, inline?: boolean, state?: Record<string, any>) => ReactNode[];
|
||||
astParserFor(rules: Record<string, any>): (content: string, inline?: boolean, state?: Record<string, any>) => any[];
|
||||
createReactRules(options: Record<string, any>): Record<string, any>;
|
||||
defaultReactRuleOptions: Record<string, any>;
|
||||
combineAndInjectMentionRule(rules: Record<string, any>, mentionRule: Record<string, any>): Record<string, any>;
|
||||
};
|
||||
|
||||
export interface Alerts {
|
||||
show(alert: {
|
||||
|
|
@ -48,16 +63,39 @@ export interface Alerts {
|
|||
onConfirmSecondary?(): void;
|
||||
onCloseCallback?(): void;
|
||||
}): void;
|
||||
confirm(alert: {
|
||||
title: any;
|
||||
body: React.ReactNode;
|
||||
className?: string;
|
||||
confirmColor?: string;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
secondaryConfirmText?: string;
|
||||
onCancel?(): void;
|
||||
onConfirmSecondary?(): void;
|
||||
onCloseCallback?(): void;
|
||||
}): Promise<boolean>;
|
||||
/** This is a noop, it does nothing. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface SnowflakeUtils {
|
||||
fromTimestamp(timestamp: number): string;
|
||||
fromTimestampWithSequence(timestamp: number, sequence: number): string;
|
||||
extractTimestamp(snowflake: string): number;
|
||||
age(snowflake: string): number;
|
||||
atPreviousMillisecond(snowflake: string): string;
|
||||
atNextMillisecond(snowflake: string): string;
|
||||
compare(snowflake1?: string, snowflake2?: string): number;
|
||||
isProbablyAValidSnowflake(snowflake: string): boolean;
|
||||
castChannelIdAsMessageId(channelId: string): string;
|
||||
castMessageIdAsChannelId(messageId: string): string;
|
||||
castGuildIdAsEveryoneGuildRoleId(guildId: string): string;
|
||||
cast(snowflake: string): string;
|
||||
keys(obj: Record<string, any>): string[];
|
||||
forEach(obj: Record<string, any>, callback: (value: any, key: string) => void): void;
|
||||
forEachKey(obj: Record<string, any>, callback: (key: string) => void): void;
|
||||
entries(obj: Record<string, any>): [string, any][];
|
||||
}
|
||||
|
||||
interface RestRequestData {
|
||||
|
|
@ -249,45 +287,55 @@ export interface UsernameUtils {
|
|||
useName(user: User): string;
|
||||
getUserTag(user: User, options?: UserNameUtilsTagOptions): string;
|
||||
useUserTag(user: User, options?: UserNameUtilsTagOptions): string;
|
||||
|
||||
|
||||
isNameConcealed(user: User): boolean;
|
||||
getUserIsStaff(user: User): boolean;
|
||||
useDirectMessageRecipient: any;
|
||||
humanizeStatus: any;
|
||||
}
|
||||
|
||||
// TODO: fix type
|
||||
export class DisplayProfile {
|
||||
userId: string;
|
||||
guildId?: string;
|
||||
banner?: string;
|
||||
bio?: string;
|
||||
pronouns?: string;
|
||||
accentColor?: number;
|
||||
themeColors?: number[];
|
||||
profileEffect?: any;
|
||||
popoutAnimationParticleType?: any;
|
||||
profileEffectId?: string;
|
||||
fetchStartedAt?: Date | null;
|
||||
fetchEndedAt?: Date | null;
|
||||
_userProfile?: any;
|
||||
_guildMemberProfile?: any;
|
||||
canUsePremiumProfileCustomization: boolean;
|
||||
canEditThemes: boolean;
|
||||
premiumGuildSince: Date | null;
|
||||
premiumSince: Date | null;
|
||||
premiumType?: PremiumType;
|
||||
primaryColor?: number;
|
||||
|
||||
get premiumSince(): Date | null;
|
||||
get premiumGuildSince(): Date | null;
|
||||
get premiumType(): PremiumType | undefined;
|
||||
get private(): boolean;
|
||||
get widgets(): any[] | undefined;
|
||||
get gameWidgets(): any[] | undefined;
|
||||
get primaryColor(): number | undefined;
|
||||
get canUsePremiumProfileCustomization(): boolean;
|
||||
get canEditThemes(): boolean;
|
||||
get application(): any;
|
||||
get isLoaded(): boolean;
|
||||
|
||||
hasThemeColors(): boolean;
|
||||
hasPremiumCustomization(): boolean;
|
||||
isUsingGuildMemberBanner(): boolean;
|
||||
isUsingGuildMemberBio(): boolean;
|
||||
isUsingGuildMemberPronouns(): boolean;
|
||||
getBannerURL(options: { canAnimate: boolean; size: number; }): string;
|
||||
getPreviewBanner(banner: string | null, canAnimate: boolean, size?: number): string | null;
|
||||
getPreviewBio(bio: string | undefined): string | undefined;
|
||||
getPreviewThemeColors(themeColors: number[] | undefined): number[] | undefined;
|
||||
getBadges(): Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
link?: string;
|
||||
}>;
|
||||
getBannerURL(options: { canAnimate: boolean; size: number; }): string;
|
||||
getLegacyUsername(): string | null;
|
||||
hasFullProfile(): boolean;
|
||||
hasPremiumCustomization(): boolean;
|
||||
hasThemeColors(): boolean;
|
||||
isUsingGuildMemberBanner(): boolean;
|
||||
isUsingGuildMemberBio(): boolean;
|
||||
isUsingGuildMemberPronouns(): boolean;
|
||||
}
|
||||
|
||||
export interface DisplayProfileUtils {
|
||||
|
|
@ -317,3 +365,51 @@ export interface CommandOptions {
|
|||
max_value?: number;
|
||||
autocomplete?: boolean;
|
||||
}
|
||||
|
||||
export interface URLUtils {
|
||||
URL_REGEX: RegExp;
|
||||
makeUrl(url: string): URL | null;
|
||||
isDiscordUrl(url: string): boolean;
|
||||
isDiscordCdnUrl(url: string): boolean;
|
||||
isDiscordAssetUrl(url: string, proxyUrl?: string, originalUrl?: string): boolean;
|
||||
isDiscordDirectAssetUrl(url: string): boolean;
|
||||
isDiscordProxiedAssetUrl(url: string, proxyUrl?: string, originalUrl?: string): boolean;
|
||||
isDiscordHostname(hostname: string): boolean;
|
||||
isDiscordLocalhost(hostname: string): boolean;
|
||||
isDiscordProtocol(url: string): boolean;
|
||||
isDiscordUri(url: string): boolean;
|
||||
isDiscordUrlOrUri(url: string): boolean;
|
||||
isAllowedGifProviderUrl(url: string): boolean;
|
||||
isAppRoute(url: string): boolean;
|
||||
isOriginalContentTypeDifferent(url: string, proxyUrl: string): boolean;
|
||||
toURLSafe(url: string): URL | null;
|
||||
safeDecodeURIComponent(component: string): string;
|
||||
safeParseWithQuery(url: string): URL | null;
|
||||
format(url: URL): string;
|
||||
formatPathWithQuery(path: string, query: Record<string, string>): string;
|
||||
formatSearch(query: Record<string, string>): string;
|
||||
}
|
||||
|
||||
export interface Humanize {
|
||||
filesize(bytes: number, base?: number, decimals?: number, decimalSep?: string, thousandsSep?: string, suffix?: string): string;
|
||||
intword(number: number, units?: string[], base?: number, decimals?: number, decimalSep?: string, thousandsSep?: string): string;
|
||||
relativeTime(timestamp?: number): string;
|
||||
naturalDay(timestamp?: number, format?: string): string;
|
||||
ordinal(value: number): string;
|
||||
numberFormat(number: number, decimals?: number, decimalSep?: string, thousandsSep?: string): string;
|
||||
truncatechars(str: string, maxLength: number): string;
|
||||
truncatewords(str: string, maxWords: number): string;
|
||||
pad(str: string, length: number, padChar?: string, direction?: "left" | "right"): string;
|
||||
date(format: string, timestamp?: Date | number): string;
|
||||
time(): number;
|
||||
linebreaks(str: string): string;
|
||||
nl2br(str: string): string;
|
||||
}
|
||||
|
||||
export interface EmojiUtils {
|
||||
getEmojiColors(emoji: any): number[] | null;
|
||||
getURL(emoji: any): string;
|
||||
filterUnsupportedEmojis(emojis: any[]): any[];
|
||||
triggerFullscreenAnimation(emoji: any, node: HTMLElement): void;
|
||||
applyPlatformToThemedEmojiColorPalette(colors: any): any;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,6 @@ export default {
|
|||
setUpdateState: (available: boolean) => ipcRenderer.send(IpcEvents.SET_TRAY_UPDATE_STATE, available),
|
||||
onCheckUpdates: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_CHECK_UPDATES, cb); },
|
||||
onRepair: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_REPAIR, cb); },
|
||||
onAbout: (cb: () => void) => { ipcRenderer.on(IpcEvents.TRAY_ABOUT, cb); },
|
||||
},
|
||||
|
||||
pluginHelpers: PluginHelpers
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ async function runReporter() {
|
|||
|
||||
await loadLazyChunksDone;
|
||||
|
||||
if (IS_REPORTER && IS_WEB && !IS_VESKTOP) {
|
||||
if (IS_REPORTER && IS_WEB && !IS_VESKTOP && !IS_EQUIBOP) {
|
||||
console.log("[REPORTER_META]", {
|
||||
buildNumber: getBuildNumber(),
|
||||
buildHash: window.GLOBAL_ENV.SENTRY_TAGS.buildId
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export default definePlugin({
|
|||
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
find: ".WIDGETS_RTC_UPSELL_COACHMARK),",
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=className:(\i)\.\i,style:\i,)children:\[/,
|
||||
|
|
@ -27,7 +27,7 @@ export default definePlugin({
|
|||
},
|
||||
// fix discord weird shrink with extra buttons
|
||||
{
|
||||
match: /(?<=\{ref:\i,)style:(\i)(?=,onMouseEnter:\i,onMouseDown:\i,onClick)/,
|
||||
match: /(?<=\{ref:\i,)style:(\i)/,
|
||||
replace: "style:{...$1,minWidth:0}"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -55,76 +55,77 @@ export default definePlugin({
|
|||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
replacement: {
|
||||
match: /,nameplate:\i,selectedGuildId:(\i),avatarDecoration/,
|
||||
replace: ",nameplate:null,selectedGuildId:$1,avatarDecoration"
|
||||
},
|
||||
predicate: () => settings.store.removeNameplate
|
||||
},
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=#{intl::MUTE}\),)className:\i\.\i,/,
|
||||
replace: ""
|
||||
match: /,nameplate:\i,selectedGuildId:(\i),avatarDecoration/,
|
||||
replace: ",nameplate:null,selectedGuildId:$1,avatarDecoration",
|
||||
predicate: () => settings.store.removeNameplate
|
||||
},
|
||||
{
|
||||
match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/,
|
||||
replace: ""
|
||||
match: /hoverText:(\i),forceHover:\i,children:/g,
|
||||
replace: "hoverText:$1,forceHover:!0,children:",
|
||||
predicate: () => settings.store.alwaysShowUsername
|
||||
},
|
||||
{
|
||||
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,600}#{intl::ACCOUNT_INPUT_OPTIONS}\)\}\)(?=\])/,
|
||||
replace: ""
|
||||
match: /avatarDecoration:(.{0,70}),size:/,
|
||||
replace: "avatarDecoration:void 0,size:",
|
||||
predicate: () => settings.store.removeAvatarDecoration
|
||||
},
|
||||
{
|
||||
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,650}#{intl::ACCOUNT_OUTPUT_OPTIONS}\)\}\)(?=\])/,
|
||||
replace: ""
|
||||
match: /displayNameStyles:(\i),/,
|
||||
replace: "displayNameStyles:void 0,",
|
||||
predicate: () => settings.store.removeUsernameStyles
|
||||
}
|
||||
],
|
||||
predicate: () => settings.store.removeAudioMenus
|
||||
},
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
replacement: {
|
||||
match: /hoverText:(\i),forceHover:\i,children:/g,
|
||||
replace: "hoverText:$1,forceHover:!0,children:"
|
||||
},
|
||||
predicate: () => settings.store.alwaysShowUsername
|
||||
},
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
find: '"MicrophoneButton"',
|
||||
replacement: [
|
||||
{
|
||||
match: /:\{tooltipText:\i\};/,
|
||||
replace: ":{tooltipText:void 0};"
|
||||
replace: ":{tooltipText:void 0};",
|
||||
predicate: () => settings.store.removeButtonTooltips
|
||||
},
|
||||
{
|
||||
match: /(?<=#{intl::MUTE}\),)className:\i\.\i,/,
|
||||
replace: "",
|
||||
predicate: () => settings.store.removeAudioMenus
|
||||
},
|
||||
{
|
||||
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,600}#{intl::ACCOUNT_INPUT_OPTIONS}\)\}\)(?=\])/,
|
||||
replace: "",
|
||||
predicate: () => settings.store.removeAudioMenus
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
find: "#{intl::f+DDY/::raw},{outputDeviceName",
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=role:"switch",)tooltipText:\i\}/,
|
||||
replace: "tooltipText:void 0}"
|
||||
replace: "tooltipText:void 0}",
|
||||
predicate: () => settings.store.removeButtonTooltips
|
||||
},
|
||||
{
|
||||
match: /tooltipText:\i,tooltipPositionKey/,
|
||||
replace: "tooltipText:void 0,tooltipPositionKey"
|
||||
match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/,
|
||||
replace: "",
|
||||
predicate: () => settings.store.removeAudioMenus
|
||||
},
|
||||
{
|
||||
match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,650}#{intl::ACCOUNT_OUTPUT_OPTIONS}\)\}\)(?=\])/,
|
||||
replace: "",
|
||||
predicate: () => settings.store.removeAudioMenus
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}",
|
||||
replacement: {
|
||||
match: /tooltipText:\i,tooltipPositionKey/,
|
||||
replace: "tooltipText:void 0,tooltipPositionKey"
|
||||
},
|
||||
predicate: () => settings.store.removeButtonTooltips
|
||||
},
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
replacement: {
|
||||
match: /avatarDecoration:(.{0,70}),size:/,
|
||||
replace: "avatarDecoration:void 0,size:"
|
||||
},
|
||||
predicate: () => settings.store.removeAvatarDecoration
|
||||
},
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
replacement: {
|
||||
match: /displayNameStyles:(\i),/,
|
||||
replace: "displayNameStyles:void 0,"
|
||||
},
|
||||
predicate: () => settings.store.removeUsernameStyles
|
||||
}
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ export default definePlugin({
|
|||
},
|
||||
// Member list items (server member list)
|
||||
{
|
||||
find: "MemberListItem",
|
||||
find: ".MEMBER_USER})]",
|
||||
replacement: {
|
||||
match: /(?<=useRawTargetDimensions.{0,250})onContextMenu:\i,(?=onMouseEnter:)/,
|
||||
replace: "$&draggable:!0,onDragStart:e=>$self.onUserDragStart(e,{id:arguments[0].user?.id}),\"data-dragify-user\":!0,\"data-user-id\":arguments[0].user?.id,"
|
||||
|
|
@ -234,7 +234,7 @@ export default definePlugin({
|
|||
{
|
||||
find: ']="BADGES"',
|
||||
replacement: {
|
||||
match: /(?<=textDecorationColor:.{0,300})(?=onClick:\i,onContextMenu:\i,)/,
|
||||
match: /(?="data-username-has-gradient")/,
|
||||
replace: "draggable:!0,onDragStart:e=>$self.onUserDragStart(e,arguments[0].author),\"data-dragify-user\":!0,\"data-user-id\":arguments[0].author.id,"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -98,15 +98,21 @@ const settings = definePluginSettings({
|
|||
accountStandingButton: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: t("equicordHelper.settings.accountStandingButton"),
|
||||
default: false,
|
||||
restartNeeded: true,
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
restoreFileDownloadButton: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Adds back the Download button at the top right corner of files",
|
||||
restartNeeded: true,
|
||||
default: false
|
||||
},
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "EquicordHelper",
|
||||
description: 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,
|
||||
settings,
|
||||
headerBarButton: {
|
||||
|
|
@ -165,6 +171,7 @@ export default definePlugin({
|
|||
replace: "true||$&"
|
||||
},
|
||||
},
|
||||
// Show your own activity buttons because discord removes them for who knows why
|
||||
{
|
||||
find: ".USER_PROFILE_ACTIVITY_BUTTONS),",
|
||||
predicate: () => settings.store.showYourOwnActivityButtons && !isPluginEnabled(customRPC.name),
|
||||
|
|
@ -201,6 +208,15 @@ export default definePlugin({
|
|||
replace: "1"
|
||||
}
|
||||
},
|
||||
// Restore File Download Button
|
||||
{
|
||||
predicate: () => settings.store.restoreFileDownloadButton,
|
||||
find: '"VISUAL_PLACEHOLDER":',
|
||||
replacement: {
|
||||
match: /(\.downloadUrl,showDownload:)\i/,
|
||||
replace: "$1!0"
|
||||
}
|
||||
},
|
||||
],
|
||||
renderMessageAccessory(props) {
|
||||
return (
|
||||
|
|
|
|||
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 { getIntlMessage } from "@utils/discord";
|
||||
import { t } from "@utils/translation";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Embed } from "@vencord/discord-types";
|
||||
import { findComponentByCodeLazy, findCssClassesLazy, proxyLazyWebpack } from "@webpack";
|
||||
import { proxyLazyWebpack } from "@webpack";
|
||||
import { React } from "@webpack/common";
|
||||
import { Component, ReactNode } from "react";
|
||||
import { ComponentType, ReactNode } from "react";
|
||||
|
||||
enum Format {
|
||||
NONE = 0,
|
||||
IMAGE = 1,
|
||||
VIDEO = 2
|
||||
}
|
||||
import { AttachmentAccessory, EmbedAccessory, FilePicker } from "./components";
|
||||
import { SignedUrlsStore } from "./stores";
|
||||
import managedStyle from "./style.css?managed";
|
||||
import { AttachmentItem, EmbedComponent, ExpressionPickerTabProps, ExpressionPickerView, FavouriteItem, FavouriteItemFormat } from "./types";
|
||||
import { getThumbnailUrl } from "./utils";
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
width: number;
|
||||
height: number;
|
||||
// Media URL
|
||||
src: string;
|
||||
// Provider source URL
|
||||
url: string;
|
||||
format: Format;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface AccessoryProps
|
||||
extends Pick<FavoriteButtonProps, "width" | "height" | "url"> {
|
||||
proxyUrl?: string;
|
||||
video?: boolean;
|
||||
}
|
||||
|
||||
interface EmbedComponent extends Component<{ embed: Embed; }> {
|
||||
__render: () => ReactNode;
|
||||
}
|
||||
|
||||
const FavoriteButton = findComponentByCodeLazy<FavoriteButtonProps>("#{intl::GIF_TOOLTIP_ADD_TO_FAVORITES}");
|
||||
const Classes = findCssClassesLazy("gifFavoriteButton", "ctaButtonContainer");
|
||||
const EmbedContext = proxyLazyWebpack(() => React.createContext<null | Embed>(null));
|
||||
export const EmbedContext = proxyLazyWebpack(() => React.createContext<null | Embed>(null));
|
||||
export const EmbedMosaicContext = proxyLazyWebpack(() => React.createContext<null | number>(null));
|
||||
export const AttachmentContext = proxyLazyWebpack(() => React.createContext<null | AttachmentItem>(null));
|
||||
|
||||
export default definePlugin({
|
||||
name: "FavouriteAnything",
|
||||
description: t("favouriteAnything.description"),
|
||||
authors: [Devs.nin0dev, EquicordDevs.davri],
|
||||
managedStyle,
|
||||
patches: [
|
||||
// EMBEDS
|
||||
{
|
||||
find: "static isAnimated",
|
||||
find: "#{intl::SUPPRESS_ALL_EMBEDS}",
|
||||
replacement: [
|
||||
// .isAnimated is checked in almost every media overlay event listener, so it's easier to patch the source.
|
||||
{
|
||||
match: /static isAnimated\((\i)\)\{/,
|
||||
replace:
|
||||
"static isAnimated($1,override){if(!override)return true;"
|
||||
// Wrap the embed component's render method in a custom context to avoid having to drill props
|
||||
match: "render()",
|
||||
replace: "$&{return $self.renderEmbed.call(this)}__render()"
|
||||
},
|
||||
// Always render the custom accessory if the prop wasn't provided. This mostly affects video attachments.
|
||||
// Url and proxyUrl are additionally set to the same value, since the original url property only stores the thumbnail.
|
||||
{
|
||||
match: /(?<=this\.props\.renderAccessory\(\):)null/,
|
||||
replace:
|
||||
"$self.Accessory({...this.props,url:this.props.proxyUrl,video:true})"
|
||||
},
|
||||
// Always return static thumbnails for non gif media to prevent graphical glitches (side effect of the first patch).
|
||||
{
|
||||
match: /getSrc\(\i\)\{.*?let \i=/,
|
||||
replace: "$&!this.constructor.isAnimated(this.props,true)||"
|
||||
},
|
||||
// Hide the default "GIF" tag accessory that is visible when discord is unfocused.
|
||||
{
|
||||
match: "return this.props.shouldRenderAccessory?",
|
||||
replace: "$&!this.constructor.isAnimated(this.props,true)||"
|
||||
// Specify the index for individual items in embed.images
|
||||
match: /\.images\.map\((\i)=>(this.renderImage\(\{[^}]{50,100}\}\))\)/,
|
||||
replace: ".images.map(($1,index)=>$self.renderEmbedMosaicItem($2,index))"
|
||||
}
|
||||
]
|
||||
},
|
||||
// Wrap the embed component with a custom context provider to avoid having to drill props.
|
||||
{
|
||||
find: "#{intl::SUPPRESS_ALL_EMBEDS}",
|
||||
// Override the default renderAdjacentContent prop value for all types of embed components (renderImageComponent, renderVideoComponent...)
|
||||
find: "#{intl::MEDIA_MOSAIC_ALT_TEXT_POPOUT_TITLE}",
|
||||
replacement: {
|
||||
match: "render()",
|
||||
replace: "$&{return $self.renderEmbed.call(this)}__render()"
|
||||
match: /renderAdjacentContent:(\i)/g,
|
||||
replace: "$&=$self.renderEmbedAccessory"
|
||||
}
|
||||
},
|
||||
// Replace the default gif accessory with a custom one that skips fileType checks. Mostly affects image attachments.
|
||||
// ATTACHMENTS
|
||||
{
|
||||
find: "renderComponentAccessories",
|
||||
find: '["VIDEO","CLIP","AUDIO"]',
|
||||
replacement: [
|
||||
{
|
||||
// Wrap the attachment component in a custom context to avoid having to drill props
|
||||
match: /(?<=children:)(\i)=>(\i\(\1\))\}\):(\i\(\))/,
|
||||
replace: "$1=>$self.renderAttachment($2,arguments[0])}):$self.renderAttachment($3,arguments[0])"
|
||||
},
|
||||
{
|
||||
// Always add our custom accessory to the attachment's adjacent content
|
||||
match: "=[];",
|
||||
replace: "=[$self.renderAttachmentAccessory()];"
|
||||
}
|
||||
]
|
||||
},
|
||||
// EXPRESSION PICKER
|
||||
{
|
||||
find: "#{intl::EXPRESSION_PICKER_CATEGORIES_A11Y_LABEL}",
|
||||
replacement: [
|
||||
{
|
||||
// Replace the "GIFs" tab with two custom tabs
|
||||
match: /\(0,\i\.jsx\)\((\i),[^}]{20,40}?"aria-selected":(\i)[^}]{50,100}?#{intl::EXPRESSION_PICKER_GIF}\)\}\)/,
|
||||
replace: "$self.renderTabs($1,$2)"
|
||||
},
|
||||
{
|
||||
// Insert the custom file picker into the expression picker's body
|
||||
match: /\{onSelectGIF:(\i),[^}]{20,40}\}\):null,(?=(\i)===)/,
|
||||
replace: "$&$self.renderFilePicker($2,$1),"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// Hide favourite files from the GIFs/Media tab
|
||||
find: '.sortBy("order").reverse().value()',
|
||||
replacement: {
|
||||
match: /\i=>\(\)=>\{.{200,300}?null\}/,
|
||||
replace: "props=>()=>$self.Accessory({...props,video:false})"
|
||||
match: '.sortBy("order").reverse()',
|
||||
replace: "$&.filter($self.filterGifs)"
|
||||
}
|
||||
},
|
||||
// Add a proxyUrl prop alongside the src prop, which is used for video thumbnails.
|
||||
// FAVOURITE BUTTON
|
||||
{
|
||||
find: /disableArrowKeySeek:\i\}\)\}/,
|
||||
find: "#{intl::GIF_TOOLTIP_REMOVE_FROM_FAVORITES}",
|
||||
replacement: {
|
||||
match: /src:\i(?=,\.\.\.\i)/,
|
||||
replace: "$&,proxyUrl:this.props.src"
|
||||
// Intercept the onClick callback to replace the placeholder thumbnail with a valid CDN link
|
||||
match: /\(0,(\i\.\i)\)\((\{[^}].{40,60}?\})\)/,
|
||||
replace: "$self.interceptAddToFavourites($2).then($1)"
|
||||
}
|
||||
}
|
||||
],
|
||||
renderEmbed(this: EmbedComponent) {
|
||||
renderTabs(Tab: ComponentType<ExpressionPickerTabProps>, activeView: ExpressionPickerView) {
|
||||
return (
|
||||
<EmbedContext.Provider value={this.props.embed}>
|
||||
{this.__render()}
|
||||
</EmbedContext.Provider>
|
||||
<>
|
||||
<Tab
|
||||
id="gif-picker-tab"
|
||||
key="gif-picker-tab"
|
||||
aria-controls="gif-picker-tab-panel"
|
||||
aria-selected={activeView === ExpressionPickerView.GIF}
|
||||
isActive={activeView === ExpressionPickerView.GIF}
|
||||
viewType={ExpressionPickerView.GIF}
|
||||
>
|
||||
Media
|
||||
</Tab>
|
||||
<Tab
|
||||
id="files-picker-tab"
|
||||
key="files-picker-tab"
|
||||
aria-controls="files-picker-tab-panel"
|
||||
aria-selected={activeView === ExpressionPickerView.FILES}
|
||||
isActive={activeView === ExpressionPickerView.FILES}
|
||||
viewType={ExpressionPickerView.FILES}
|
||||
>
|
||||
{getIntlMessage("FILES")}
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
},
|
||||
Accessory(props: AccessoryProps) {
|
||||
const embed = React.useContext(EmbedContext);
|
||||
const content = embed?.image ?? embed?.video;
|
||||
renderFilePicker(activeView: ExpressionPickerView, onSelectGIF: (item: { url: string; }) => void) {
|
||||
return activeView === ExpressionPickerView.FILES ? <FilePicker onSelectItem={onSelectGIF} /> : null;
|
||||
},
|
||||
renderAttachment(children: ReactNode, props: { item: AttachmentItem; }) {
|
||||
return <AttachmentContext.Provider value={props.item}>{children}</AttachmentContext.Provider>;
|
||||
},
|
||||
renderEmbed(this: EmbedComponent) {
|
||||
return <EmbedContext.Provider value={this.props.embed}>{this.__render()}</EmbedContext.Provider>;
|
||||
},
|
||||
renderEmbedMosaicItem(children: ReactNode, index: number) {
|
||||
return <EmbedMosaicContext.Provider value={index}>{children}</EmbedMosaicContext.Provider>;
|
||||
},
|
||||
renderAttachmentAccessory: () => <AttachmentAccessory />,
|
||||
renderEmbedAccessory: () => <EmbedAccessory />,
|
||||
filterGifs: (item: FavouriteItem) => item.format !== FavouriteItemFormat.NONE,
|
||||
interceptAddToFavourites: async (item: FavouriteItem & { url: string; }) => {
|
||||
if (item.format !== FavouriteItemFormat.NONE) return item;
|
||||
|
||||
const { url, proxyUrl, width, height, video } =
|
||||
embed && content
|
||||
? {
|
||||
...content,
|
||||
url: (embed.type === "gifv" && embed.url) || content.url,
|
||||
proxyUrl: content.proxyURL,
|
||||
video: !!embed.video
|
||||
}
|
||||
: props;
|
||||
SignedUrlsStore.addSigned(item.url);
|
||||
|
||||
if (!width || !height || !url) return null;
|
||||
if (URL.canParse(item.src)) {
|
||||
SignedUrlsStore.addSigned(item.src);
|
||||
return item;
|
||||
}
|
||||
|
||||
return (
|
||||
<FavoriteButton
|
||||
format={video ? Format.VIDEO : Format.IMAGE}
|
||||
className={Classes?.gifFavoriteButton}
|
||||
src={proxyUrl ?? url}
|
||||
url={url}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
const thumbnail = await getThumbnailUrl(item.src, item.width, item.height);
|
||||
if (!thumbnail) return item;
|
||||
|
||||
thumbnail.search = "";
|
||||
thumbnail.hash = item.src;
|
||||
return { ...item, src: `${thumbnail}` };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
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 (
|
||||
<>
|
||||
{settings.store.showIndicator && getGhostedChannels().length && (
|
||||
{settings.store.showIndicator && getGhostedChannels().length > 0 && (
|
||||
<div id={cl("container")}>
|
||||
<Tooltip text={getTooltipText()} position="right">
|
||||
{({ onMouseEnter, onMouseLeave }) => (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
background-color: var(--background-primary);
|
||||
height: 30px;
|
||||
width: 48px;
|
||||
margin-left: 8px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
color: var(--text-default);
|
||||
fill: var(--text-default);
|
||||
}
|
||||
|
|
@ -153,4 +154,4 @@
|
|||
|
||||
.vc-boo-modal-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { getFormat } from "./utils/getFormat";
|
||||
import { getGif } from "./utils/getGif";
|
||||
import { refreshGifUrl } from "./utils/refreshUrl";
|
||||
import { batchRefreshAttachmentUrls, isCdnUrlExpired } from "./utils/refreshUrl";
|
||||
import { downloadCollections, uploadGifCollections } from "./utils/settingsUtils";
|
||||
import { uuidv4 } from "./utils/uuidv4";
|
||||
|
||||
|
|
@ -386,11 +386,44 @@ export default definePlugin({
|
|||
width: g.width,
|
||||
height: g.height
|
||||
})).reverse();
|
||||
|
||||
const expiredGifs = collection.gifs.filter(g => g.src && g.url && (isCdnUrlExpired(g.src) || isCdnUrlExpired(g.url)));
|
||||
if (expiredGifs.length > 0) {
|
||||
const allUrls = [...new Set<string>(
|
||||
expiredGifs.flatMap(g => [g.src, g.url].filter((u): u is string => !!u && isCdnUrlExpired(u)))
|
||||
)];
|
||||
const refreshInBatches = async () => {
|
||||
const fullMap: Record<string, string> = {};
|
||||
for (let i = 0; i < allUrls.length; i += 50) {
|
||||
const batch = allUrls.slice(i, i + 50);
|
||||
const result = await batchRefreshAttachmentUrls(batch);
|
||||
Object.assign(fullMap, result);
|
||||
}
|
||||
return fullMap;
|
||||
};
|
||||
refreshInBatches().then(async refreshMap => {
|
||||
if (!Object.keys(refreshMap).length) return;
|
||||
let anyUpdated = false;
|
||||
for (const gif of expiredGifs) {
|
||||
const newSrc = refreshMap[gif.src] ?? gif.src;
|
||||
const newUrl = refreshMap[gif.url] ?? gif.url;
|
||||
if (newSrc !== gif.src || newUrl !== gif.url) {
|
||||
await updateGif(gif.id, { ...gif, src: newSrc, url: newUrl });
|
||||
anyUpdated = true;
|
||||
}
|
||||
}
|
||||
if (!anyUpdated) return;
|
||||
const { query } = instance.props;
|
||||
FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query: `${query} ` });
|
||||
await new Promise<void>(r => setTimeout(r, 2500));
|
||||
FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY", query });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
hidePrefix(name) {
|
||||
return name.split(":".length > 1) ? name.replace(/.+?:/, "") : name;
|
||||
return name.includes(":") ? name.replace(/.+?:/, "") : name;
|
||||
},
|
||||
insertCollections(instance) {
|
||||
const shouldRemoveAll = settings.store.onlyShowCollections;
|
||||
|
|
@ -548,40 +581,6 @@ const RemoveItemContextMenu = ({ type, nameOrId, instance }) => (
|
|||
}}
|
||||
/>
|
||||
<Menu.MenuSeparator />
|
||||
<Menu.MenuItem
|
||||
key="refresh-url"
|
||||
id="refresh-url"
|
||||
label="Refresh URL"
|
||||
action={async () => {
|
||||
const gifInfo = getGifById(nameOrId);
|
||||
if (!gifInfo) return;
|
||||
|
||||
if (!gifInfo.channelId || !gifInfo.messageId || !gifInfo.attachmentId) {
|
||||
showToast("Cannot refresh: GIF missing metadata (re-add to collection)", Toasts.Type.FAILURE);
|
||||
return;
|
||||
}
|
||||
|
||||
showToast("Refreshing URL...", Toasts.Type.MESSAGE);
|
||||
const refreshedGif = await refreshGifUrl(gifInfo);
|
||||
|
||||
if (refreshedGif) {
|
||||
await updateGif(nameOrId, refreshedGif);
|
||||
const collectionName = getItemCollectionNameFromId(nameOrId);
|
||||
FluxDispatcher.dispatch({
|
||||
type: "GIF_PICKER_QUERY",
|
||||
query: `${collectionName} `
|
||||
});
|
||||
FluxDispatcher.dispatch({
|
||||
type: "GIF_PICKER_QUERY",
|
||||
query: `${collectionName}`
|
||||
});
|
||||
showToast("URL refreshed successfully", Toasts.Type.SUCCESS);
|
||||
} else {
|
||||
showToast("Failed to refresh URL", Toasts.Type.FAILURE);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Menu.MenuSeparator />
|
||||
<Menu.MenuItem
|
||||
key="copy-url"
|
||||
id="copy-url"
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function getGifByMessageAndUrl(url: string, message: Message): Gif | null
|
|||
id: uuidv4(settings.store.itemPrefix),
|
||||
height: embed.image.height,
|
||||
width: embed.image.width,
|
||||
src: embed.image.proxyURL,
|
||||
src: embed.image.proxyURL!,
|
||||
url: embed.image.url,
|
||||
};
|
||||
// Tennor
|
||||
|
|
|
|||
|
|
@ -4,46 +4,31 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Gif } from "@equicordplugins/gifCollections/types";
|
||||
import { RestAPI } from "@webpack/common";
|
||||
|
||||
async function fetchMessageFromAPI(channelId: string, messageId: string): Promise<any> {
|
||||
export function isCdnUrlExpired(url: string): boolean {
|
||||
try {
|
||||
const response = await RestAPI.get({
|
||||
url: `/channels/${channelId}/messages`,
|
||||
query: {
|
||||
limit: 1,
|
||||
around: messageId
|
||||
}
|
||||
const ex = new URL(url).searchParams.get("ex");
|
||||
if (!ex) return false;
|
||||
return parseInt(ex, 16) * 1000 < Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function batchRefreshAttachmentUrls(urls: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
const response = await RestAPI.post({
|
||||
url: "/attachments/refresh-urls",
|
||||
body: { attachment_urls: urls }
|
||||
});
|
||||
|
||||
if (response.ok && response.body.length > 0) {
|
||||
return response.body.find((m: any) => m.id === messageId);
|
||||
if (!response.ok) return {};
|
||||
const map: Record<string, string> = {};
|
||||
for (const { original, refreshed } of response.body.refreshed_urls) {
|
||||
map[original] = refreshed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch message for GIF refresh:", error);
|
||||
return map;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function refreshGifUrl(gif: Gif): Promise<Gif | null> {
|
||||
if (!gif.channelId || !gif.messageId || !gif.attachmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = await fetchMessageFromAPI(gif.channelId, gif.messageId);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachment = message.attachments?.find((a: any) => a.id === gif.attachmentId);
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...gif,
|
||||
src: attachment.proxy_url,
|
||||
url: attachment.url
|
||||
};
|
||||
}
|
||||
|
|
|
|||
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 { FluxDispatcher, Menu, React, Tooltip, UserStore } from "@webpack/common";
|
||||
|
||||
interface CallUpdate {
|
||||
ringing: string[];
|
||||
ongoingRings: Record<number, string>;
|
||||
messageId: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
const args: CallUpdate = {
|
||||
ringing: [],
|
||||
ongoingRings: {},
|
||||
messageId: "",
|
||||
region: "",
|
||||
};
|
||||
|
||||
const ignoredChannelIds = new Set<string>();
|
||||
const cl = classNameFactory("vc-ignore-calls-");
|
||||
const Deafen = findComponentByCodeLazy("0-1.02-.1H3.05a9");
|
||||
|
|
@ -75,12 +89,6 @@ const settings = definePluginSettings({
|
|||
},
|
||||
});
|
||||
|
||||
const args = {
|
||||
ringing: [],
|
||||
messageId: "",
|
||||
region: "",
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "IgnoreCalls",
|
||||
description: t("ignoreCalls.description"),
|
||||
|
|
@ -100,8 +108,9 @@ export default definePlugin({
|
|||
"gdm-context": ContextMenuPatch,
|
||||
},
|
||||
flux: {
|
||||
async CALL_UPDATE({ ringing, messageId, region }) {
|
||||
args.ringing = ringing;
|
||||
async CALL_UPDATE({ ringing, ongoingRings, messageId, region }) {
|
||||
args.ringing = ringing || [];
|
||||
args.ongoingRings = ongoingRings || {};
|
||||
args.messageId = messageId;
|
||||
args.region = region;
|
||||
}
|
||||
|
|
@ -114,6 +123,7 @@ export default definePlugin({
|
|||
type: "CALL_UPDATE",
|
||||
channelId: channel.id,
|
||||
ringing: args.ringing.filter((id: string) => id !== currentUserId),
|
||||
ongoingRings: args.ongoingRings,
|
||||
messageId: args.messageId,
|
||||
region: args.region
|
||||
});
|
||||
|
|
@ -134,6 +144,7 @@ export default definePlugin({
|
|||
type: "CALL_UPDATE",
|
||||
channelId: channel.id,
|
||||
ringing: args.ringing.filter((id: string) => id !== currentUserId),
|
||||
ongoingRings: args.ongoingRings,
|
||||
messageId: args.messageId,
|
||||
region: args.region
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { isPluginEnabled } from "@api/PluginManager";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import betterActivities from "@equicordplugins/betterActivities";
|
||||
import showMeYourName from "@plugins/showMeYourName";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { classes } from "@utils/misc";
|
||||
import { 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 { MessageFlags } from "@vencord/discord-types/enums";
|
||||
import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findCssClassesLazy, findExportedComponentLazy } from "@webpack";
|
||||
import { ChannelStore, ExperimentStore, MessageStore, Parser, RelationshipStore, SnowflakeUtils, UserStore, useStateFromStores } from "@webpack/common";
|
||||
import { ChannelStore, ExperimentStore, MessageStore, Parser, RelationshipStore, SnowflakeUtils, UserGuildSettingsStore, UserStore, useStateFromStores } from "@webpack/common";
|
||||
|
||||
const cl = classNameFactory("vc-message-peek-");
|
||||
|
||||
|
|
@ -31,6 +32,14 @@ const ActivityText: React.ComponentType<ActivityTextProps> = findComponentByCode
|
|||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
const settings = definePluginSettings({
|
||||
hideMuted: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Hide message previews and timestamps for muted DMs and group chats.",
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
type AttachmentType = "image" | "gif" | "video" | "file";
|
||||
type IconType = AttachmentType | "voice" | "sticker";
|
||||
|
||||
|
|
@ -151,7 +160,7 @@ function MessagePreviewContent({ channel, user }: { channel: Channel; user: User
|
|||
return <>Official Discord Message</>;
|
||||
}
|
||||
|
||||
const smynName = isPluginEnabled(showMeYourName.name) ? showMeYourName.getMemberListProfilesReactionsVoiceNameText({ user: user ?? lastMessage?.author, type: "membersList" }) : null;
|
||||
const smynName = isPluginEnabled(showMeYourName.name) ? showMeYourName.getTypingMemberListProfilesReactionsVoiceNameText({ user: user ?? lastMessage?.author, type: "membersList" }) : null;
|
||||
|
||||
if (!lastMessage) {
|
||||
if (channel.isMultiUserDM()) return <>{channel.recipients.length + 1} Members</>;
|
||||
|
|
@ -216,7 +225,7 @@ function Timestamp({ channel }: { channel: Channel; }) {
|
|||
if (!lastMessage) return null;
|
||||
|
||||
const timestamp = SnowflakeUtils.extractTimestamp(lastMessage.id);
|
||||
const className = ExperimentStore.getUserExperimentBucket("2021-09_favorites_server") >= 1 ? cl("timestamp-favorites") : cl("timestamp");
|
||||
const className = ExperimentStore.getUserExperimentBucket("2026-01-favorites-server") >= 1 ? cl("timestamp-favorites") : cl("timestamp");
|
||||
return <span className={className}>{formatRelativeTime(timestamp)}</span>;
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +241,7 @@ export default definePlugin({
|
|||
name: "MessagePeek",
|
||||
description: t("messagePeek.description"),
|
||||
authors: [Devs.prism, EquicordDevs.justjxke],
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "PrivateChannel.renderAvatar",
|
||||
|
|
@ -253,11 +263,28 @@ export default definePlugin({
|
|||
|
||||
renderMemberListDecorator({ channel }: DecoratorProps) {
|
||||
if (!channel) return null;
|
||||
if (settings.store.hideMuted && UserGuildSettingsStore.isChannelMuted(null!, channel.id)) return null;
|
||||
return <Timestamp channel={channel} />;
|
||||
},
|
||||
|
||||
getSubText(props: PrivateChannelProps) {
|
||||
const { channel, user, activities, status, applicationStream, voiceChannel } = props;
|
||||
|
||||
if (settings.store.hideMuted && UserGuildSettingsStore.isChannelMuted(null!, channel.id)) {
|
||||
const hasActivity = hasRelevantActivity({ activities, status, applicationStream, voiceChannel });
|
||||
if (hasActivity) {
|
||||
return (
|
||||
<ActivityText
|
||||
user={user}
|
||||
activities={activities}
|
||||
voiceChannel={voiceChannel}
|
||||
applicationStream={applicationStream}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastMessage = MessageStore.getLastMessage(channel.id) as Message | undefined;
|
||||
const hasActivity = hasRelevantActivity({ activities, status, applicationStream, voiceChannel });
|
||||
const showActivity = shouldShowActivity(lastMessage, hasActivity);
|
||||
|
|
|
|||
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: [
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
find: ".WIDGETS_RTC_UPSELL_COACHMARK),",
|
||||
replacement: {
|
||||
// react.jsx)(AccountPanel, { ..., showTaglessAccountPanel: blah })
|
||||
match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,hidePrivateData:)/,
|
||||
match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,occluded:)/,
|
||||
// react.jsx(WrapperComponent, { VencordOriginal: AccountPanel, ...
|
||||
replace: "$self.PanelWrapper,{VencordOriginal:$1,"
|
||||
},
|
||||
|
|
|
|||
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"),
|
||||
value: "playstation",
|
||||
},
|
||||
{
|
||||
label: "VR",
|
||||
value: "vr",
|
||||
},
|
||||
]
|
||||
}
|
||||
});
|
||||
|
|
@ -49,7 +53,7 @@ const settings = definePluginSettings({
|
|||
export default definePlugin({
|
||||
name: "PlatformSpoofer",
|
||||
description: t("platformSpoofer.description"),
|
||||
authors: [EquicordDevs.Drag],
|
||||
authors: [EquicordDevs.Drag, EquicordDevs.neoarz],
|
||||
settingsAboutComponent: () => (
|
||||
<Notice.Warning>
|
||||
{t("platformSpoofer.warning")}
|
||||
|
|
@ -96,6 +100,8 @@ export default definePlugin({
|
|||
return { browser: "Discord Embedded", vcIcon: 2 };
|
||||
case "playstation":
|
||||
return { browser: "Discord Embedded", vcIcon: 3 };
|
||||
case "vr":
|
||||
return { browser: "Discord VR", vcIcon: 4 };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { isNonNullish } from "@utils/guards";
|
||||
import { classes } from "@utils/misc";
|
||||
import { ProfilePreset } from "@vencord/discord-types";
|
||||
import { ContextMenuApi, Menu, React, TextInput } from "@webpack/common";
|
||||
|
||||
import { cl } from "..";
|
||||
|
|
@ -37,6 +39,7 @@ export function PresetList({
|
|||
currentPage,
|
||||
onPageChange
|
||||
}: PresetListProps) {
|
||||
type EditableProfile = Omit<ProfilePreset, "name" | "timestamp">;
|
||||
const [renaming, setRenaming] = React.useState<number>(-1);
|
||||
const [renameText, setRenameText] = React.useState("");
|
||||
const isGuildProfile = section === "server";
|
||||
|
|
@ -149,11 +152,11 @@ export function PresetList({
|
|||
label="Update"
|
||||
action={async () => {
|
||||
const profile = await getCurrentProfile(guildId, { isGuildProfile });
|
||||
Object.entries(profile).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
updatePresetField(actualIndex, key as any, value, section, guildId);
|
||||
}
|
||||
});
|
||||
await Promise.all(
|
||||
(Object.entries(profile) as [keyof EditableProfile, EditableProfile[keyof EditableProfile]][])
|
||||
.filter(([, value]) => isNonNullish(value))
|
||||
.map(([key, value]) => updatePresetField(actualIndex, key, value, section, guildId))
|
||||
);
|
||||
onUpdate();
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export function PresetManager({ section, guildId }: PresetManagerProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const avatarSize = settings.store.avatarSize || 40;
|
||||
const { avatarSize } = settings.store;
|
||||
const hasPresets = presets.length > 0;
|
||||
const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ import { loadPresets, PresetSection } from "./utils/storage";
|
|||
export const cl = classNameFactory("vc-profile-presets-");
|
||||
export const settings = definePluginSettings({
|
||||
avatarSize: {
|
||||
type: OptionType.NUMBER,
|
||||
type: OptionType.SLIDER,
|
||||
description: 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)",
|
||||
replacement: {
|
||||
match: /onChange:\i\}\)(?=.{0,25}profilePreviewTitle:)/,
|
||||
replace: '$&,$self.renderPresetSection("server")'
|
||||
match: /guildId:(\i\.id),onChange:(\i)\}\)(?=.{0,25}profilePreviewTitle:)/,
|
||||
replace: 'guildId:$1,onChange:$2}),$self.renderPresetSection("server",$1)'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -4,24 +4,64 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { isNonNullish } from "@utils/guards";
|
||||
import { ProfilePreset } from "@vencord/discord-types";
|
||||
import { findStoreLazy } from "@webpack";
|
||||
import { showToast, Toasts } from "@webpack/common";
|
||||
|
||||
import { getCurrentProfile } from "./profile";
|
||||
import { addPreset, movePresetInArray, 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) {
|
||||
const profile = await getCurrentProfile(guildId, { isGuildProfile: section === "server" });
|
||||
const freshPendingAvatar = getFreshPendingAvatar(section, guildId);
|
||||
const effectiveAvatar = freshPendingAvatar ?? profile.avatarDataUrl ?? null;
|
||||
|
||||
const newPreset: ProfilePresetEx = {
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
...profile,
|
||||
avatarDataUrl: effectiveAvatar,
|
||||
};
|
||||
addPreset(newPreset);
|
||||
await savePresetsData(section);
|
||||
}
|
||||
|
||||
export async function updatePresetField(index: number, field: keyof Omit<ProfilePreset, "name" | "timestamp">, value: any, section: PresetSection, guildId?: string) {
|
||||
export async function updatePresetField<K extends keyof Omit<ProfilePreset, "name" | "timestamp">>(
|
||||
index: number,
|
||||
field: K,
|
||||
value: Omit<ProfilePreset, "name" | "timestamp">[K],
|
||||
section: PresetSection,
|
||||
guildId?: string
|
||||
) {
|
||||
if (index < 0 || index >= presets.length) return;
|
||||
void guildId;
|
||||
|
||||
const updatedPreset = {
|
||||
...presets[index],
|
||||
|
|
@ -55,17 +95,14 @@ export async function renamePreset(index: number, newName: string, section: Pres
|
|||
}
|
||||
|
||||
export function exportPresets(section: PresetSection) {
|
||||
try {
|
||||
const dataStr = JSON.stringify(presets, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `profile-presets-${section}-${Date.now()}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
}
|
||||
const dataStr = JSON.stringify(presets, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `profile-presets-${section}-${Date.now()}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export type ImportDecision = "override" | "merge" | "cancel";
|
||||
|
|
@ -108,6 +145,7 @@ export async function importPresets(
|
|||
await savePresetsData(section);
|
||||
forceUpdate();
|
||||
} catch {
|
||||
showToast("Failed to import presets. The file might be invalid.", Toasts.Type.FAILURE);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@
|
|||
import { getUserSettingLazy } from "@api/UserSettings";
|
||||
import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
|
||||
import { findStoreLazy } from "@webpack";
|
||||
import { FluxDispatcher, GuildMemberStore,IconUtils, UserProfileStore, UserStore } from "@webpack/common";
|
||||
import { FluxDispatcher, GuildMemberStore, IconUtils, UserProfileStore, UserStore } from "@webpack/common";
|
||||
|
||||
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
|
||||
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
|
||||
|
||||
type PendingChanges = Record<string, unknown> & {
|
||||
selectedAvatarRaw?: ImageInput;
|
||||
presetAvatarRaw?: ImageInput;
|
||||
selectedAvatarProcessed?: ImageInput;
|
||||
presetAvatarProcessed?: ImageInput;
|
||||
pendingAvatar?: ImageInput;
|
||||
avatar?: ImageInput;
|
||||
pendingBanner?: ImageInput;
|
||||
|
|
@ -30,34 +34,22 @@ type PendingChanges = Record<string, unknown> & {
|
|||
pendingPrimaryGuildId?: string | null;
|
||||
};
|
||||
|
||||
type UserExtras = {
|
||||
collectibles?: {
|
||||
nameplate?: Nameplate;
|
||||
};
|
||||
displayNameStyles?: DisplayNameStyles;
|
||||
avatarDecorationData?: {
|
||||
asset: string;
|
||||
skuId: string;
|
||||
} | null;
|
||||
primaryGuild?: {
|
||||
identityGuildId?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ProfileCollectible = {
|
||||
skuId: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
accessibilityLabel?: string;
|
||||
reducedMotionSrc?: string;
|
||||
thumbnailPreviewSrc?: string;
|
||||
effects?: ProfileEffect["effects"];
|
||||
animationType?: number;
|
||||
staticFrameSrc?: string;
|
||||
type?: number;
|
||||
};
|
||||
|
||||
type ImageInput = string | { imageUri: string; } | null | undefined;
|
||||
type DisplayNameStylesLike = DisplayNameStyles & {
|
||||
fontId?: number;
|
||||
effectId?: number;
|
||||
};
|
||||
|
||||
type CurrentProfileOptions = {
|
||||
isGuildProfile?: boolean;
|
||||
};
|
||||
|
||||
type LoadPresetOptions = {
|
||||
skipGlobalName?: boolean;
|
||||
skipBio?: boolean;
|
||||
skipPronouns?: boolean;
|
||||
isGuildProfile?: boolean;
|
||||
};
|
||||
|
||||
function dispatch(type: string, payload: Record<string, unknown>) {
|
||||
FluxDispatcher.dispatch({ type, ...payload });
|
||||
|
|
@ -70,7 +62,23 @@ function isNonEmptyString(value: unknown): value is string {
|
|||
function hasImageInput(value: ImageInput): boolean {
|
||||
if (!value) return false;
|
||||
if (typeof value === "string") return value.length > 0;
|
||||
return typeof value === "object" && "imageUri" in value && isNonEmptyString(value.imageUri);
|
||||
return typeof value === "object" && isNonEmptyString(value?.imageUri);
|
||||
}
|
||||
|
||||
function normalizeDisplayNameStyles(value: DisplayNameStylesLike | null | undefined): DisplayNameStylesLike | null {
|
||||
if (!value) return null;
|
||||
const fontId = value.fontId ?? value.font_id;
|
||||
const effectId = value.effectId ?? value.effect_id;
|
||||
if (typeof fontId !== "number" || typeof effectId !== "number") return null;
|
||||
const colors = Array.isArray(value.colors) ? [...value.colors] : [];
|
||||
|
||||
return {
|
||||
fontId,
|
||||
effectId,
|
||||
font_id: fontId,
|
||||
effect_id: effectId,
|
||||
colors
|
||||
};
|
||||
}
|
||||
|
||||
export async function imageUrlToBase64(url: string): Promise<string | null> {
|
||||
|
|
@ -91,7 +99,7 @@ export async function imageUrlToBase64(url: string): Promise<string | null> {
|
|||
async function processImage(imageData: ImageInput, userId: string, type: "avatar" | "banner", guildId?: string, useGuildPath?: boolean): Promise<string | null> {
|
||||
if (!imageData) return null;
|
||||
|
||||
if (typeof imageData === "object" && "imageUri" in imageData && isNonEmptyString(imageData.imageUri)) {
|
||||
if (typeof imageData === "object" && isNonEmptyString(imageData?.imageUri)) {
|
||||
return imageData.imageUri;
|
||||
}
|
||||
|
||||
|
|
@ -117,17 +125,6 @@ async function processImage(imageData: ImageInput, userId: string, type: "avatar
|
|||
return null;
|
||||
}
|
||||
|
||||
type CurrentProfileOptions = {
|
||||
isGuildProfile?: boolean;
|
||||
};
|
||||
|
||||
type LoadPresetOptions = {
|
||||
skipGlobalName?: boolean;
|
||||
skipBio?: boolean;
|
||||
skipPronouns?: boolean;
|
||||
isGuildProfile?: boolean;
|
||||
};
|
||||
|
||||
export async function getCurrentProfile(guildId?: string, options: CurrentProfileOptions = {}): Promise<Omit<ProfilePreset, "name" | "timestamp">> {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
|
||||
|
|
@ -135,19 +132,17 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
|
|||
const effectiveGuildId = isGuildProfile ? guildId : undefined;
|
||||
const guildProfile = effectiveGuildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, effectiveGuildId) : null;
|
||||
const userProfile = guildProfile ?? baseProfile;
|
||||
const userAny = currentUser as UserExtras;
|
||||
const userAny = currentUser;
|
||||
const guildMember = effectiveGuildId ? GuildMemberStore.getMember(effectiveGuildId, currentUser.id) : null;
|
||||
|
||||
const pendingChangesDefault = (UserProfileSettingsStore.getPendingChanges() ?? {}) as PendingChanges;
|
||||
const pendingChangesForGuild = (effectiveGuildId
|
||||
? (UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {})
|
||||
: {}) as PendingChanges;
|
||||
const pendingChangesDefault: PendingChanges = UserProfileSettingsStore.getPendingChanges() ?? {};
|
||||
const pendingChangesForGuild: PendingChanges = UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {};
|
||||
const pendingChanges: PendingChanges = isGuildProfile && Object.keys(pendingChangesForGuild).length > 0
|
||||
? pendingChangesForGuild
|
||||
: pendingChangesDefault;
|
||||
|
||||
const customStatusSetting = CustomStatusSettings.getSetting();
|
||||
const customStatus: CustomStatus | null = isGuildProfile
|
||||
const customStatus = isGuildProfile
|
||||
? null
|
||||
: {
|
||||
text: customStatusSetting?.text ?? "",
|
||||
|
|
@ -180,7 +175,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
|
|||
type: effectToUse.type || 1
|
||||
};
|
||||
} else if (effectToUse.skuId) {
|
||||
const collectibles = (userProfile as { collectibles?: ProfileCollectible[]; } | null)?.collectibles;
|
||||
const collectibles = userProfile?.collectibles;
|
||||
const collectible = collectibles?.find(c => c?.skuId === effectToUse.skuId);
|
||||
if (collectible) {
|
||||
profileEffect = {
|
||||
|
|
@ -208,12 +203,11 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
|
|||
type: nameplateToUse.type || 2
|
||||
} : null;
|
||||
|
||||
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? userAny.displayNameStyles;
|
||||
const displayNameStyles = displayNameStylesToUse ? {
|
||||
font_id: displayNameStylesToUse.font_id ?? 0,
|
||||
effect_id: displayNameStylesToUse.effect_id ?? 0,
|
||||
colors: Array.isArray(displayNameStylesToUse.colors) ? displayNameStylesToUse.colors : []
|
||||
} : null;
|
||||
const savedDisplayNameStyles = isGuildProfile
|
||||
? (guildMember?.displayNameStyles ?? userAny.displayNameStyles)
|
||||
: userAny.displayNameStyles;
|
||||
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? savedDisplayNameStyles;
|
||||
const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse);
|
||||
|
||||
const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar;
|
||||
const avatarToUse: ImageInput = hasImageInput(pendingAvatar)
|
||||
|
|
@ -245,7 +239,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
|
|||
globalName: isGuildProfile
|
||||
? (pendingChanges.pendingNickname ?? guildMember?.nick ?? null)
|
||||
: (pendingChanges.pendingGlobalName ?? currentUser.globalName ?? null),
|
||||
pronouns: pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null,
|
||||
pronouns: isGuildProfile ? null : (pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null),
|
||||
avatarDecoration,
|
||||
profileEffect,
|
||||
nameplate,
|
||||
|
|
@ -263,98 +257,156 @@ function jsonEq(a: unknown, b: unknown): boolean {
|
|||
}
|
||||
|
||||
function customStatusEq(a: CustomStatus | null | undefined, b: CustomStatus | null | undefined): boolean {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
return a.text === b.text
|
||||
&& String(a.emojiId ?? "") === String(b.emojiId ?? "")
|
||||
&& a.emojiName === b.emojiName
|
||||
&& String(a.expiresAtMs ?? "0") === String(b.expiresAtMs ?? "0");
|
||||
}
|
||||
|
||||
function resolvePendingAvatar(pendingChanges: PendingChanges | null): ImageInput {
|
||||
if (!pendingChanges) return null;
|
||||
|
||||
const candidates: ImageInput[] = [
|
||||
pendingChanges.selectedAvatarRaw,
|
||||
pendingChanges.presetAvatarRaw,
|
||||
pendingChanges.pendingAvatar,
|
||||
pendingChanges.avatar,
|
||||
pendingChanges.selectedAvatarProcessed,
|
||||
pendingChanges.presetAvatarProcessed
|
||||
];
|
||||
return candidates.find(hasImageInput) ?? null;
|
||||
}
|
||||
|
||||
function normalizeImageValue(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object" && "imageUri" in value) {
|
||||
const { imageUri } = value as { imageUri: unknown; };
|
||||
return typeof imageUri === "string" ? imageUri : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectibleEqBySku(a: { skuId?: string | number | null; } | null | undefined, b: { skuId?: string | number | null; } | null | undefined): boolean {
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
return String(a.skuId ?? "") === String(b.skuId ?? "");
|
||||
}
|
||||
|
||||
function avatarDecorationEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean {
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? "");
|
||||
}
|
||||
|
||||
function nameplateEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean {
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? "");
|
||||
}
|
||||
|
||||
export async function loadPresetAsPending(preset: ProfilePreset, guildId?: string, options: LoadPresetOptions = {}) {
|
||||
try {
|
||||
const isGuild = options.isGuildProfile ?? Boolean(guildId);
|
||||
if (isGuild && !guildId) return;
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
|
||||
const guildProfile = isGuild && guildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, guildId) : null;
|
||||
const equippedProfile = guildProfile ?? baseProfile;
|
||||
const current = await getCurrentProfile(guildId, {
|
||||
isGuildProfile: isGuild
|
||||
});
|
||||
const profilePrefix = "USER_SETTINGS_ACCOUNT";
|
||||
const dispatchProfile = (type: string, payload: Record<string, unknown>) => {
|
||||
dispatch(type, isGuild ? { ...payload, guildId } : payload);
|
||||
const pendingChanges = (isGuild && guildId
|
||||
? UserProfileSettingsStore.getPendingChanges(guildId)
|
||||
: UserProfileSettingsStore.getPendingChanges());
|
||||
const equippedAvatarDecoration = currentUser.avatarDecorationData ?? null;
|
||||
const equippedProfileEffect = equippedProfile?.profileEffect ?? null;
|
||||
const equippedNameplate = currentUser.collectibles?.nameplate ?? null;
|
||||
const pendingAvatarDecoration = pendingChanges?.pendingAvatarDecoration ?? null;
|
||||
const pendingProfileEffect = pendingChanges?.pendingProfileEffect ?? null;
|
||||
const pendingNameplate = pendingChanges?.pendingNameplate ?? null;
|
||||
const setPending = (field: string, payload: Record<string, unknown>) => {
|
||||
const cleanPayload = Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== undefined));
|
||||
if (!Object.keys(cleanPayload).length) return;
|
||||
dispatch(`USER_PROFILE_SETTINGS_SET_PENDING_${field}`, isGuild ? { ...cleanPayload, guildId } : cleanPayload);
|
||||
};
|
||||
const setPending = (type: string, payload: Record<string, unknown>) => dispatchProfile(type, payload);
|
||||
|
||||
if ("avatarDataUrl" in preset) {
|
||||
const avatarValue = preset.avatarDataUrl;
|
||||
if (isGuild || avatarValue !== current.avatarDataUrl) {
|
||||
setPending(`${profilePrefix}_SET_PENDING_AVATAR`, { avatar: avatarValue });
|
||||
const presetAvatar = normalizeImageValue(avatarValue);
|
||||
const currentAvatar = normalizeImageValue(current.avatarDataUrl);
|
||||
const pendingAvatar = normalizeImageValue(resolvePendingAvatar(pendingChanges));
|
||||
if (isGuild || (presetAvatar !== currentAvatar && presetAvatar !== pendingAvatar)) {
|
||||
const avatarPayload =
|
||||
typeof avatarValue === "string" && avatarValue.startsWith("data:")
|
||||
? {
|
||||
assetOrigin: "NEW_ASSET",
|
||||
imageUri: avatarValue,
|
||||
description: `profilesets-${preset.name ?? "preset"}`
|
||||
}
|
||||
: avatarValue;
|
||||
setPending("AVATAR", { avatar: avatarPayload });
|
||||
}
|
||||
}
|
||||
|
||||
if ("bannerDataUrl" in preset && preset.bannerDataUrl !== current.bannerDataUrl) {
|
||||
setPending(`${profilePrefix}_SET_PENDING_BANNER`, { banner: preset.bannerDataUrl });
|
||||
setPending("BANNER", { banner: preset.bannerDataUrl });
|
||||
}
|
||||
|
||||
if (!options.skipBio && "bio" in preset && preset.bio !== current.bio) {
|
||||
if (!options.skipBio && preset?.bio !== current?.bio) {
|
||||
const bioValue = preset.bio === null ? "" : preset.bio;
|
||||
setPending(`${profilePrefix}_SET_PENDING_BIO`, { bio: bioValue });
|
||||
setPending("BIO", { bio: bioValue });
|
||||
}
|
||||
|
||||
if (!options.skipPronouns && "pronouns" in preset && preset.pronouns !== current.pronouns) {
|
||||
if (!isGuild && !options.skipPronouns && preset?.pronouns !== current?.pronouns) {
|
||||
const pronounsValue = preset.pronouns === null ? "" : preset.pronouns;
|
||||
setPending(`${profilePrefix}_SET_PENDING_PRONOUNS`, { pronouns: pronounsValue });
|
||||
setPending("PRONOUNS", { pronouns: pronounsValue });
|
||||
}
|
||||
|
||||
if (!options.skipGlobalName && preset.globalName !== undefined && preset.globalName !== current.globalName) {
|
||||
if (!options.skipGlobalName && preset?.globalName !== current?.globalName) {
|
||||
if (isGuild) {
|
||||
setPending("USER_SETTINGS_ACCOUNT_SET_PENDING_NICKNAME", { nickname: preset.globalName });
|
||||
setPending("NICKNAME", { nickname: preset.globalName });
|
||||
} else {
|
||||
setPending("USER_SETTINGS_ACCOUNT_SET_PENDING_GLOBAL_NAME", { globalName: preset.globalName });
|
||||
setPending("GLOBAL_NAME", { globalName: preset.globalName });
|
||||
}
|
||||
}
|
||||
|
||||
if (preset.avatarDecoration !== undefined && !jsonEq(preset.avatarDecoration, current.avatarDecoration)) {
|
||||
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, {
|
||||
if (preset.avatarDecoration && ![pendingAvatarDecoration, equippedAvatarDecoration, current.avatarDecoration].some(d => avatarDecorationEq(preset.avatarDecoration, d))) {
|
||||
setPending("COLLECTIBLES_ITEM", {
|
||||
item: { type: 0, value: preset.avatarDecoration }
|
||||
});
|
||||
}
|
||||
|
||||
if (preset.profileEffect !== undefined && !jsonEq(preset.profileEffect, current.profileEffect)) {
|
||||
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, {
|
||||
if (preset.profileEffect && ![pendingProfileEffect, equippedProfileEffect, current.profileEffect].some(d => collectibleEqBySku(preset.profileEffect, d))) {
|
||||
setPending("COLLECTIBLES_ITEM", {
|
||||
item: { type: 1, value: preset.profileEffect }
|
||||
});
|
||||
}
|
||||
|
||||
if (preset.nameplate !== undefined && !jsonEq(preset.nameplate, current.nameplate)) {
|
||||
dispatchProfile(`${profilePrefix}_SET_PENDING_COLLECTIBLES_ITEM`, {
|
||||
if (preset.nameplate && ![pendingNameplate, equippedNameplate, current.nameplate].some(d => nameplateEq(preset.nameplate, d))) {
|
||||
setPending("COLLECTIBLES_ITEM", {
|
||||
item: { type: 2, value: preset.nameplate }
|
||||
});
|
||||
}
|
||||
|
||||
if (preset.displayNameStyles !== undefined && !jsonEq(preset.displayNameStyles, current.displayNameStyles)) {
|
||||
setPending(`${profilePrefix}_SET_PENDING_DISPLAY_NAME_STYLES`, { displayNameStyles: preset.displayNameStyles });
|
||||
if (preset.displayNameStyles) {
|
||||
const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles);
|
||||
if (!jsonEq(presetDisplayNameStyles, current.displayNameStyles)) {
|
||||
setPending("DISPLAY_NAME_STYLES", { displayNameStyles: presetDisplayNameStyles });
|
||||
}
|
||||
}
|
||||
|
||||
if (preset.themeColors !== undefined && !jsonEq(preset.themeColors, current.themeColors)) {
|
||||
setPending(`${profilePrefix}_SET_PENDING_THEME_COLORS`, { themeColors: preset.themeColors });
|
||||
if (preset.themeColors && !jsonEq(preset.themeColors, current.themeColors)) {
|
||||
setPending("THEME_COLORS", { themeColors: preset.themeColors });
|
||||
}
|
||||
|
||||
if (preset.primaryGuildId !== undefined && !isGuild && preset.primaryGuildId !== current.primaryGuildId) {
|
||||
if (preset.primaryGuildId && !isGuild && preset.primaryGuildId !== current.primaryGuildId) {
|
||||
dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId });
|
||||
}
|
||||
|
||||
if (preset.customStatus !== undefined && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) {
|
||||
const customStatusValue = preset.customStatus ?? {
|
||||
text: "",
|
||||
expiresAtMs: "0",
|
||||
emojiId: "0",
|
||||
emojiName: ""
|
||||
};
|
||||
if (preset.customStatus && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) {
|
||||
CustomStatusSettings.updateSetting({
|
||||
text: customStatusValue.text || "",
|
||||
expiresAtMs: customStatusValue.expiresAtMs || "0",
|
||||
emojiId: customStatusValue.emojiId || "0",
|
||||
emojiName: customStatusValue.emojiName || ""
|
||||
text: preset.customStatus?.text ?? "",
|
||||
expiresAtMs: preset.customStatus?.expiresAtMs ?? "0",
|
||||
emojiId: preset.customStatus?.emojiId ?? "0",
|
||||
emojiName: preset.customStatus?.emojiName ?? ""
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
if (!quest) { return null; }
|
||||
|
||||
const task = getQuestTask(quest);
|
||||
|
||||
if (!task) { return null; }
|
||||
|
|
@ -1295,8 +1297,8 @@ export default definePlugin({
|
|||
// Fixes the progress tracking for auto-completing Quests.
|
||||
find: ",{progressTextAnimation:",
|
||||
replacement: {
|
||||
match: /(?<=children:\i}=)(\i)/,
|
||||
replace: "Object.assign({},$1,$1.quest?$self.getQuestPanelPercentComplete($1):{})"
|
||||
match: /(let{percentComplete:.{0,115}?children:\i}=)(\i)/,
|
||||
replace: "const questifyProgress=$self.getQuestPanelPercentComplete({...$2,quest:$2.children?.props?.quest});$1Object.assign({},$2,questifyProgress??{})"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -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
|
||||
find: '"sm",preClickCallback:',
|
||||
find: '"primary",preClickCallback:',
|
||||
replacement: [
|
||||
{
|
||||
match: /(?=let{quest:)/,
|
||||
|
|
@ -1625,11 +1619,19 @@ export default definePlugin({
|
|||
replace: "!$self.processQuestForAutoComplete(arguments[0].quest)&&($1)"
|
||||
},
|
||||
{
|
||||
match: /(?<=,text:)(?=\i)/,
|
||||
replace: "questifyText??"
|
||||
match: /(?<=,text:)(\i),icon:\i/,
|
||||
replace: "questifyText??$1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// Sets intervals to progress Video Quests in the background.
|
||||
find: "CAPTCHA_FAILED:",
|
||||
replacement: {
|
||||
match: /(?<=SUCCESS:)(\i\({)/,
|
||||
replace: "!$self.processQuestForAutoComplete(arguments[0])&&$1"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Sets intervals to progress Play Game Quests in the background.
|
||||
// Triggers if a Quest has already been started but was interrupted, such as by a reload.
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, Mod
|
|||
import { t } from "@utils/translation";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { Button, Menu, TextInput, UploadHandler, useEffect, useState } from "@webpack/common";
|
||||
import { Button, IconUtils, Menu, TextInput, UploadHandler, useEffect, useState } from "@webpack/common";
|
||||
|
||||
import { QuoteIcon } from "./components/QuoteIcon";
|
||||
import { QuoteFont } from "./types";
|
||||
import { createQuoteImage, ensureFontLoaded, generateFileNamePreview, getFileExtension, getMimeType, resetFontLoading, sizeUpgrade } from "./utils";
|
||||
import { createQuoteImage, ensureFontLoaded, generateFileNamePreview, getFileExtension, getMimeType, resetFontLoading } from "./utils";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
quoteFont: {
|
||||
|
|
@ -106,7 +106,7 @@ function QuoteModal({ message, ...props }: ModalProps & { message: Message; }) {
|
|||
|
||||
const generateImage = async () => {
|
||||
const image = await createQuoteImage({
|
||||
avatarUrl: sizeUpgrade(message.author.getAvatarURL()),
|
||||
avatarUrl: IconUtils.getUserAvatarURL(message.author, true, 512),
|
||||
quote: message.content,
|
||||
grayScale: gray,
|
||||
author: message.author,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,20 @@
|
|||
*/
|
||||
|
||||
import { User } from "@vencord/discord-types";
|
||||
import { UserStore } from "@webpack/common";
|
||||
import { IconUtils, UserStore } from "@webpack/common";
|
||||
import { applyPalette, GIFEncoder, quantize } from "gifenc";
|
||||
|
||||
import { CANVAS_CONFIG, CanvasConfig, FONT_SIZES, FontSizeCalculation, QuoteFont, QuoteImageOptions, SPACING } from "./types";
|
||||
|
||||
export function sizeUpgrade(url: string): string {
|
||||
const u = new URL(url);
|
||||
u.searchParams.set("size", "512");
|
||||
return u.toString();
|
||||
const CUSTOM_EMOJI_REGEX = /<a?:(\w+):(\d+)>/g;
|
||||
const CUSTOM_EMOJI_PLACEHOLDER = "\uFFFC";
|
||||
const CUSTOM_EMOJI_SIZE_MULTIPLIER = 1.15;
|
||||
const CUSTOM_EMOJI_BASELINE_OFFSET = 0.85;
|
||||
|
||||
interface CustomEmojiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
|
|
@ -32,7 +37,7 @@ export async function fetchImageAsBlob(url: string): Promise<Blob> {
|
|||
}
|
||||
|
||||
export function fixUpQuote(quote: string): string {
|
||||
let result = quote.replace(/<a?:(\w+):(\d+)>/g, "");
|
||||
let result = quote;
|
||||
const mentionMatches = result.match(/<@!?\d+>/g);
|
||||
if (mentionMatches) {
|
||||
mentionMatches.forEach(match => {
|
||||
|
|
@ -46,7 +51,8 @@ export function fixUpQuote(quote: string): string {
|
|||
|
||||
export function generateFileNamePreview(message: string): string {
|
||||
const words = message.split(" ");
|
||||
return words.length > 6 ? words.slice(0, 6).join(" ") : words.join(" ");
|
||||
const preview = words.length > 6 ? words.slice(0, 6).join(" ") : words.join(" ");
|
||||
return preview.slice(0, 10);
|
||||
}
|
||||
|
||||
export function getFileExtension(saveAsGif: boolean): string {
|
||||
|
|
@ -140,6 +146,89 @@ function drawGradientOverlay(ctx: CanvasRenderingContext2D, config: CanvasConfig
|
|||
ctx.fillRect(config.height - SPACING.gradientWidth, 0, SPACING.gradientWidth, config.height);
|
||||
}
|
||||
|
||||
function extractCustomEmojis(text: string): { text: string; emojis: CustomEmojiToken[]; } {
|
||||
const emojis: CustomEmojiToken[] = [];
|
||||
const cleanText = text.replace(CUSTOM_EMOJI_REGEX, (match, name: string, id: string) => {
|
||||
emojis.push({
|
||||
id,
|
||||
name,
|
||||
animated: match.startsWith("<a:")
|
||||
});
|
||||
return CUSTOM_EMOJI_PLACEHOLDER;
|
||||
});
|
||||
|
||||
return { text: cleanText, emojis };
|
||||
}
|
||||
|
||||
async function loadImageFromBlob(blob: Blob): Promise<HTMLImageElement> {
|
||||
const image = new Image();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => reject(new Error("Failed to load image"));
|
||||
image.src = blobUrl;
|
||||
});
|
||||
return image;
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomEmojiImage(emoji: CustomEmojiToken): Promise<HTMLImageElement | null> {
|
||||
const animatedVariants = emoji.animated ? [true, false] : [false];
|
||||
for (const animated of animatedVariants) {
|
||||
try {
|
||||
const blob = await fetchImageAsBlob(IconUtils.getEmojiURL({
|
||||
id: emoji.id,
|
||||
animated,
|
||||
size: 96
|
||||
}));
|
||||
return await loadImageFromBlob(blob);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadCustomEmojiImages(emojis: CustomEmojiToken[]): Promise<Map<string, HTMLImageElement>> {
|
||||
if (!emojis.length) return new Map<string, HTMLImageElement>();
|
||||
|
||||
const unique = new Map<string, CustomEmojiToken>();
|
||||
emojis.forEach(emoji => {
|
||||
const key = `${emoji.id}:${emoji.animated ? "a" : "s"}`;
|
||||
if (!unique.has(key)) unique.set(key, emoji);
|
||||
});
|
||||
|
||||
const entries = await Promise.all(
|
||||
Array.from(unique.entries()).map(async ([key, emoji]) => [key, await loadCustomEmojiImage(emoji)] as const)
|
||||
);
|
||||
|
||||
return entries.reduce((acc, [key, image]) => {
|
||||
if (image) acc.set(key, image);
|
||||
return acc;
|
||||
}, new Map<string, HTMLImageElement>());
|
||||
}
|
||||
|
||||
function measureTextWithCustomEmojis(ctx: CanvasRenderingContext2D, text: string, fontSize: number): number {
|
||||
const emojiSize = fontSize * CUSTOM_EMOJI_SIZE_MULTIPLIER;
|
||||
let width = 0;
|
||||
let start = 0;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== CUSTOM_EMOJI_PLACEHOLDER) continue;
|
||||
width += ctx.measureText(text.slice(start, i)).width;
|
||||
width += emojiSize;
|
||||
start = i + 1;
|
||||
}
|
||||
|
||||
width += ctx.measureText(text.slice(start)).width;
|
||||
return width;
|
||||
}
|
||||
|
||||
function calculateTextLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
|
|
@ -153,12 +242,31 @@ function calculateTextLines(
|
|||
let currentLine: string[] = [];
|
||||
|
||||
words.forEach(word => {
|
||||
const testLine = [...currentLine, word].join(" ");
|
||||
if (ctx.measureText(testLine).width > maxWidth && currentLine.length) {
|
||||
lines.push(currentLine.join(" "));
|
||||
currentLine = [word];
|
||||
if (measureTextWithCustomEmojis(ctx, word, fontSize) > maxWidth) {
|
||||
if (currentLine.length) {
|
||||
lines.push(currentLine.join(" "));
|
||||
currentLine = [];
|
||||
}
|
||||
|
||||
let chunk = "";
|
||||
for (const char of word) {
|
||||
const testChunk = chunk + char;
|
||||
if (measureTextWithCustomEmojis(ctx, testChunk, fontSize) > maxWidth) {
|
||||
if (chunk) lines.push(chunk);
|
||||
chunk = char;
|
||||
} else {
|
||||
chunk = testChunk;
|
||||
}
|
||||
}
|
||||
if (chunk) lines.push(chunk);
|
||||
} else {
|
||||
currentLine.push(word);
|
||||
const testLine = [...currentLine, word].join(" ");
|
||||
if (measureTextWithCustomEmojis(ctx, testLine, fontSize) > maxWidth && currentLine.length) {
|
||||
lines.push(currentLine.join(" "));
|
||||
currentLine = [word];
|
||||
} else {
|
||||
currentLine.push(word);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -204,17 +312,52 @@ function drawQuoteText(
|
|||
ctx: CanvasRenderingContext2D,
|
||||
calculation: FontSizeCalculation,
|
||||
font: QuoteFont,
|
||||
config: CanvasConfig
|
||||
config: CanvasConfig,
|
||||
emojis: CustomEmojiToken[],
|
||||
emojiImages: Map<string, HTMLImageElement>
|
||||
): number {
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = `300 ${calculation.fontSize}px '${font}', sans-serif`;
|
||||
const emojiSize = calculation.fontSize * CUSTOM_EMOJI_SIZE_MULTIPLIER;
|
||||
|
||||
let quoteY = (config.height - calculation.totalHeight) / 2;
|
||||
let emojiIndex = 0;
|
||||
|
||||
calculation.lines.forEach(line => {
|
||||
const xOffset = (config.quoteAreaWidth - ctx.measureText(line).width) / 2;
|
||||
const xOffset = (config.quoteAreaWidth - measureTextWithCustomEmojis(ctx, line, calculation.fontSize)) / 2;
|
||||
let x = config.quoteAreaX + xOffset;
|
||||
let segmentStart = 0;
|
||||
quoteY += calculation.lineHeight;
|
||||
ctx.fillText(line, config.quoteAreaX + xOffset, quoteY);
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] !== CUSTOM_EMOJI_PLACEHOLDER) continue;
|
||||
|
||||
const segment = line.slice(segmentStart, i);
|
||||
if (segment) {
|
||||
ctx.fillText(segment, x, quoteY);
|
||||
x += ctx.measureText(segment).width;
|
||||
}
|
||||
|
||||
const emoji = emojis[emojiIndex++];
|
||||
if (emoji) {
|
||||
const key = `${emoji.id}:${emoji.animated ? "a" : "s"}`;
|
||||
const image = emojiImages.get(key);
|
||||
if (image) {
|
||||
const y = quoteY - emojiSize * CUSTOM_EMOJI_BASELINE_OFFSET;
|
||||
ctx.drawImage(image, x, y, emojiSize, emojiSize);
|
||||
} else {
|
||||
ctx.fillText("□", x, quoteY);
|
||||
}
|
||||
}
|
||||
|
||||
x += emojiSize;
|
||||
segmentStart = i + 1;
|
||||
}
|
||||
|
||||
const tail = line.slice(segmentStart);
|
||||
if (tail) {
|
||||
ctx.fillText(tail, x, quoteY);
|
||||
}
|
||||
});
|
||||
|
||||
return quoteY;
|
||||
|
|
@ -263,6 +406,8 @@ export async function createQuoteImage(options: QuoteImageOptions): Promise<Blob
|
|||
await ensureFontLoaded();
|
||||
|
||||
const quote = fixUpQuote(rawQuote);
|
||||
const { text: quoteText, emojis } = extractCustomEmojis(quote);
|
||||
const emojiImages = await loadCustomEmojiImages(emojis);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to get 2D rendering context");
|
||||
|
|
@ -282,8 +427,8 @@ export async function createQuoteImage(options: QuoteImageOptions): Promise<Blob
|
|||
|
||||
drawGradientOverlay(ctx, CANVAS_CONFIG);
|
||||
|
||||
const calculation = calculateOptimalFontSize(ctx, quote, quoteFont, CANVAS_CONFIG);
|
||||
const quoteEndY = drawQuoteText(ctx, calculation, quoteFont, CANVAS_CONFIG);
|
||||
const calculation = calculateOptimalFontSize(ctx, quoteText, quoteFont, CANVAS_CONFIG);
|
||||
const quoteEndY = drawQuoteText(ctx, calculation, quoteFont, CANVAS_CONFIG, emojis, emojiImages);
|
||||
drawAuthorInfo(ctx, author, calculation, CANVAS_CONFIG, quoteEndY);
|
||||
|
||||
if (showWatermark && watermark) {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import { EquicordDevs } from "@utils/constants";
|
|||
import { closeModal, openModal } from "@utils/modal";
|
||||
import { t } from "@utils/translation";
|
||||
import definePlugin from "@utils/types";
|
||||
import { extractAndLoadChunksLazy, findStoreLazy } from "@webpack";
|
||||
import { ChannelStore, DraftType, FluxDispatcher, Menu, SelectedChannelStore, UploadHandler } from "@webpack/common";
|
||||
import { extractAndLoadChunksLazy } from "@webpack";
|
||||
import { ChannelStore, DraftType, FluxDispatcher, Menu, PendingReplyStore, SelectedChannelStore, UploadHandler } from "@webpack/common";
|
||||
|
||||
import RemixModal from "./RemixModal";
|
||||
import css from "./styles.css?managed";
|
||||
|
|
@ -20,7 +20,6 @@ import css from "./styles.css?managed";
|
|||
const requireCreateStickerModal = extractAndLoadChunksLazy([".CREATE_STICKER_MODAL,", "isDisplayingIndividualStickers"]);
|
||||
const requireSettingsMenu = extractAndLoadChunksLazy(['type:"USER_SETTINGS_MODAL_OPEN"']);
|
||||
|
||||
const PendingReplyStore = findStoreLazy("PendingReplyStore");
|
||||
const validMediaTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
||||
|
||||
const UploadContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export default definePlugin({
|
|||
managedStyle,
|
||||
patches: [
|
||||
{
|
||||
find: '"CustomStatusModalWithPreview"',
|
||||
find: '="custom-status-input";',
|
||||
replacement: {
|
||||
match: /(?<=(\i)\?\.state\?\?.*?)\{text:\i\.\i\.\i\(\i\.\i#{intl::SAVE}\)/,
|
||||
replace: "$self.renderRememberButton($1),$&"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export default definePlugin({
|
|||
|
||||
start() {
|
||||
SettingsPlugin.customEntries.push({
|
||||
key: "theme_library",
|
||||
key: "equicord_theme_library",
|
||||
title: "Theme Library",
|
||||
Component: require("./components/ThemeTab").default,
|
||||
Icon: ColorPaletteIcon
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export async function authorizeUser(triggerModal: boolean = true) {
|
|||
responseType="code"
|
||||
redirectUri="https://themes.equicord.org/api/user/auth"
|
||||
permissions={0n}
|
||||
clientId="1257819493422465235"
|
||||
clientId="1464006702125940736"
|
||||
cancelCompletesFlow={false}
|
||||
callback={async ({ location }: any) => {
|
||||
if (!location) return logger.error("No redirect location returned");
|
||||
|
|
|
|||
|
|
@ -25,11 +25,6 @@ const settings = definePluginSettings({
|
|||
description: "Custom tone indicators (format: jk=Joking; srs=Serious)",
|
||||
default: "",
|
||||
},
|
||||
maxIndicators: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "Maximum number of tone indicators to show per message",
|
||||
default: 5,
|
||||
},
|
||||
});
|
||||
|
||||
function getCustomIndicators(): Record<string, string> {
|
||||
|
|
@ -92,15 +87,17 @@ function buildIndicatorRegex(): RegExp {
|
|||
function splitTextWithIndicators(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let count = 0;
|
||||
const regex = buildIndicatorRegex();
|
||||
const prefix = settings.store.prefix || "/";
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) && count < settings.store.maxIndicators) {
|
||||
while ((match = regex.exec(text))) {
|
||||
const indicator = match[1];
|
||||
const desc = getIndicator(indicator);
|
||||
|
||||
const fullMatch = match[0];
|
||||
const leadingWhitespace = fullMatch.match(/^(\s*)/)?.[1] ?? "";
|
||||
|
||||
const matchStart = match.index;
|
||||
const matchEnd = regex.lastIndex;
|
||||
|
||||
|
|
@ -109,6 +106,7 @@ function splitTextWithIndicators(text: string): ReactNode[] {
|
|||
}
|
||||
|
||||
if (desc) {
|
||||
if (leadingWhitespace) nodes.push(leadingWhitespace);
|
||||
nodes.push(
|
||||
<ToneIndicator
|
||||
key={`ti-${matchStart}`}
|
||||
|
|
@ -117,7 +115,6 @@ function splitTextWithIndicators(text: string): ReactNode[] {
|
|||
desc={desc}
|
||||
/>,
|
||||
);
|
||||
count++;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!user) return null;
|
||||
const positionClass = settings.store.buttonPosition === "right"
|
||||
? "voice-user-buttons-right"
|
||||
: "voice-user-buttons-left";
|
||||
|
||||
return (
|
||||
<Flex flexDirection="row" className="voice-user-buttons">
|
||||
<Flex flexDirection="row" className={`voice-user-buttons ${positionClass}`}>
|
||||
{settings.store.showChatButton && <UserChatButton user={user} />}
|
||||
{settings.store.showMuteButton && <UserMuteButton user={user} />}
|
||||
{settings.store.showDeafenButton && <UserDeafenButton user={user} />}
|
||||
|
|
|
|||
|
|
@ -30,46 +30,47 @@ export const settings = definePluginSettings({
|
|||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Toggles their soundboard upon clicking deafen button.",
|
||||
restartNeeded: false,
|
||||
},
|
||||
disableVideo: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Toggles their video upon clicking deafen button.",
|
||||
restartNeeded: false,
|
||||
},
|
||||
useServer: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Use server mute/deafen instead of local when you have permission.",
|
||||
default: false,
|
||||
restartNeeded: false,
|
||||
},
|
||||
serverSelf: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Deafen / Mute yourself on the server when using mute/deafen.",
|
||||
default: false,
|
||||
restartNeeded: false,
|
||||
},
|
||||
showButtonsSelf: {
|
||||
type: OptionType.SELECT,
|
||||
default: "display",
|
||||
description: "Whether you want to display buttons for your own user. Same functionality as other button(s) except it'll open DMs panel and mute/deafen for yourself upon clicking button(s).",
|
||||
restartNeeded: true,
|
||||
options: [
|
||||
{ label: "Display", value: "display" },
|
||||
{ label: "Display", value: "display", default: true },
|
||||
{ label: "Hide", value: "hide" },
|
||||
{ label: "Disable", value: "disable" },
|
||||
],
|
||||
},
|
||||
whichNameToShow: {
|
||||
type: OptionType.SELECT,
|
||||
default: "both",
|
||||
description: "Choose whether to show nickname or username in tooltip.",
|
||||
restartNeeded: false,
|
||||
options: [
|
||||
{ label: "Both", value: "both", default: true },
|
||||
{ label: "Global Name", value: "global" },
|
||||
{ label: "Default Username", value: "username" },
|
||||
{ label: "Both", value: "both" },
|
||||
{ label: "Username", value: "username" },
|
||||
],
|
||||
},
|
||||
buttonPosition: {
|
||||
type: OptionType.SELECT,
|
||||
description: "Choose where to place voice buttons in the voice user row.",
|
||||
options: [
|
||||
{ label: "Left", value: "left", default: true },
|
||||
{ label: "Right", value: "right" },
|
||||
],
|
||||
}
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@
|
|||
flex: none !important;
|
||||
}
|
||||
|
||||
.voice-user-buttons-left {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.voice-user-buttons-right {
|
||||
order: 1;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.voice-user-button-container {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
div[class*='list'][class*='collapsed'] .voice-user-buttons {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { insertTextIntoChatInputBox } from "@utils/discord";
|
||||
import { t } from "@utils/translation";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/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 {
|
||||
channel: Channel;
|
||||
}
|
||||
|
|
@ -70,6 +82,13 @@ const VoiceChannelContext: NavContextMenuPatchCallback = (children, { channel }:
|
|||
key="voice-tools"
|
||||
id="voice-tools"
|
||||
>
|
||||
<Menu.MenuItem
|
||||
key="voice-tools-mention-all"
|
||||
id="voice-tools-mention-all"
|
||||
label="Mention all Users"
|
||||
action={() => mentionVoiceUsers(channel)}
|
||||
/>
|
||||
|
||||
<Menu.MenuItem
|
||||
key="voice-tools-disconnect-all"
|
||||
id="voice-tools-disconnect-all"
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ const settings = definePluginSettings({
|
|||
description: "Comma or space separated list of friends' user IDs you want to recieve join messages from",
|
||||
default: ""
|
||||
},
|
||||
ignoredFriends: {
|
||||
type: OptionType.STRING,
|
||||
description: "Comma or space separated list of friends' user IDs you do NOT want to recieve join messages from",
|
||||
default: ""
|
||||
},
|
||||
ignoreBlockedUsers: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Do not send messages about blocked users joining/leaving/moving voice channels",
|
||||
|
|
@ -90,9 +95,11 @@ function sendVoiceStatusMessage(channelId: string, content: string, userId: stri
|
|||
|
||||
function isFriendAllowlisted(friendId: string) {
|
||||
if (!RelationshipStore.isFriend(friendId)) return false;
|
||||
const list = settings.store.allowedFriends.split(",").join(" ").split(" ").filter(i => i.length > 0);
|
||||
if (list.join(" ").length < 1) return true;
|
||||
return list.includes(friendId);
|
||||
const ignoreList = settings.store.ignoredFriends.split(",").join(" ").split(" ").filter(i => i.length > 0);
|
||||
if (ignoreList.includes(friendId)) return false;
|
||||
const allowList = settings.store.allowedFriends.split(",").join(" ").split(" ").filter(i => i.length > 0);
|
||||
if (allowList.join(" ").length < 1) return true;
|
||||
return allowList.includes(friendId);
|
||||
}
|
||||
|
||||
// Blatantly stolen from VcNarrator plugin
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { t } from "@utils/translation";
|
|||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import type { Channel } from "@vencord/discord-types";
|
||||
import { ChannelType } from "@vencord/discord-types/enums";
|
||||
import { ChannelActions, ChannelStore, Menu, VoiceStateStore } from "@webpack/common";
|
||||
import { ChannelActions, ChannelStore, Menu, PermissionsBits, PermissionStore, VoiceStateStore } from "@webpack/common";
|
||||
|
||||
let waitingChannelId: string | null = null;
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ const settings = definePluginSettings({
|
|||
|
||||
const ChannelContext: NavContextMenuPatchCallback = (children, { channel }) => {
|
||||
if (channel?.type !== ChannelType.GUILD_VOICE || !channel.userLimit) return;
|
||||
if (PermissionStore.can(PermissionsBits.MOVE_MEMBERS, channel)) return;
|
||||
if (Object.keys(VoiceStateStore.getVoiceStatesForChannel(channel.id)).length < channel.userLimit) return;
|
||||
|
||||
const isWaiting = waitingChannelId === channel.id;
|
||||
|
|
@ -49,6 +50,7 @@ const ChannelContext: NavContextMenuPatchCallback = (children, { channel }) => {
|
|||
|
||||
function promptVoiceChannel(channel: Channel | null | undefined): boolean {
|
||||
if (!channel || channel.type !== ChannelType.GUILD_VOICE || !channel.userLimit) return false;
|
||||
if (PermissionStore.can(PermissionsBits.MOVE_MEMBERS, channel)) return false;
|
||||
if (Object.keys(VoiceStateStore.getVoiceStatesForChannel(channel.id)).length < channel.userLimit) return false;
|
||||
if (waitingChannelId === channel.id) return true;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import "./settings";
|
|||
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { IpcEvents } from "@shared/IpcEvents";
|
||||
import { BrowserWindow, ipcMain, nativeTheme, shell, systemPreferences } from "electron";
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, shell, systemPreferences } from "electron";
|
||||
import monacoHtml from "file://monacoWin.html?minify&base64";
|
||||
import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs";
|
||||
import { open, readdir, readFile, unlink } from "fs/promises";
|
||||
|
|
@ -139,16 +139,17 @@ ipcMain.on(IpcEvents.GET_MONACO_THEME, e => {
|
|||
e.returnValue = nativeTheme.shouldUseDarkColors ? "vs-dark" : "vs-light";
|
||||
});
|
||||
|
||||
let monacoWin: BrowserWindow | null = null;
|
||||
|
||||
ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
|
||||
const title = "Equicord QuickCSS Editor";
|
||||
const existingWindow = BrowserWindow.getAllWindows().find(w => w.title === title);
|
||||
if (existingWindow && !existingWindow.isDestroyed()) {
|
||||
existingWindow.focus();
|
||||
if (monacoWin && !monacoWin.isDestroyed()) {
|
||||
monacoWin.show();
|
||||
monacoWin.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title,
|
||||
monacoWin = new BrowserWindow({
|
||||
title: "Equicord QuickCSS Editor",
|
||||
autoHideMenuBar: true,
|
||||
darkTheme: true,
|
||||
webPreferences: {
|
||||
|
|
@ -159,9 +160,28 @@ ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
|
|||
}
|
||||
});
|
||||
|
||||
makeLinksOpenExternally(win);
|
||||
monacoWin.once("closed", () => { monacoWin = null; });
|
||||
|
||||
await win.loadURL(`data:text/html;base64,${monacoHtml}`);
|
||||
makeLinksOpenExternally(monacoWin);
|
||||
|
||||
await monacoWin.loadURL(`data:text/html;base64,${monacoHtml}`);
|
||||
});
|
||||
|
||||
app.on("before-quit", async event => {
|
||||
if (monacoWin && !monacoWin.isDestroyed() && !monacoWin.isVisible()) {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: "question",
|
||||
buttons: ["Cancel", "Close Anyway"],
|
||||
defaultId: 0,
|
||||
title: "QuickCSS Editor Open",
|
||||
message: "QuickCSS editor is still open in the background.",
|
||||
detail: "Do you want to close Discord anyway? This will also close the QuickCSS editor."
|
||||
});
|
||||
|
||||
if (result.response === 1) {
|
||||
app.exit();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(IpcEvents.GET_RENDERER_CSS, () => readFile(RENDERER_CSS_PATH, "utf-8"));
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ async function loadBadges(url: string, noCache = false) {
|
|||
|
||||
async function loadAllBadges(noCache = false) {
|
||||
const vencordBadges = await loadBadges("https://badges.vencord.dev/badges.json", noCache);
|
||||
const equicordBadges = await loadBadges("https://equicord.org/badges.json", noCache);
|
||||
const equicordBadges = await loadBadges("https://badge.equicord.org/badges.json", noCache);
|
||||
|
||||
DonorBadges = vencordBadges;
|
||||
EquicordDonorBadges = equicordBadges;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default definePlugin({
|
|||
{
|
||||
find: "#{intl::USER_PROFILE_PRONOUNS}",
|
||||
replacement: {
|
||||
match: /(?<=isVerifiedBot\(\)}\).{0,60})children:(\i)/,
|
||||
match: /(?<=shouldUnderlineOnHover:null.{0,150})children:(\i)/,
|
||||
replace: "children:[...Vencord.Api.NicknameIcons._renderIcons({userId:arguments[0].user?.id}),$1]"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export default definePlugin({
|
|||
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
find: ".WIDGETS_RTC_UPSELL_COACHMARK)",
|
||||
group: true,
|
||||
replacement: [
|
||||
{
|
||||
|
|
@ -88,7 +88,7 @@ export default definePlugin({
|
|||
replace: "$&$self.onPopoutClose();"
|
||||
},
|
||||
{
|
||||
match: /(?:#{intl::5fWB8U::raw}|#{intl::SET_STATUS})\)(?<=ref:(\i),style:.+?)/,
|
||||
match: /ref:(\i),style:\i(?=.{0,250}#{intl::USER_PROFILE_ACCOUNT_POPOUT_BUTTON_A11Y_LABEL})/,
|
||||
replace: "$&,onContextMenu:($self.grabRef($1),$self.openAccountPanelContextMenu)"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ import { definePluginSettings } from "@api/Settings";
|
|||
import { Devs } from "@utils/constants";
|
||||
import { t } from "@utils/translation";
|
||||
import { getIntlMessage } from "@utils/discord";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findByPropsLazy, findStoreLazy } from "@webpack";
|
||||
import { FluxDispatcher } from "@webpack/common";
|
||||
import type { GuildFolder } from "@vencord/discord-types";
|
||||
import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
|
||||
import { ChannelStore, FluxDispatcher, ReadStateStore } from "@webpack/common";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import FolderSideBar from "./FolderSideBar";
|
||||
|
|
@ -36,15 +36,42 @@ enum FolderIconDisplay {
|
|||
MoreThanOneFolderExpanded
|
||||
}
|
||||
|
||||
type GuildTreeNode = {
|
||||
id: string | number;
|
||||
type?: string;
|
||||
parentId?: string | number | null;
|
||||
name?: string;
|
||||
children?: GuildTreeNode[];
|
||||
isBetterFoldersNested?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type FolderRenderProps = {
|
||||
isBetterFolders?: boolean;
|
||||
folderNode?: GuildTreeNode;
|
||||
};
|
||||
|
||||
type FolderDragItem = {
|
||||
type?: string;
|
||||
nodeId: string | number;
|
||||
};
|
||||
|
||||
type FolderMentionProps = {
|
||||
mentionCount?: number;
|
||||
folderNode?: GuildTreeNode;
|
||||
};
|
||||
|
||||
export const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore");
|
||||
export const SortedGuildStore = findStoreLazy("SortedGuildStore");
|
||||
const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");
|
||||
const FolderItem = findComponentByCodeLazy("FolderItem", "onExpandCollapse", "folderButtonSize");
|
||||
|
||||
const MAX_TREE_FILTER_DEPTH = 1000;
|
||||
let lastGuildId = null as string | null;
|
||||
let dispatchingFoldersClose = false;
|
||||
|
||||
function getGuildFolder(id: string) {
|
||||
return SortedGuildStore.getGuildFolders().find(folder => folder.guildIds.includes(id));
|
||||
return SortedGuildStore.getGuildFolders().find((folder: GuildFolder) => folder.guildIds.includes(id));
|
||||
}
|
||||
|
||||
function closeFolders() {
|
||||
|
|
@ -53,22 +80,31 @@ function closeFolders() {
|
|||
}
|
||||
|
||||
// Nuckyz: Unsure if this should be a general utility or not
|
||||
function filterTreeWithTargetNode(children: any, predicate: (node: any) => boolean) {
|
||||
function filterTreeWithTargetNode(children: any, predicate: (node: any) => boolean, visited = new WeakSet<object>(), depth = 0) {
|
||||
if (children == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (depth > MAX_TREE_FILTER_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof children === "object") {
|
||||
if (visited.has(children)) return false;
|
||||
visited.add(children);
|
||||
}
|
||||
|
||||
if (!Array.isArray(children)) {
|
||||
if (predicate(children)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return filterTreeWithTargetNode(children.props?.children, predicate);
|
||||
return filterTreeWithTargetNode(children.props?.children, predicate, visited, depth + 1);
|
||||
}
|
||||
|
||||
let childIsTargetChild = false;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const shouldKeep = filterTreeWithTargetNode(children[i], predicate);
|
||||
const shouldKeep = filterTreeWithTargetNode(children[i], predicate, visited, depth + 1);
|
||||
if (shouldKeep) {
|
||||
childIsTargetChild = true;
|
||||
continue;
|
||||
|
|
@ -80,6 +116,75 @@ function filterTreeWithTargetNode(children: any, predicate: (node: any) => boole
|
|||
return childIsTargetChild;
|
||||
}
|
||||
|
||||
function getNestedFolderMap(): Record<string, string> {
|
||||
return settings.store.nestedFolders ?? {};
|
||||
}
|
||||
|
||||
function saveNestedFolderMap(map: Record<string, string>) {
|
||||
settings.store.nestedFolders = map;
|
||||
}
|
||||
|
||||
export function getParentFolderId(childId: string | number): string | undefined {
|
||||
return getNestedFolderMap()[childId.toString()];
|
||||
}
|
||||
|
||||
export function getChildFolderIds(parentId: string | number): string[] {
|
||||
const map = getNestedFolderMap();
|
||||
return Object.entries(map)
|
||||
.filter(([, pid]) => pid === String(parentId))
|
||||
.map(([cid]) => cid);
|
||||
}
|
||||
|
||||
function getDescendantFolderIds(parentId: string | number): string[] {
|
||||
const descendants: string[] = [];
|
||||
const queue = [...getChildFolderIds(parentId)];
|
||||
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
if (!current) continue;
|
||||
descendants.push(current);
|
||||
queue.push(...getChildFolderIds(current));
|
||||
}
|
||||
|
||||
return descendants;
|
||||
}
|
||||
|
||||
function nestFolder(childId: string, parentId: string) {
|
||||
if (childId === parentId) return;
|
||||
if (hasParentInChain(parentId, childId)) return;
|
||||
|
||||
const map = { ...getNestedFolderMap() };
|
||||
|
||||
delete map[childId];
|
||||
|
||||
map[childId] = parentId;
|
||||
saveNestedFolderMap(map);
|
||||
}
|
||||
|
||||
function unnestFolder(childId: string) {
|
||||
const map = { ...getNestedFolderMap() };
|
||||
if (map[childId] == null) return;
|
||||
delete map[childId];
|
||||
saveNestedFolderMap(map);
|
||||
}
|
||||
|
||||
function hasParentInChain(childId: string, parentId: string): boolean {
|
||||
const seen = new Set<string>();
|
||||
let current = getParentFolderId(childId);
|
||||
|
||||
while (current != null && !seen.has(current)) {
|
||||
if (current === parentId) return true;
|
||||
seen.add(current);
|
||||
current = getParentFolderId(current);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function areNestedRelated(firstId: string, secondId: string): boolean {
|
||||
return hasParentInChain(firstId, secondId) || hasParentInChain(secondId, firstId);
|
||||
}
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
sidebar: {
|
||||
type: OptionType.BOOLEAN,
|
||||
|
|
@ -133,8 +238,10 @@ export const settings = definePluginSettings({
|
|||
{ label: t("betterFolders.settings.folderIconMultipleExpanded"), value: FolderIconDisplay.MoreThanOneFolderExpanded }
|
||||
],
|
||||
restartNeeded: true
|
||||
}
|
||||
});
|
||||
},
|
||||
}).withPrivateSettings<{
|
||||
nestedFolders: Record<string, string>;
|
||||
}>();
|
||||
|
||||
const IS_BETTER_FOLDERS_VAR = "typeof isBetterFolders!=='undefined'?isBetterFolders:arguments[0]?.isBetterFolders";
|
||||
const BETTER_FOLDERS_EXPANDED_IDS_VAR = "typeof betterFoldersExpandedIds!=='undefined'?betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds";
|
||||
|
|
@ -147,6 +254,9 @@ export default definePlugin({
|
|||
isModified: true,
|
||||
|
||||
settings,
|
||||
start() {
|
||||
settings.store.nestedFolders ??= {};
|
||||
},
|
||||
|
||||
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
|
||||
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:",
|
||||
predicate: () => settings.store.sidebar,
|
||||
|
|
@ -264,7 +399,7 @@ export default definePlugin({
|
|||
replace: (m, conditions, props) => `${m},${conditions}$self.FolderSideBar(${props})`
|
||||
},
|
||||
{
|
||||
// Add grid styles to fix aligment with other visual refresh elements
|
||||
// Add grid styles to fix alignment with other visual refresh elements
|
||||
match: /(?<=className:)\i\.\i(?=,"data-fullscreen")/,
|
||||
replace: `"${GRID_STYLE_NAME} "+$&`
|
||||
}
|
||||
|
|
@ -278,6 +413,35 @@ export default definePlugin({
|
|||
match: /(?<=onClick:\(\)=>{)(?=.{0,300}"discodo")/,
|
||||
replace: "$self.closeFolders();"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "[GuildDropTarget]",
|
||||
all: true,
|
||||
replacement: [
|
||||
{
|
||||
match: /(\i)=!(\i)&&null==(\i)\.parentId/,
|
||||
replace: "$1=$self.shouldShowCombineTarget($2,$3)"
|
||||
},
|
||||
{
|
||||
match: /canDrop:\i=>\i\.nodeId.{0,100}==\i\.parentId\)/,
|
||||
replace: "canDrop:e=>$self.canDropOnFolder(e,arguments[1],arguments[3])"
|
||||
},
|
||||
{
|
||||
match: /drop\(\i\)\{(?=.{0,25}!==\i\.\i\.FOLDER)/,
|
||||
replace: "$&if($self.handleFolderDrop(arguments[0],arguments[1],arguments[2],arguments[3]))return;"
|
||||
},
|
||||
{
|
||||
match: /\[\i\.\i\.GUILD\](?=.{0,250}#{intl::DND_DROP_COMBINE})/,
|
||||
replace: "[...$self.getFolderAcceptTypes(arguments[0]?.targetNode)]"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: ".hasFetchedRequestToJoinGuilds)",
|
||||
replacement: {
|
||||
match: /return \i\.type!==\i\.\i\.GUILD/,
|
||||
replace: "return $self.renderFolderChild(arguments[0],arguments[1],arguments[2])"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
|
|
@ -309,10 +473,14 @@ export default definePlugin({
|
|||
|
||||
FluxDispatcher.wait(() => {
|
||||
const expandedFolders = ExpandedGuildFolderStore.getExpandedFolders();
|
||||
const expandedId = data.folderId?.toString();
|
||||
|
||||
if (expandedFolders.size > 1) {
|
||||
for (const id of expandedFolders) if (id !== data.folderId)
|
||||
for (const id of expandedFolders) {
|
||||
const folderId = id?.toString();
|
||||
if (folderId === expandedId || areNestedRelated(folderId, expandedId)) continue;
|
||||
FolderUtils.toggleGuildFolderExpand(id);
|
||||
}
|
||||
}
|
||||
|
||||
dispatchingFoldersClose = false;
|
||||
|
|
@ -327,12 +495,219 @@ export default definePlugin({
|
|||
|
||||
FolderSideBar,
|
||||
closeFolders,
|
||||
getGuildMentionCount(guildId: string): number {
|
||||
const mentionChannelIds = ReadStateStore.getMentionChannelIds() ?? [];
|
||||
let count = 0;
|
||||
|
||||
for (const channelId of mentionChannelIds) {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (channel?.guild_id !== guildId) continue;
|
||||
count += ReadStateStore.getMentionCount(channelId);
|
||||
}
|
||||
|
||||
return count;
|
||||
},
|
||||
getFolderMentionMetaWithNested(props: FolderMentionProps | undefined): { mentionCount: number, hasNestedMention: boolean; } {
|
||||
const folderNode = props?.folderNode;
|
||||
const baseMentionCount = props?.mentionCount ?? 0;
|
||||
if (folderNode?.id == null) {
|
||||
return {
|
||||
mentionCount: baseMentionCount,
|
||||
hasNestedMention: false
|
||||
};
|
||||
}
|
||||
|
||||
const descendantIds = getDescendantFolderIds(folderNode.id);
|
||||
if (descendantIds.length === 0) {
|
||||
return {
|
||||
mentionCount: baseMentionCount,
|
||||
hasNestedMention: false
|
||||
};
|
||||
}
|
||||
|
||||
const tree = SortedGuildStore.getGuildsTree();
|
||||
if (typeof tree?.getNode !== "function") {
|
||||
return {
|
||||
mentionCount: baseMentionCount,
|
||||
hasNestedMention: false
|
||||
};
|
||||
}
|
||||
|
||||
const nestedGuildIds = new Set<string>();
|
||||
for (const childFolderId of descendantIds) {
|
||||
const stack = [...(tree.getNode(childFolderId)?.children ?? [])];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (!node) continue;
|
||||
if (node.type === "guild") {
|
||||
nestedGuildIds.add(node.id?.toString());
|
||||
continue;
|
||||
}
|
||||
if (node.type === "folder" && Array.isArray(node.children)) {
|
||||
stack.push(...node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nestedGuildIds.size === 0) {
|
||||
return {
|
||||
mentionCount: baseMentionCount,
|
||||
hasNestedMention: false
|
||||
};
|
||||
}
|
||||
|
||||
let nestedMentionCount = 0;
|
||||
for (const guildId of nestedGuildIds) nestedMentionCount += this.getGuildMentionCount(guildId);
|
||||
return {
|
||||
mentionCount: baseMentionCount + nestedMentionCount,
|
||||
hasNestedMention: nestedMentionCount > 0
|
||||
};
|
||||
},
|
||||
getFolderMentionCountWithNested(props: FolderMentionProps | undefined): number {
|
||||
return this.getFolderMentionMetaWithNested(props).mentionCount;
|
||||
},
|
||||
getFolderIsMentionLowImportanceWithNested(props: FolderMentionProps | undefined, originalFlag: boolean): boolean {
|
||||
return this.getFolderMentionMetaWithNested(props).hasNestedMention ? false : originalFlag;
|
||||
},
|
||||
augmentFolderChildren(folderNode: GuildTreeNode, originalChildren: GuildTreeNode[]): GuildTreeNode[] {
|
||||
const childIds = getChildFolderIds(folderNode.id);
|
||||
if (childIds.length === 0) return originalChildren;
|
||||
|
||||
try {
|
||||
const tree = SortedGuildStore.getGuildsTree();
|
||||
if (typeof tree?.getNode !== "function") {
|
||||
return originalChildren;
|
||||
}
|
||||
const existingIds = new Set(originalChildren.map(child => child?.id?.toString()));
|
||||
const childFolderNodes: GuildTreeNode[] = [];
|
||||
|
||||
for (const id of childIds) {
|
||||
if (existingIds.has(id?.toString())) continue;
|
||||
const node = tree.getNode(id);
|
||||
if (node) {
|
||||
childFolderNodes.push({
|
||||
...node,
|
||||
parentId: folderNode.id,
|
||||
isBetterFoldersNested: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (childFolderNodes.length === 0) return originalChildren;
|
||||
|
||||
return [...childFolderNodes, ...originalChildren];
|
||||
} catch {
|
||||
return originalChildren;
|
||||
}
|
||||
},
|
||||
|
||||
getFolderNodeForRender(folderNode: GuildTreeNode): GuildTreeNode {
|
||||
const children = this.augmentFolderChildren(folderNode, folderNode.children ?? []);
|
||||
if (children === folderNode.children) return folderNode;
|
||||
return {
|
||||
...folderNode,
|
||||
children
|
||||
};
|
||||
},
|
||||
|
||||
getFolderGuildsListClassName(
|
||||
props: FolderRenderProps | undefined,
|
||||
baseClassName: string
|
||||
): string {
|
||||
if (props?.isBetterFolders) return baseClassName;
|
||||
|
||||
const folderNode = props?.folderNode;
|
||||
if (folderNode == null) return baseClassName;
|
||||
|
||||
const children = this.getFolderNodeForRender(folderNode)?.children;
|
||||
if (!Array.isArray(children)) return baseClassName;
|
||||
|
||||
return children.some(child => child?.type === "folder")
|
||||
? `${baseClassName} vc-betterFolders-nested-parent-list`
|
||||
: baseClassName;
|
||||
},
|
||||
|
||||
renderFolderChild(node: GuildTreeNode, posInSet: number, setSize: number): ReactNode | null {
|
||||
if (node?.type !== "folder") return null;
|
||||
|
||||
try {
|
||||
if (!FolderItem) return null;
|
||||
return (
|
||||
<FolderItem
|
||||
folderNode={node}
|
||||
aria-setsize={setSize}
|
||||
aria-posinset={posInSet}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
handleFolderDrop(dragItem: FolderDragItem, targetNode: GuildTreeNode, _moveToBelow: boolean, isCombine: boolean): boolean {
|
||||
if (dragItem.type !== "folder") return false;
|
||||
|
||||
if (!isCombine) {
|
||||
unnestFolder(dragItem.nodeId?.toString());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (targetNode.type !== "folder") return false;
|
||||
|
||||
const childId = dragItem.nodeId?.toString();
|
||||
const parentId = targetNode.id?.toString();
|
||||
|
||||
try {
|
||||
nestFolder(childId, parentId);
|
||||
FluxDispatcher.dispatch({ type: "BETTER_FOLDERS_NESTED_UPDATE" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
shouldShowCombineTarget(noCombine: boolean, targetNode: GuildTreeNode): boolean {
|
||||
if (noCombine) return false;
|
||||
return targetNode.parentId == null || targetNode.type === "folder";
|
||||
},
|
||||
|
||||
getFolderAcceptTypes(targetNode: GuildTreeNode): string[] {
|
||||
const GUILD = "guild";
|
||||
const FOLDER = "folder";
|
||||
if (targetNode?.type === FOLDER) return [GUILD, FOLDER];
|
||||
return [GUILD];
|
||||
},
|
||||
|
||||
canDropOnFolder(dragItem: FolderDragItem, targetNode: GuildTreeNode, isCombine: boolean): boolean {
|
||||
if (dragItem.nodeId === targetNode.id) return false;
|
||||
|
||||
if (dragItem.type === "folder" && targetNode.type === "folder") {
|
||||
if (isCombine) return !hasParentInChain(targetNode.id?.toString(), dragItem.nodeId?.toString());
|
||||
return targetNode.parentId == null;
|
||||
}
|
||||
|
||||
if (isCombine && dragItem.type === "folder") return false;
|
||||
if (dragItem.type === "folder" && targetNode.parentId != null) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
wrapGuildNodeComponent(node: GuildTreeNode, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set<string | number>) {
|
||||
if (node.type === "folder") {
|
||||
const mappedParentId = getParentFolderId(node.id);
|
||||
if (mappedParentId != null && (node.parentId?.toString() !== mappedParentId || node.isBetterFoldersNested !== true)) {
|
||||
return (
|
||||
<div style={{ display: "none" }}>
|
||||
{originalComponent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
wrapGuildNodeComponent(node: any, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
|
||||
if (
|
||||
!isBetterFolders ||
|
||||
node.type === "folder" && expandedFolderIds?.has(node.id) ||
|
||||
node.type === "guild" && expandedFolderIds?.has(node.parentId)
|
||||
node.type === "guild" && node.parentId != null && expandedFolderIds?.has(node.parentId)
|
||||
) {
|
||||
return originalComponent();
|
||||
}
|
||||
|
|
@ -354,12 +729,10 @@ export default definePlugin({
|
|||
// can cause hang if intl message is not found
|
||||
const serversIntlMsg = getIntlMessage("SERVERS");
|
||||
if (!serversIntlMsg) {
|
||||
new Logger("BetterFolders").error("Failed to get SERVERS intl message");
|
||||
return true;
|
||||
}
|
||||
return child?.props?.["aria-label"] === serversIntlMsg;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
|
@ -373,14 +746,13 @@ export default definePlugin({
|
|||
|
||||
try {
|
||||
return filterTreeWithTargetNode(child, child => child?.props?.renderTreeNode != null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
|
||||
shouldShowFolderIconAndBackground(isBetterFolders: boolean, expandedFolderIds?: Set<string | number>) {
|
||||
if (!isBetterFolders) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
grid-area: betterFoldersSidebar
|
||||
}
|
||||
|
||||
.vc-betterFolders-nested-parent-list {
|
||||
height: auto !important;
|
||||
padding-bottom: 6px !important;
|
||||
}
|
||||
|
||||
/* These area names need to be hardcoded. Only betterFoldersSidebar is added by the plugin. */
|
||||
.vc-betterFolders-sidebar-grid {
|
||||
/* stylelint-disable-next-line value-keyword-case */
|
||||
|
|
@ -10,4 +15,4 @@
|
|||
"titleBar titleBar titleBar titleBar"
|
||||
"guildsList betterFoldersSidebar notice notice"
|
||||
"guildsList betterFoldersSidebar channelsList page";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { definePluginSettings } from "@api/Settings";
|
||||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import { buildPluginMenuEntries, buildThemeMenuEntries } from "@equicordplugins/equicordToolbox/menu";
|
||||
import SettingsPlugin from "@plugins/_core/settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
|
@ -18,7 +19,17 @@ import type { HTMLAttributes, ReactElement } from "react";
|
|||
|
||||
import fullHeightStyle from "./fullHeightContext.css?managed";
|
||||
|
||||
type SettingsEntry = { section: string, label: string; };
|
||||
type SettingsEntry = {
|
||||
ariaLabel?: string;
|
||||
element?: any;
|
||||
label?: string;
|
||||
newIndicator?: any;
|
||||
newIndicatorDismissibleContentTypes?: number[];
|
||||
predicate?: () => boolean;
|
||||
searchableTitles?: string[];
|
||||
section: string,
|
||||
tabPredicate?: () => boolean;
|
||||
};
|
||||
|
||||
const cl = classNameFactory("");
|
||||
const Classes = findCssClassesLazy("animating", "baseLayer", "bg", "layer", "layers");
|
||||
|
|
@ -127,26 +138,46 @@ export default definePlugin({
|
|||
],
|
||||
predicate: () => settings.store.disableFade
|
||||
},
|
||||
{ // Disable fade animations for settings menu
|
||||
find: '"data-mana-component":"layer-modal"',
|
||||
replacement: [
|
||||
{
|
||||
match: /(\i)\.animated\.div(?=,\{"data-mana-component":"layer-modal")/,
|
||||
replace: '"div"'
|
||||
},
|
||||
{
|
||||
match: /(?<="data-mana-component":"layer-modal"[^}]*?)style:\i,/,
|
||||
replace: "style:{},"
|
||||
}
|
||||
],
|
||||
predicate: () => settings.store.disableFade
|
||||
},
|
||||
{ // Disable initial and exit delay for settings menu
|
||||
find: "headerId:void 0,headerIdIsManaged:!1",
|
||||
replacement: {
|
||||
match: /let (\i)=300/,
|
||||
replace: "let $1=0"
|
||||
},
|
||||
predicate: () => settings.store.disableFade
|
||||
},
|
||||
{ // Load menu TOC eagerly
|
||||
find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}",
|
||||
find: "handleOpenSettingsContextMenu=",
|
||||
replacement: {
|
||||
match: /(?=handleOpenSettingsContextMenu=.{0,100}?null!=\i&&.{0,100}?(await [^};]*?\)\)))/,
|
||||
replace: "_vencordBetterSettingsEagerLoad=(async ()=>$1)();"
|
||||
},
|
||||
predicate: () => settings.store.eagerLoad
|
||||
},
|
||||
{
|
||||
// Settings cog context menu
|
||||
{ // Settings cog context menu
|
||||
find: "#{intl::USER_SETTINGS_ACTIONS_MENU_LABEL}",
|
||||
replacement: [
|
||||
{
|
||||
match: /=\[\];(\i)(?=\.forEach.{0,200}?"logout"===\i.{0,100}?(\i)\.get\(\i\))/,
|
||||
replace: "=$self.wrapMap([]);$self.transformSettingsEntries($1,$2)",
|
||||
predicate: () => settings.store.organizeMenu
|
||||
},
|
||||
{
|
||||
match: /case \i\.\i\.DEVELOPER_OPTIONS:return \i;/,
|
||||
replace: "$&case 'EquicordPlugins':return $self.buildPluginMenuEntries(true);$&case 'EquicordThemes':return $self.buildThemeMenuEntries();"
|
||||
replace: "$&case 'EquicordPlugins':return $self.buildPluginMenuEntries(true);case 'EquicordThemes':return $self.buildThemeMenuEntries();"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -171,21 +202,49 @@ export default definePlugin({
|
|||
},
|
||||
|
||||
transformSettingsEntries(list: SettingsEntry[], keyMap: Map<string, string>) {
|
||||
const items = [] as TransformedSettingsEntry[];
|
||||
const items: any[] = [];
|
||||
const equicordItems: SettingsEntry[] = [
|
||||
{ section: "EquicordSettings", label: "Settings" },
|
||||
{ section: "EquicordPlugins", label: "Plugins" },
|
||||
{ section: "EquicordThemes", label: "Themes" },
|
||||
{ section: "EquicordUpdater", label: "Updater" },
|
||||
{ section: "EquicordChangelog", label: "Changelog" },
|
||||
{ section: "EquicordCloud", label: "Cloud" },
|
||||
{ section: "EquicordBackupAndRestore", label: "Backup & Restore" },
|
||||
{ section: "EquicordPatchHelper", label: "Patch Helper" },
|
||||
];
|
||||
|
||||
for (const item of list) {
|
||||
if (item.section === "HEADER") {
|
||||
keyMap.set(item.label, item.label);
|
||||
items.push({ section: item.label, items: [] });
|
||||
} else if (item.section !== "DIVIDER" && keyMap.has(item.section)) {
|
||||
items.at(-1)?.items.push(item);
|
||||
for (const [section, key] of SettingsPlugin.settingsSectionMap) {
|
||||
const entry = SettingsPlugin.customEntries.find(e => key.endsWith(e.key));
|
||||
if (entry) equicordItems.push({ section, label: entry.title });
|
||||
}
|
||||
|
||||
if (settings.store.organizeMenu) {
|
||||
for (const item of list) {
|
||||
if (!item.label || item.predicate != null && !item.predicate()) continue;
|
||||
|
||||
if (item.section === "HEADER") {
|
||||
keyMap.set(item.label, item.label);
|
||||
items.push({ section: item.label, items: [] });
|
||||
} else if (item.section !== "DIVIDER" && keyMap.has(item.section)) {
|
||||
items.at(-1)?.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
keyMap.set("Equicord", "Equicord");
|
||||
items.push({
|
||||
section: "Equicord",
|
||||
items: equicordItems
|
||||
});
|
||||
} else {
|
||||
items.push(...list, ...equicordItems);
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
wrapMap(toWrap: TransformedSettingsEntry[]) {
|
||||
if (!settings.store.organizeMenu) return toWrap;
|
||||
// @ts-expect-error
|
||||
toWrap.map = function (render: (item: SettingsEntry) => ReactElement<any>) {
|
||||
return this
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export default definePlugin({
|
|||
},
|
||||
// Current user area, at bottom of channels/dm list
|
||||
{
|
||||
find: "#{intl::ACCOUNT_SPEAKING_WHILE_MUTED}",
|
||||
find: ".WIDGETS_RTC_UPSELL_COACHMARK),",
|
||||
replacement: [
|
||||
// Use Decor avatar decoration hook
|
||||
{
|
||||
|
|
@ -100,7 +100,7 @@ export default definePlugin({
|
|||
...[
|
||||
'"Message Username"', // Messages
|
||||
"#{intl::COLLECTIBLES_NAMEPLATE_PREVIEW_A11Y}", // Nameplate preview
|
||||
"#{intl::ayozFl::raw}", // Avatar preview
|
||||
"#{intl::COLLECTIBLES_PROFILE_PREVIEW_A11Y}", // Avatar preview
|
||||
].map(find => ({
|
||||
find,
|
||||
replacement: {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export default definePlugin({
|
|||
{
|
||||
find: "useCanFavoriteChannel",
|
||||
replacement: {
|
||||
match: /(?<=\i\.id\)\),\i=)\i\.isThread\(\)/,
|
||||
match: /(?<=isFavorite\(\i\.id\).{0,5})\i\.isThread\(\)/,
|
||||
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 { AuthenticationStore, Constants, EditMessageStore, FluxDispatcher, MessageActions, MessageTypeSets, PermissionsBits, PermissionStore, PinActions, RestAPI, Toasts, WindowStore } from "@webpack/common";
|
||||
|
||||
import { AdditionalReactEmojisSetting, MAX_ADDITIONAL_REACT_EMOJIS, ReactEmojiSetting } from "./ReactEmojiSetting";
|
||||
|
||||
type Modifier = "NONE" | "SHIFT" | "CTRL" | "ALT" | "BACKSPACE" | "DELETE";
|
||||
type ClickAction = "NONE" | "DELETE" | "COPY_LINK" | "COPY_ID" | "COPY_CONTENT" | "COPY_USER_ID" | "EDIT" | "REPLY" | "REACT" | "OPEN_THREAD" | "OPEN_TAB" | "EDIT_REPLY" | "QUOTE" | "PIN";
|
||||
|
||||
const logger = new Logger("MessageClickActions");
|
||||
const ADDITIONAL_REACTION_DELAY_MS = 300; // discord seems to rate limit this for 300ms but that might not be constant
|
||||
|
||||
const actions: { label: string; value: ClickAction; }[] = [
|
||||
{ label: "None", value: "NONE" },
|
||||
{ label: "Delete", value: "DELETE" },
|
||||
|
|
@ -130,12 +135,7 @@ let mouseDownCount = 0;
|
|||
let doubleClickDetected = false;
|
||||
let secondMouseDownTime = 0;
|
||||
|
||||
const settings = definePluginSettings({
|
||||
reactEmoji: {
|
||||
type: OptionType.STRING,
|
||||
description: "Emoji to use for react actions",
|
||||
default: "💀"
|
||||
},
|
||||
export const settings = definePluginSettings({
|
||||
singleClickAction: {
|
||||
type: OptionType.SELECT,
|
||||
description: "Action on single click (your messages)",
|
||||
|
|
@ -190,6 +190,26 @@ const settings = definePluginSettings({
|
|||
options: modifiers,
|
||||
default: "NONE"
|
||||
},
|
||||
reactEmoji: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Emoji to use for react actions.",
|
||||
component: ReactEmojiSetting,
|
||||
default: "💀"
|
||||
},
|
||||
addAdditionalReacts: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Also add additional configured reaction emojis",
|
||||
default: false
|
||||
},
|
||||
additionalReactEmojis: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: `Additional emojis to add when using React action (comma/newline separated, max ${MAX_ADDITIONAL_REACT_EMOJIS})`,
|
||||
component: AdditionalReactEmojisSetting,
|
||||
get hidden() {
|
||||
return !settings.store.addAdditionalReacts;
|
||||
},
|
||||
default: ""
|
||||
},
|
||||
disableInDms: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Disable all click actions in direct messages",
|
||||
|
|
@ -215,7 +235,7 @@ const settings = definePluginSettings({
|
|||
deferDoubleClickForTriple: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Delay double-click to allow triple-click actions (disables triple-click when off)",
|
||||
default: true
|
||||
default: false
|
||||
},
|
||||
selectionHoldTimeout: {
|
||||
type: OptionType.NUMBER,
|
||||
|
|
@ -246,6 +266,51 @@ function showWarning(message: string) {
|
|||
});
|
||||
}
|
||||
|
||||
function clearClickTimeouts() {
|
||||
if (doubleClickTimeout) {
|
||||
clearTimeout(doubleClickTimeout);
|
||||
doubleClickTimeout = null;
|
||||
}
|
||||
if (singleClickTimeout) {
|
||||
clearTimeout(singleClickTimeout);
|
||||
singleClickTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetClickState() {
|
||||
mouseDownCount = 0;
|
||||
doubleClickDetected = false;
|
||||
secondMouseDownTime = 0;
|
||||
}
|
||||
|
||||
function normalizeEmoji(emoji: string): string | null {
|
||||
const trimmed = emoji.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
|
||||
if (customMatch) {
|
||||
return `${customMatch[1]}:${customMatch[2]}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getConfiguredReactionEmojis() {
|
||||
const baseEmoji = normalizeEmoji(settings.store.reactEmoji);
|
||||
const configured = [baseEmoji];
|
||||
|
||||
if (settings.store.addAdditionalReacts) {
|
||||
const extra = settings.store.additionalReactEmojis
|
||||
.split(/[\n,]/g)
|
||||
.map(normalizeEmoji)
|
||||
.filter((emoji): emoji is string => Boolean(emoji))
|
||||
.slice(0, MAX_ADDITIONAL_REACT_EMOJIS);
|
||||
configured.push(...extra);
|
||||
}
|
||||
|
||||
return Array.from(new Set(configured.filter((emoji): emoji is string => Boolean(emoji))));
|
||||
}
|
||||
|
||||
const canSend = (channel: Channel) =>
|
||||
!channel.guild_id || PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel);
|
||||
|
||||
|
|
@ -260,19 +325,14 @@ const canReply = (msg: Message) =>
|
|||
MessageTypeSets.REPLYABLE.has(msg.type) && !msg.hasFlag(MessageFlags.EPHEMERAL);
|
||||
|
||||
async function toggleReaction(channelId: string, messageId: string, emoji: string, channel: Channel, msg: Message) {
|
||||
const trimmed = emoji.trim();
|
||||
if (!trimmed) return;
|
||||
const emojiParam = normalizeEmoji(emoji);
|
||||
if (!emojiParam) return;
|
||||
|
||||
if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
|
||||
showWarning("Cannot react: Missing permissions");
|
||||
return;
|
||||
}
|
||||
|
||||
const customMatch = trimmed.match(/^:?([\w-]+):(\d+)$/);
|
||||
const emojiParam = customMatch
|
||||
? `${customMatch[1]}:${customMatch[2]}`
|
||||
: trimmed;
|
||||
|
||||
const hasReacted = msg.reactions?.some(r => {
|
||||
const reactionEmoji = r.emoji.id
|
||||
? `${r.emoji.name}:${r.emoji.id}`
|
||||
|
|
@ -291,12 +351,47 @@ async function toggleReaction(channelId: string, messageId: string, emoji: strin
|
|||
});
|
||||
}
|
||||
} catch (e) {
|
||||
new Logger("MessageClickActions").error("Failed to toggle reaction:", e);
|
||||
logger.error("Failed to toggle reaction:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function addReaction(channelId: string, messageId: string, emoji: string, channel: Channel) {
|
||||
const emojiParam = normalizeEmoji(emoji);
|
||||
if (!emojiParam) return;
|
||||
|
||||
if (channel.guild_id && (!PermissionStore.can(PermissionsBits.ADD_REACTIONS, channel) || !PermissionStore.can(PermissionsBits.READ_MESSAGE_HISTORY, channel))) {
|
||||
showWarning("Cannot react: Missing permissions");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await RestAPI.put({
|
||||
url: Constants.Endpoints.REACTION(channelId, messageId, emojiParam, "@me")
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to add reaction:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function reactWithConfiguredEmojis(channel: Channel, msg: Message) {
|
||||
const [primaryEmoji, ...additionalEmojis] = getConfiguredReactionEmojis();
|
||||
if (!primaryEmoji) return;
|
||||
|
||||
await toggleReaction(channel.id, msg.id, primaryEmoji, channel, msg);
|
||||
|
||||
for (const emoji of additionalEmojis) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, ADDITIONAL_REACTION_DELAY_MS));
|
||||
await addReaction(channel.id, msg.id, emoji, channel);
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageLink(msg: Message, channel: Channel) {
|
||||
const guildId = channel.guild_id ?? "@me";
|
||||
return `${window.location.origin}/channels/${guildId}/${channel.id}/${msg.id}`;
|
||||
}
|
||||
|
||||
function copyLink(msg: Message, channel: Channel) {
|
||||
copyWithToast(`https://discord.com/channels/${channel.guild_id ?? "@me"}/${channel.id}/${msg.id}`, "Link copied!");
|
||||
copyWithToast(getMessageLink(msg, channel), "Link copied!");
|
||||
}
|
||||
|
||||
function togglePin(channel: Channel, msg: Message) {
|
||||
|
|
@ -343,9 +438,7 @@ function quoteMessage(channel: Channel, msg: Message) {
|
|||
}
|
||||
|
||||
function openInNewTab(msg: Message, channel: Channel) {
|
||||
const guildId = channel.guild_id ?? "@me";
|
||||
const link = `https://discord.com/channels/${guildId}/${channel.id}/${msg.id}`;
|
||||
VencordNative.native.openExternal(link);
|
||||
VencordNative.native.openExternal(getMessageLink(msg, channel));
|
||||
}
|
||||
|
||||
function openInThread(msg: Message, channel: Channel) {
|
||||
|
|
@ -462,7 +555,7 @@ async function executeAction(
|
|||
break;
|
||||
|
||||
case "REACT":
|
||||
await toggleReaction(channel.id, msg.id, settings.store.reactEmoji, channel, msg);
|
||||
await reactWithConfiguredEmojis(channel, msg);
|
||||
event.preventDefault();
|
||||
break;
|
||||
|
||||
|
|
@ -484,7 +577,7 @@ async function executeAction(
|
|||
export default definePlugin({
|
||||
name: "MessageClickActions",
|
||||
description: "Customize click actions on messages.",
|
||||
authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite],
|
||||
authors: [Devs.Ven, EquicordDevs.keircn, EquicordDevs.ZcraftElite, EquicordDevs.omaw],
|
||||
isModified: true,
|
||||
|
||||
settings,
|
||||
|
|
@ -502,18 +595,9 @@ export default definePlugin({
|
|||
document.removeEventListener("mousedown", onMouseDown);
|
||||
WindowStore.removeChangeListener(focusChanged);
|
||||
|
||||
if (doubleClickTimeout) {
|
||||
clearTimeout(doubleClickTimeout);
|
||||
doubleClickTimeout = null;
|
||||
}
|
||||
if (singleClickTimeout) {
|
||||
clearTimeout(singleClickTimeout);
|
||||
singleClickTimeout = null;
|
||||
}
|
||||
clearClickTimeouts();
|
||||
pendingDoubleClickAction = null;
|
||||
mouseDownCount = 0;
|
||||
doubleClickDetected = false;
|
||||
secondMouseDownTime = 0;
|
||||
resetClickState();
|
||||
},
|
||||
|
||||
onMessageClick(msg, channel, event) {
|
||||
|
|
@ -547,9 +631,7 @@ export default definePlugin({
|
|||
|
||||
if (Date.now() - lastMouseDownTime > settings.store.selectionHoldTimeout) {
|
||||
pressedModifiers.clear();
|
||||
mouseDownCount = 0;
|
||||
doubleClickDetected = false;
|
||||
secondMouseDownTime = 0;
|
||||
resetClickState();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -560,9 +642,7 @@ export default definePlugin({
|
|||
|
||||
if (isTripleClick) {
|
||||
if (!settings.store.deferDoubleClickForTriple) {
|
||||
mouseDownCount = 0;
|
||||
doubleClickDetected = false;
|
||||
secondMouseDownTime = 0;
|
||||
resetClickState();
|
||||
return;
|
||||
}
|
||||
if (doubleClickTimeout) {
|
||||
|
|
@ -576,9 +656,7 @@ export default definePlugin({
|
|||
pressedModifiers.clear();
|
||||
}
|
||||
doubleClickFired = false;
|
||||
mouseDownCount = 0;
|
||||
doubleClickDetected = false;
|
||||
secondMouseDownTime = 0;
|
||||
resetClickState();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -628,9 +706,7 @@ export default definePlugin({
|
|||
event.preventDefault();
|
||||
}
|
||||
|
||||
mouseDownCount = 0;
|
||||
doubleClickDetected = false;
|
||||
secondMouseDownTime = 0;
|
||||
resetClickState();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -642,9 +718,7 @@ export default definePlugin({
|
|||
executeAction(singleClickAction, msg, channel, event);
|
||||
pressedModifiers.clear();
|
||||
}
|
||||
mouseDownCount = 0;
|
||||
doubleClickDetected = false;
|
||||
secondMouseDownTime = 0;
|
||||
resetClickState();
|
||||
};
|
||||
|
||||
const canDoubleClickWithCurrentModifier =
|
||||
|
|
|
|||
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: {
|
||||
// Show inside voice channel instead of trying to join them when clicking on a channel mention
|
||||
match: /(?<=getChannel\(\i\);null!=(\i))(?=.{0,100}?selectVoiceChannel)/,
|
||||
replace: (_, channel) => `&&!$self.isHiddenChannel(${channel})`
|
||||
match: /(?<=getChannel\(\i\);if\(null!=(\i)).{0,200}?return void (?=\i\.default\.selectVoiceChannel)/,
|
||||
replace: (m, channel) => `${m}!$self.isHiddenChannel(${channel})&&`
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { definePluginSettings } from "@api/Settings";
|
|||
import { Button, TextButton } from "@components/Button";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Heading } from "@components/Heading";
|
||||
import ircColors from "@plugins/ircColors";
|
||||
import mentionAvatars from "@plugins/mentionAvatars";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/index";
|
||||
|
|
@ -20,7 +21,7 @@ import { t } from "@utils/translation";
|
|||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { GuildMember, Message, User } from "@vencord/discord-types";
|
||||
import { findByCodeLazy, findStoreLazy } from "@webpack";
|
||||
import { ChannelStore, GuildMemberStore, GuildStore, Menu, MessageStore, RelationshipStore, StreamerModeStore, TextInput, useEffect, useState } from "@webpack/common";
|
||||
import { ChannelStore, GuildMemberStore, GuildRoleStore, GuildStore, Menu, MessageStore, RelationshipStore, StreamerModeStore, TextInput, useEffect, useState } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
const SMYNC = classNameFactory();
|
||||
|
|
@ -29,7 +30,6 @@ const wrapEmojis = findByCodeLazy("lastIndex;return");
|
|||
const adjustColor = findByCodeLazy("light1", "dark1", "toonStroke");
|
||||
const AccessibilityStore = findStoreLazy("AccessibilityStore");
|
||||
|
||||
const colorPattern = /^#(?:[\da-f]{3}){1,2}$|^#(?:[\da-f]{4}){1,2}$|(rgb|hsl)a?\((\s*-?\d+%?\s*,){2}(\s*-?\d+%?\s*)\)|(rgb|hsl)a?\((\s*-?\d+%?\s*,){3}\s*(0|(0?\.\d+)|1)\)$/iu;
|
||||
const roleColorPattern = /^role((?:\+|-)\d{0,4})?$/iu;
|
||||
const symbolPattern = /^[\p{S}\p{P}]{1,3}$/iu;
|
||||
const templatePattern = /(?:\{(?:custom|friend|nick|display|user)(?:,\s*(?:custom|friend|nick|display|user))*\})/iu;
|
||||
|
|
@ -37,29 +37,59 @@ const templatePattern = /(?:\{(?:custom|friend|nick|display|user)(?:,\s*(?:custo
|
|||
type CustomNicknameData = Record<string, string>;
|
||||
let customNicknames: CustomNicknameData = {};
|
||||
|
||||
function adjustHex(color: string, percent: number): string {
|
||||
let hex = color.replace("#", "");
|
||||
let toCSSCache: Map<string, string | null> | null = null;
|
||||
let toCSSProbe: HTMLDivElement | null = null;
|
||||
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split("").map(c => c + c).join("");
|
||||
}
|
||||
function toCSS(color: string | number | null | undefined): string | null {
|
||||
if (color == null) return null;
|
||||
if (typeof color === "number") return `#${color.toString(16).padStart(6, "0")}`;
|
||||
if (!color) return null;
|
||||
|
||||
const num = parseInt(hex, 16);
|
||||
let r = (num >> 16) & 0xFF;
|
||||
let g = (num >> 8) & 0xFF;
|
||||
let b = num & 0xFF;
|
||||
const cached = toCSSCache?.get(color);
|
||||
if (cached !== undefined) return cached ?? null;
|
||||
|
||||
if (!toCSSProbe) return null;
|
||||
|
||||
toCSSProbe.style.color = "";
|
||||
toCSSProbe.style.color = color;
|
||||
const result = toCSSProbe.style.color !== "" ? color : null;
|
||||
toCSSCache?.set(color, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let convertToRGBCanvas: HTMLCanvasElement | null = null;
|
||||
let convertToRGBCtx: CanvasRenderingContext2D | null = null;
|
||||
let convertToRGBCache: Map<string, [number, number, number] | null> | null = null;
|
||||
|
||||
function convertToRGB(color: string): [number, number, number] | null {
|
||||
const cached = convertToRGBCache?.get(color);
|
||||
if (cached !== undefined) return cached ?? null;
|
||||
|
||||
if (!convertToRGBCanvas || !convertToRGBCtx) return null;
|
||||
|
||||
convertToRGBCtx.fillStyle = "#000000";
|
||||
convertToRGBCtx.clearRect(0, 0, 1, 1);
|
||||
convertToRGBCtx.fillStyle = color;
|
||||
convertToRGBCtx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = convertToRGBCtx.getImageData(0, 0, 1, 1).data;
|
||||
const result: [number, number, number] = [r, g, b];
|
||||
convertToRGBCache?.set(color, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function adjustBrightness(color: string, percent: number): string {
|
||||
const rgb = convertToRGB(color);
|
||||
if (!rgb) return color;
|
||||
|
||||
let [r, g, b] = rgb;
|
||||
r = Math.max(0, Math.min(255, r + Math.round(r * (percent / 100))));
|
||||
g = Math.max(0, Math.min(255, g + Math.round(g * (percent / 100))));
|
||||
b = Math.max(0, Math.min(255, b + Math.round(b * (percent / 100))));
|
||||
|
||||
const newColor = ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0");
|
||||
const hex = `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
|
||||
if (hex === "#ffffff" || hex === "#000000") return color;
|
||||
|
||||
if (newColor === "ffffff" || newColor === "000000") {
|
||||
return color;
|
||||
}
|
||||
|
||||
return `#${newColor}`;
|
||||
return hex;
|
||||
}
|
||||
|
||||
function validColor(color: string) {
|
||||
|
|
@ -74,66 +104,51 @@ function validColor(color: string) {
|
|||
return !isNaN(percentage) && percentage <= 100 && percentage >= -100;
|
||||
}
|
||||
|
||||
const colorTestDiv = document.createElement("div");
|
||||
colorTestDiv.style.borderColor = color;
|
||||
|
||||
const isValid = colorTestDiv.style.borderColor !== "" && colorPattern.test(color);
|
||||
colorTestDiv.remove();
|
||||
|
||||
return isValid;
|
||||
return !!toCSS(color);
|
||||
}
|
||||
|
||||
function resolveColor(
|
||||
user: User | GuildMember,
|
||||
colorStrings: colorStringsType,
|
||||
displayNameStyles: { effectId: number; colors: number[]; } | null | undefined,
|
||||
savedColor: string,
|
||||
canUseGradient: boolean,
|
||||
inGuild: boolean,
|
||||
forceWhite: boolean
|
||||
ircColorsEnabled: boolean,
|
||||
isHovering: boolean,
|
||||
): Record<string, any> | null {
|
||||
if (!savedColor.trim()) { return null; }
|
||||
const defaultColor = getComputedStyle(document.documentElement).getPropertyValue("--text-strong").trim() || null;
|
||||
|
||||
if (!defaultColor) { return null; }
|
||||
|
||||
savedColor = savedColor.trim() || defaultColor;
|
||||
const isRoleColor = savedColor.toLowerCase().includes("role");
|
||||
const forceDefault = !inGuild && !ircColorsEnabled && (isRoleColor ? !isHovering : false);
|
||||
|
||||
let gradient: any = null;
|
||||
let primaryColor: any = savedColor;
|
||||
let secondaryColor: any = savedColor;
|
||||
let tertiaryColor: any = savedColor;
|
||||
let primaryAdjusted: any = savedColor;
|
||||
let secondaryAdjusted: any = savedColor;
|
||||
let tertiaryAdjusted: any = savedColor;
|
||||
let primaryColor: any = null;
|
||||
let secondaryColor: any = null;
|
||||
let tertiaryColor: any = null;
|
||||
let primaryAdjusted: any = null;
|
||||
let secondaryAdjusted: any = null;
|
||||
let tertiaryAdjusted: any = null;
|
||||
|
||||
if (savedColor.toLowerCase().includes("role")) {
|
||||
if (isRoleColor) {
|
||||
const percentage = roleColorPattern.exec(savedColor)?.[1] || "";
|
||||
if (percentage && isNaN(parseInt(percentage))) return null;
|
||||
|
||||
const colorStrings = (user as any)?.colorStrings || {};
|
||||
const displayStyles = !inGuild ? (user as any)?.displayNameStyles || {} : {};
|
||||
|
||||
primaryColor = forceWhite ? "#ffffff" : colorStrings.primaryColor || displayStyles?.colors?.[0] || "#ffffff";
|
||||
if (typeof primaryColor === "number") primaryColor = `#${primaryColor.toString(16).padStart(6, "0")}`;
|
||||
|
||||
secondaryColor = forceWhite ? null : colorStrings.secondaryColor || displayStyles?.colors?.[1] || null;
|
||||
if (typeof secondaryColor === "number") secondaryColor = `#${secondaryColor.toString(16).padStart(6, "0")}`;
|
||||
|
||||
tertiaryColor = forceWhite ? null : colorStrings.tertiaryColor || displayStyles?.colors?.[2] || null;
|
||||
if (typeof tertiaryColor === "number") tertiaryColor = `#${tertiaryColor.toString(16).padStart(6, "0")}`;
|
||||
primaryColor = forceDefault ? defaultColor : (toCSS(colorStrings?.primaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[0])) || defaultColor);
|
||||
secondaryColor = forceDefault ? null : (toCSS(colorStrings?.secondaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[1])) || null);
|
||||
tertiaryColor = forceDefault ? null : (toCSS(colorStrings?.tertiaryColor) || (!inGuild && toCSS(displayNameStyles?.colors?.[2])) || null);
|
||||
|
||||
primaryAdjusted = percentage ? adjustBrightness(primaryColor, parseInt(percentage)) : primaryColor;
|
||||
secondaryAdjusted = secondaryColor && percentage ? adjustBrightness(secondaryColor, parseInt(percentage)) : secondaryColor;
|
||||
tertiaryAdjusted = tertiaryColor && percentage ? adjustBrightness(tertiaryColor, parseInt(percentage)) : tertiaryColor;
|
||||
} else {
|
||||
primaryColor = forceDefault ? defaultColor : toCSS(savedColor);
|
||||
primaryAdjusted = primaryColor;
|
||||
secondaryAdjusted = secondaryColor;
|
||||
tertiaryAdjusted = tertiaryColor;
|
||||
|
||||
if (primaryColor && percentage) {
|
||||
primaryAdjusted = adjustHex(primaryColor, parseInt(percentage));
|
||||
}
|
||||
|
||||
if (secondaryColor && percentage) {
|
||||
secondaryAdjusted = adjustHex(secondaryColor, parseInt(percentage));
|
||||
}
|
||||
|
||||
if (tertiaryColor && percentage) {
|
||||
tertiaryAdjusted = adjustHex(tertiaryColor, parseInt(percentage));
|
||||
}
|
||||
}
|
||||
|
||||
gradient = !canUseGradient || !secondaryColor || forceWhite
|
||||
gradient = !canUseGradient || !secondaryColor || forceDefault
|
||||
? null
|
||||
: tertiaryColor
|
||||
? "linear-gradient(to right,var(--custom-gradient-color-1),var(--custom-gradient-color-2),var(--custom-gradient-color-3),var(--custom-gradient-color-1))"
|
||||
|
|
@ -165,7 +180,7 @@ function resolveColor(
|
|||
"--custom-gradient-color-2": secondaryColor || primaryColor,
|
||||
"--custom-gradient-color-3": tertiaryColor || primaryColor,
|
||||
"background-image": gradient,
|
||||
"animation": "smyn-animation 1.5s linear infinite"
|
||||
"animation": "smyn-animation var(--smyn-gradient-duration) linear infinite"
|
||||
},
|
||||
static: {
|
||||
original: {
|
||||
|
|
@ -301,6 +316,8 @@ interface mentionProps {
|
|||
|
||||
interface messageProps {
|
||||
message: Message | null | undefined;
|
||||
colorString?: string;
|
||||
colorStrings: colorStringsType;
|
||||
userOverride?: User;
|
||||
isRepliedMessage?: boolean;
|
||||
withMentionPrefix?: boolean;
|
||||
|
|
@ -308,12 +325,14 @@ interface messageProps {
|
|||
|
||||
interface memberListProfileReactionProps {
|
||||
user: User | null | undefined;
|
||||
type: "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel";
|
||||
type: "typingIndicator" | "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel";
|
||||
guildId?: string;
|
||||
tags?: any;
|
||||
}
|
||||
|
||||
function getMemberListProfilesReactionsVoiceName(
|
||||
type colorStringsType = { primaryColor: string | null, secondaryColor: string | null, tertiaryColor: string | null; } | null | undefined;
|
||||
|
||||
function getTypingMemberListProfilesReactionsVoiceName(
|
||||
props: memberListProfileReactionProps,
|
||||
): [string | null, JSX.Element | null, string | null] {
|
||||
const { user, type } = props;
|
||||
|
|
@ -322,16 +341,16 @@ function getMemberListProfilesReactionsVoiceName(
|
|||
const guildId = props.guildId || props.tags?.props?.displayProfile?.guildId || null;
|
||||
const member = guildId && user ? GuildMemberStore.getMember(guildId, user.id) : null;
|
||||
const author = user && member ? { ...user, ...member } : user || member || null;
|
||||
const shouldHookless = type === "reactionsTooltip" || type === "profilesTooltip";
|
||||
const shouldHookless = ["typingIndicator", "reactionsTooltip", "profilesTooltip"].includes(type);
|
||||
return renderUsername(author, null, null, type, "", shouldHookless, !!guildId);
|
||||
}
|
||||
|
||||
function getMemberListProfilesReactionsVoiceNameText(props: memberListProfileReactionProps): string | null {
|
||||
return getMemberListProfilesReactionsVoiceName(props)[2];
|
||||
function getTypingMemberListProfilesReactionsVoiceNameText(props: memberListProfileReactionProps): string | null {
|
||||
return getTypingMemberListProfilesReactionsVoiceName(props)[2];
|
||||
}
|
||||
|
||||
function getMemberListProfilesReactionsVoiceNameElement(props: memberListProfileReactionProps): JSX.Element | null {
|
||||
return getMemberListProfilesReactionsVoiceName(props)[1];
|
||||
function getTypingMemberListProfilesReactionsVoiceNameElement(props: memberListProfileReactionProps): JSX.Element | null {
|
||||
return getTypingMemberListProfilesReactionsVoiceName(props)[1];
|
||||
}
|
||||
|
||||
function getMessageName(props: messageProps): [string | null, JSX.Element | null, string | null] {
|
||||
|
|
@ -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 author = user && member ? { ...user, ...member } : user || member || null;
|
||||
const mentionSymbol = hideDefaultAtSign && (!isRepliedMessage || replies) ? "" : withMentionPrefix ? "@" : "";
|
||||
return renderUsername(author, channel?.id || null, message?.id || null, isRepliedMessage ? "replies" : "messages", mentionSymbol, false, !!channel?.guild_id);
|
||||
return renderUsername(author, channel?.id || null, message?.id || null, isRepliedMessage ? "replies" : "messages", mentionSymbol, false, !!channel?.guild_id, props.colorString, props.colorStrings);
|
||||
}
|
||||
|
||||
function getMessageNameElement(props: messageProps): JSX.Element | null {
|
||||
|
|
@ -363,7 +382,20 @@ function getMentionNameElement(props: mentionProps): JSX.Element | null {
|
|||
const member = channel ? GuildMemberStore.getMember(channel.guild_id, userId) : null;
|
||||
const author = user && member ? { ...user, ...member } : user || member || null;
|
||||
const mentionSymbol = hideDefaultAtSign && mentions ? "" : "@";
|
||||
return renderUsername(author, channelId || null, nestedProps?.messageId || null, "mentions", mentionSymbol, false, !!channel?.guild_id)[1];
|
||||
|
||||
let colorString: string | undefined = undefined;
|
||||
let colorStrings: colorStringsType = undefined;
|
||||
|
||||
if (isPluginEnabled(ircColors.name)) {
|
||||
const color = ircColors.calculateNameColorForMessageContext({ message: { author: author }, author: author, channel: channel });
|
||||
|
||||
if (color) {
|
||||
colorString = color;
|
||||
colorStrings = { primaryColor: color, secondaryColor: null, tertiaryColor: null };
|
||||
}
|
||||
}
|
||||
|
||||
return renderUsername(author, channelId || null, nestedProps?.messageId || null, "mentions", mentionSymbol, false, !!channel?.guild_id, colorString, colorStrings)[1];
|
||||
}
|
||||
|
||||
function getEffectType(effectId: number | null | undefined): string | null {
|
||||
|
|
@ -406,14 +438,17 @@ function renderUsername(
|
|||
author: User | GuildMember | null,
|
||||
channelId: string | null,
|
||||
messageId: string | null,
|
||||
type: "messages" | "replies" | "mentions" | "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel",
|
||||
type: "messages" | "replies" | "typingIndicator" | "mentions" | "membersList" | "profilesPopout" | "profilesTooltip" | "reactionsTooltip" | "reactionsPopout" | "voiceChannel",
|
||||
mentionSymbol: string,
|
||||
hookless: boolean,
|
||||
inGuild: boolean
|
||||
inGuild: boolean,
|
||||
colorString?: string,
|
||||
colorStrings?: { primaryColor: string | null, secondaryColor: string | null, tertiaryColor: string | null; } | null
|
||||
): [string | null, JSX.Element | null, string | null] {
|
||||
const isMessage = type === "messages";
|
||||
const isReply = type === "replies";
|
||||
const isMention = type === "mentions";
|
||||
const isTyping = type === "typingIndicator";
|
||||
const isMember = type === "membersList";
|
||||
const isProfile = type === "profilesPopout";
|
||||
const isReactionsPopout = type === "reactionsPopout";
|
||||
|
|
@ -421,8 +456,8 @@ function renderUsername(
|
|||
const isReaction = isReactionsTooltip || isReactionsPopout;
|
||||
const isVoice = type === "voiceChannel";
|
||||
|
||||
const config = hookless ? settings.store : settings.use(["messages", "replies", "mentions", "memberList", "profilePopout", "reactions", "friendNameOnlyInDirectMessages", "customNameOnlyInDirectMessages", "discriminators", "hideDefaultAtSign", "truncateAllNamesWithStreamerMode", "removeDuplicates", "ignoreGradients", "ignoreFonts", "animateGradients", "includedNames", "customNameColor", "friendNameColor", "nicknameColor", "displayNameColor", "usernameColor", "nameSeparator", "triggerNameRerender"]);
|
||||
const { messages, replies, mentions, memberList, profilePopout, reactions, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages, discriminators, truncateAllNamesWithStreamerMode, removeDuplicates, ignoreGradients, ignoreFonts, animateGradients, includedNames, customNameColor, friendNameColor, nicknameColor, displayNameColor, usernameColor, nameSeparator, triggerNameRerender } = config;
|
||||
const config = hookless ? settings.store : settings.use(["messages", "replies", "mentions", "typingIndicator", "memberList", "profilePopout", "reactions", "friendNameOnlyInDirectMessages", "customNameOnlyInDirectMessages", "discriminators", "hideDefaultAtSign", "truncateAllNamesWithStreamerMode", "removeDuplicates", "ignoreGradients", "ignoreFonts", "animateGradients", "includedNames", "customNameColor", "friendNameColor", "nicknameColor", "displayNameColor", "usernameColor", "nameSeparator", "triggerNameRerender"]);
|
||||
const { messages, replies, mentions, typingIndicator, memberList, profilePopout, reactions, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages, discriminators, truncateAllNamesWithStreamerMode, removeDuplicates, ignoreGradients, ignoreFonts, animateGradients, includedNames, customNameColor, friendNameColor, nicknameColor, displayNameColor, usernameColor, nameSeparator, triggerNameRerender } = config;
|
||||
|
||||
const channel = channelId ? ChannelStore.getChannel(channelId) || null : null;
|
||||
const message = channelId && messageId ? MessageStore.getMessage(channelId, messageId) : null;
|
||||
|
|
@ -436,27 +471,39 @@ function renderUsername(
|
|||
? hoveringReactionPopoutSet.has((author as User).id)
|
||||
: false;
|
||||
|
||||
const authorDisplayNameStyles = (!inGuild && (author as any)?.displayNameStyles) || null;
|
||||
if (colorString && !colorStrings) {
|
||||
colorStrings = {
|
||||
primaryColor: colorString,
|
||||
secondaryColor: null,
|
||||
tertiaryColor: null
|
||||
};
|
||||
}
|
||||
|
||||
const ircColorsEnabled = isPluginEnabled(ircColors.name);
|
||||
|
||||
const authorTopRole = (author as GuildMember)?.guildId && (author as any)?.highestRoleId;
|
||||
const authorTopRoleColor = authorTopRole && GuildRoleStore.getRole((author as GuildMember).guildId, authorTopRole)?.colorStrings;
|
||||
const authorColorStrings = colorStrings || (author as any)?.colorStrings || authorTopRoleColor || null;
|
||||
const authorDisplayNameStyles = (!inGuild && !ircColorsEnabled && (author as any)?.displayNameStyles) || null;
|
||||
const effectType = authorDisplayNameStyles ? getEffectType(authorDisplayNameStyles.effectId) : null;
|
||||
const effectCSSVars = authorDisplayNameStyles ? computeEffectCSSVars(authorDisplayNameStyles) : {};
|
||||
const hasEffect = !!effectType;
|
||||
const needsEffectDataAttr = effectType === "neon" || effectType === "toon" || effectType === "pop";
|
||||
const shouldShowEffect = hasEffect && isHovering;
|
||||
const shouldAnimateEffect = shouldShowEffect && !AccessibilityStore.useReducedMotion;
|
||||
const shouldUseDMDefault = !inGuild && !isHovering;
|
||||
|
||||
const canUseGradient = ((author as GuildMember)?.guildId ? (GuildStore.getGuild((author as GuildMember).guildId) ?? {}).premiumFeatures?.features.includes("ENHANCED_ROLE_COLORS") : !inGuild);
|
||||
const useTopRoleStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
|
||||
const topRoleStyle = author ? resolveColor(author, "Role", canUseGradient, inGuild, shouldUseDMDefault) : null;
|
||||
const hasGradient = !!topRoleStyle?.gradient && Object.keys(topRoleStyle.gradient).length > 0;
|
||||
const useTopStyle = isMention || isReactionsPopout || channel?.isDM() || channel?.isGroupDM();
|
||||
const topStyle = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, "Role", canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
|
||||
const hasGradient = !!topStyle?.gradient && Object.keys(topStyle.gradient).length > 0;
|
||||
|
||||
const textMutedValue = getComputedStyle(document.documentElement)?.getPropertyValue("--text-muted")?.trim() || "#72767d";
|
||||
const options = splitTemplate(includedNames);
|
||||
const resolvedUsernameColor = author ? resolveColor(author, usernameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
|
||||
const resolvedDisplayNameColor = author ? resolveColor(author, displayNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
|
||||
const resolvedNicknameColor = author ? resolveColor(author, nicknameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
|
||||
const resolvedFriendNameColor = author ? resolveColor(author, friendNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
|
||||
const resolvedCustomNameColor = author ? resolveColor(author, customNameColor.trim(), canUseGradient, inGuild, shouldUseDMDefault) : null;
|
||||
const resolvedUsernameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, usernameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
|
||||
const resolvedDisplayNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, displayNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
|
||||
const resolvedNicknameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, nicknameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
|
||||
const resolvedFriendNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, friendNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
|
||||
const resolvedCustomNameColor = author ? resolveColor(authorColorStrings, authorDisplayNameStyles, customNameColor.trim(), canUseGradient, inGuild, ircColorsEnabled, isHovering) : null;
|
||||
const affixColor = { color: textMutedValue, "-webkit-text-fill-color": textMutedValue, isolation: "isolate", "white-space": "pre", "font-family": "var(--font-primary)", "letter-spacing": "normal" };
|
||||
const { username, display, nick, friend, custom } = getProcessedNames(author, truncateAllNamesWithStreamerMode, discriminators, inGuild, friendNameOnlyInDirectMessages, customNameOnlyInDirectMessages);
|
||||
|
||||
|
|
@ -552,6 +599,8 @@ function renderUsername(
|
|||
return [null, null, null];
|
||||
} else if (isMention && !mentions) {
|
||||
return [null, null, null];
|
||||
} else if (isTyping && !typingIndicator) {
|
||||
return [null, null, null];
|
||||
} else if (isMember && !memberList) {
|
||||
return [null, null, null];
|
||||
} else if (isProfile && !profilePopout) {
|
||||
|
|
@ -581,6 +630,7 @@ function renderUsername(
|
|||
}
|
||||
|
||||
const remainingNames = [first, second, third, fourth, fifth].filter(Boolean);
|
||||
|
||||
first = remainingNames.shift();
|
||||
second = remainingNames.shift();
|
||||
third = remainingNames.shift();
|
||||
|
|
@ -592,53 +642,56 @@ function renderUsername(
|
|||
const shouldAnimateSecondaryNames = animateGradients && !ignoreGradients;
|
||||
|
||||
const firstDataText = mentionSymbol + first.name;
|
||||
const secondDataText = second && shouldAnimateSecondaryNames ? (second.prefix + second.name + second.suffix) : "";
|
||||
const thirdDataText = third && shouldAnimateSecondaryNames ? (third.prefix + third.name + third.suffix) : "";
|
||||
const fourthDataText = fourth && shouldAnimateSecondaryNames ? (fourth.prefix + fourth.name + fourth.suffix) : "";
|
||||
const fifthDataText = fifth && shouldAnimateSecondaryNames ? (fifth.prefix + fifth.name + fifth.suffix) : "";
|
||||
const secondDataText = second && shouldAnimateSecondaryNames ? second.name : "";
|
||||
const thirdDataText = third && shouldAnimateSecondaryNames ? third.name : "";
|
||||
const fourthDataText = fourth && shouldAnimateSecondaryNames ? fourth.name : "";
|
||||
const fifthDataText = fifth && shouldAnimateSecondaryNames ? fifth.name : "";
|
||||
const allDataText = [firstDataText, secondDataText, thirdDataText, fourthDataText, fifthDataText].filter(Boolean).join(nameSeparator).trim();
|
||||
|
||||
// Only mentions and reactions popouts should patch in the gradient glow or else a double glow will appear on messages.
|
||||
const hoveringClass = (isHovering ? " show-me-your-name-gradient-hovered" : "");
|
||||
const gradientClasses = useTopRoleStyle
|
||||
? "show-me-your-name-gradient show-me-your-name-gradient-inherit-bg" + hoveringClass
|
||||
: "show-me-your-name-gradient show-me-your-name-gradient-unset-bg" + hoveringClass;
|
||||
const hoveringClass = (isHovering ? " smyn-gradient-hovered" : "");
|
||||
const gradientClasses = useTopStyle
|
||||
? "smyn-gradient smyn-gradient-inherit-bg" + hoveringClass
|
||||
: "smyn-gradient smyn-gradient-unset-bg" + hoveringClass;
|
||||
|
||||
const firstGroupClasses = "show-me-your-name-name-group show-me-your-name-first-name-group";
|
||||
const secondGroupClasses = "show-me-your-name-name-group show-me-your-name-second-name-group";
|
||||
const thirdGroupClasses = "show-me-your-name-name-group show-me-your-name-third-name-group";
|
||||
const fourthGroupClasses = "show-me-your-name-name-group show-me-your-name-fourth-name-group";
|
||||
const fifthGroupClasses = "show-me-your-name-name-group show-me-your-name-fifth-name-group";
|
||||
const firstNameClasses = "show-me-your-name-name show-me-your-name-first-name";
|
||||
const secondNameClasses = "show-me-your-name-name show-me-your-name-second-name";
|
||||
const thirdNameClasses = "show-me-your-name-name show-me-your-name-third-name";
|
||||
const fourthNameClasses = "show-me-your-name-name show-me-your-name-fourth-name";
|
||||
const fifthNameClasses = "show-me-your-name-name show-me-your-name-fifth-name";
|
||||
const prefixClasses = "show-me-your-name-affix show-me-your-name-prefix";
|
||||
const suffixClasses = "show-me-your-name-affix show-me-your-name-suffix";
|
||||
const firstGroupClasses = "smyn-name-group smyn-first-name-group";
|
||||
const secondGroupClasses = "smyn-name-group smyn-second-name-group";
|
||||
const thirdGroupClasses = "smyn-name-group smyn-third-name-group";
|
||||
const fourthGroupClasses = "smyn-name-group smyn-fourth-name-group";
|
||||
const fifthGroupClasses = "smyn-name-group smyn-fifth-name-group";
|
||||
const firstNameClasses = "smyn-name smyn-first-name";
|
||||
const secondNameClasses = "smyn-name smyn-second-name";
|
||||
const thirdNameClasses = "smyn-name smyn-third-name";
|
||||
const fourthNameClasses = "smyn-name smyn-fourth-name";
|
||||
const fifthNameClasses = "smyn-name smyn-fifth-name";
|
||||
const prefixClasses = "smyn-affix smyn-prefix";
|
||||
const suffixClasses = "smyn-affix smyn-suffix";
|
||||
|
||||
const animationDuration = Math.max(1, 1.5 * (first.name.length / 12));
|
||||
|
||||
const topLevelStyle = {
|
||||
// Allows names to wrap in reaction popouts.
|
||||
...(isReactionsPopout
|
||||
? { display: "flex", flexWrap: "wrap", lineHeight: "1.1em", fontSize: "0.9em" }
|
||||
: {}),
|
||||
...(hasEffect ? effectCSSVars : {})
|
||||
...(hasEffect ? effectCSSVars : {}),
|
||||
"--smyn-gradient-duration": `${animationDuration}s`
|
||||
} as React.CSSProperties;
|
||||
|
||||
const nameElement = (
|
||||
<span
|
||||
style={{
|
||||
...topLevelStyle,
|
||||
...(topRoleStyle?.normal.original || {})
|
||||
...(topStyle?.normal.original || {})
|
||||
}}
|
||||
className="show-me-your-name-container"
|
||||
className="smyn-container"
|
||||
>
|
||||
{mentionSymbol && <span>{mentionSymbol}</span>}
|
||||
{(
|
||||
<span
|
||||
className={SMYNC(firstGroupClasses, { [gradientClasses]: shouldGradientGlow })}
|
||||
data-text={shouldGradientGlow ? firstDataText : undefined}
|
||||
style={(shouldGradientGlow && useTopRoleStyle && topRoleStyle ? topRoleStyle.gradient.animated : undefined) as React.CSSProperties}
|
||||
style={(shouldGradientGlow && useTopStyle && topStyle ? topStyle.gradient.animated : undefined) as React.CSSProperties}
|
||||
>
|
||||
<span
|
||||
className={SMYNC(firstNameClasses, {
|
||||
|
|
@ -649,12 +702,12 @@ function renderUsername(
|
|||
data-username-with-effects={needsEffectDataAttr && shouldShowEffect ? first.name : undefined}
|
||||
style={shouldShowEffect
|
||||
? undefined
|
||||
: topRoleStyle ?
|
||||
shouldAnimateGradients && topRoleStyle.gradient
|
||||
? topRoleStyle.gradient.animated
|
||||
: topRoleStyle.gradient
|
||||
? topRoleStyle.gradient.static.original
|
||||
: topRoleStyle.normal.original
|
||||
: topStyle ?
|
||||
shouldAnimateGradients && topStyle.gradient
|
||||
? topStyle.gradient.animated
|
||||
: topStyle.gradient
|
||||
? topStyle.gradient.static.original
|
||||
: topStyle.normal.original
|
||||
: undefined
|
||||
}>
|
||||
{first.wrapped}</span>
|
||||
|
|
@ -668,9 +721,7 @@ function renderUsername(
|
|||
].map(({ name, dataText, groupClass, nameClass, position }) => name && (
|
||||
<span
|
||||
key={position}
|
||||
className={SMYNC(groupClass, { [gradientClasses]: shouldGradientGlow && shouldAnimateSecondaryNames })}
|
||||
data-text={shouldGradientGlow && dataText ? dataText : undefined}
|
||||
style={(shouldGradientGlow && shouldAnimateSecondaryNames ? name.style.gradient.animated : undefined) as React.CSSProperties}
|
||||
className={SMYNC(groupClass)}
|
||||
>
|
||||
<span style={affixColor as React.CSSProperties} className={prefixClasses}>
|
||||
<span>{nameSeparator}</span>
|
||||
|
|
@ -678,10 +729,12 @@ function renderUsername(
|
|||
<span
|
||||
// On non-primary names, allow disabling the effects completely, or just their animation & glow.
|
||||
className={SMYNC(nameClass, {
|
||||
[gradientClasses]: shouldGradientGlow && shouldAnimateSecondaryNames,
|
||||
"smyn-effect-container": shouldShowEffect && !ignoreGradients,
|
||||
[`smyn-effect-${effectType}`]: shouldShowEffect && !ignoreGradients,
|
||||
"smyn-effect-animated": shouldAnimateEffect && shouldAnimateSecondaryNames
|
||||
})}
|
||||
data-text={shouldGradientGlow && dataText ? dataText : undefined}
|
||||
data-username-with-effects={needsEffectDataAttr && shouldShowEffect && !ignoreGradients ? name.name : undefined}
|
||||
style={{
|
||||
...(ignoreFonts ? { "font-family": "var(--font-primary)", "letter-spacing": "normal" } : {}),
|
||||
|
|
@ -815,7 +868,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
|
|||
style={{ width: "100%" }}
|
||||
/>
|
||||
<TextButton
|
||||
className="show-me-your-name-reset-button"
|
||||
className="smyn-reset-button"
|
||||
onClick={async () => {
|
||||
setValue("");
|
||||
delete customNicknames[user.id];
|
||||
|
|
@ -827,7 +880,7 @@ function CustomNicknameModal({ modalProps, user }: { modalProps: ModalProps; use
|
|||
</TextButton>
|
||||
<div style={{ paddingTop: "10px", flexGrow: 0 }}></div>
|
||||
</ModalContent>
|
||||
<ModalFooter className="show-me-your-name-modal-footer-container">
|
||||
<ModalFooter className="smyn-modal-footer-container">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
|
|
@ -896,6 +949,11 @@ const settings = definePluginSettings({
|
|||
default: true,
|
||||
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: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
|
|
@ -1013,7 +1071,7 @@ const settings = definePluginSettings({
|
|||
export default definePlugin({
|
||||
name: "ShowMeYourName",
|
||||
description: "Display any permutation of custom nicknames, friend nicknames, server nicknames, display names, and usernames in chat.",
|
||||
authors: [Devs.Rini, Devs.TheKodeToad, EquicordDevs.Etorix, Devs.sadan, Devs.prism],
|
||||
authors: [EquicordDevs.Etorix, Devs.Rini, Devs.TheKodeToad, Devs.sadan, Devs.prism],
|
||||
tags: ["SMYN", "Nicknames", "Custom Nicknames",],
|
||||
isModified: true,
|
||||
settings,
|
||||
|
|
@ -1027,8 +1085,8 @@ export default definePlugin({
|
|||
replacement: [
|
||||
{
|
||||
// Replace names in messages and replies.
|
||||
match: /(onContextMenu:\i,children:)(.{0,250}?),"data-text":(\i\+\i)/,
|
||||
replace: "$1$self.getMessageNameElement(arguments[0])??($2),\"data-text\":$self.getMessageNameText(arguments[0])??($3)"
|
||||
match: /(?<=colorString:(\i),colorStrings:(\i).{0,900}?)style:.{0,120}?,(onClick:\i,onContextMenu:\i,children:)(.{0,250}?),"data-text":(\i\+\i)/,
|
||||
replace: "$3$self.getMessageNameElement({...arguments[0],colorString:$1,colorStrings:$2})??($4),\"data-text\":$self.getMessageNameText(arguments[0])??($5)"
|
||||
},
|
||||
{
|
||||
// Pass the message object to the should-animate checker.
|
||||
|
|
@ -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.
|
||||
find: "ImpressionNames.DM_LIST_RIGHT_CLICK_MENU_SHOWN",
|
||||
replacement: {
|
||||
match: /(?<=getMentionCount\(\i.id\)>0\),\i=)/,
|
||||
replace: "$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??"
|
||||
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??"
|
||||
},
|
||||
},
|
||||
{
|
||||
// Replace names in the friends list.
|
||||
find: "hasUniqueUsername()}),usernameClass",
|
||||
replacement: {
|
||||
match: /(?<=nick:)(\i)/,
|
||||
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,type:\"membersList\"})??$1"
|
||||
},
|
||||
},
|
||||
{
|
||||
// Don't block name style in friends list just
|
||||
// because the name is the same as the username.
|
||||
find: "location:\"DiscordTag\"});",
|
||||
replacement: {
|
||||
match: /(?<=forceUsername:(\i),.{0,550}?displayNameStyles:)\i!==\i\?(\i.displayNameStyles):null/,
|
||||
replace: "!$1?$2:null"
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -1050,7 +1133,7 @@ export default definePlugin({
|
|||
find: "channel.isSystemDM(),",
|
||||
replacement: {
|
||||
match: /(?<=}\);)(return.{0,500}?{text:)(\i,position:"bottom",children:.{0,40}?children:)(\i\?\?\i.\i.getName\(\i\))/,
|
||||
replace: "const smynName=arguments[0].channel.recipients.length===1?$self.getMemberListProfilesReactionsVoiceNameText({user:$self.UserStore.getUser(arguments[0].channel.recipients[0]),type:\"profilesPopout\"})??null:null;$1smynName??$2smynName??$3"
|
||||
replace: "const smynName=arguments[0].channel.recipients.length===1?$self.getTypingMemberListProfilesReactionsVoiceNameText({user:$self.UserStore.getUser(arguments[0].channel.recipients[0]),type:\"profilesPopout\"})??null:null;$1smynName??$2smynName??$3"
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -1091,7 +1174,7 @@ export default definePlugin({
|
|||
find: "let{colorRoleName:",
|
||||
replacement: {
|
||||
match: /(let{colorRoleName:\i,colorString:\i,colorStrings:\i,)name:(\i)/,
|
||||
replace: "$1showMeYourNameName:$2=$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??(arguments[0].name)"
|
||||
replace: "$1showMeYourNameName:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"membersList\"})??(arguments[0].name)"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -1099,7 +1182,7 @@ export default definePlugin({
|
|||
find: "shouldWrap:!0,loop:!0,inProfile:!0",
|
||||
replacement: {
|
||||
match: /(tags:\i,)nickname:(\i)/,
|
||||
replace: "$1showMeYourNameNickname:$2=$self.getMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"profilesPopout\"})??(arguments[0].nickname)"
|
||||
replace: "$1showMeYourNameNickname:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({...arguments[0],type:\"profilesPopout\"})??(arguments[0].nickname)"
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -1110,11 +1193,11 @@ export default definePlugin({
|
|||
replacement: [
|
||||
{
|
||||
match: /(displayName:)(\i.\i.getName\(void 0,void 0,\i\))/,
|
||||
replace: "$1$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:null,type:\"profilesTooltip\"})??($2)"
|
||||
replace: "$1$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:null,type:\"profilesTooltip\"})??($2)"
|
||||
},
|
||||
{
|
||||
match: /(displayName:)(\i.\i.getName\(\i,\i,\i\))/,
|
||||
replace: "$1$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].guildId,type:\"profilesTooltip\"})??($2)"
|
||||
replace: "$1$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].guildId,type:\"profilesTooltip\"})??($2)"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -1123,7 +1206,7 @@ export default definePlugin({
|
|||
find: "reactionTooltip1,",
|
||||
replacement: {
|
||||
match: /(\i.\i.getName\((\i),\i\?\.id,(\i)\))/,
|
||||
replace: "$self.getMemberListProfilesReactionsVoiceNameText({user:$3,guildId:$2,type:\"reactionsTooltip\"})??($1)"
|
||||
replace: "$self.getTypingMemberListProfilesReactionsVoiceNameText({user:$3,guildId:$2,type:\"reactionsTooltip\"})??($1)"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -1138,7 +1221,7 @@ export default definePlugin({
|
|||
{
|
||||
// Replace names in reaction popouts.
|
||||
match: /(?<=Child,{className:\i.\i,children:)/g,
|
||||
replace: "($self.getMemberListProfilesReactionsVoiceNameElement({user:arguments[0].user,guildId:arguments[0].guildId,type:\"reactionsPopout\"}))??"
|
||||
replace: "($self.getTypingMemberListProfilesReactionsVoiceNameElement({user:arguments[0].user,guildId:arguments[0].guildId,type:\"reactionsPopout\"}))??"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -1147,16 +1230,33 @@ export default definePlugin({
|
|||
find: ",connectUserDragSource:",
|
||||
replacement: {
|
||||
match: /(serverDeaf:\i,)nick:(\i)/,
|
||||
replace: "$1showMeYourNameVoice:$2=$self.getMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].channel.guild_id,type:\"voiceChannel\"})??(arguments[0].nick)"
|
||||
replace: "$1showMeYourNameVoice:$2=$self.getTypingMemberListProfilesReactionsVoiceNameText({user:arguments[0].user,guildId:arguments[0].channel.guild_id,type:\"voiceChannel\"})??(arguments[0].nick)"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
async start() {
|
||||
toCSSCache = new Map();
|
||||
toCSSProbe = document.createElement("div");
|
||||
convertToRGBCanvas = document.createElement("canvas");
|
||||
convertToRGBCanvas.width = convertToRGBCanvas.height = 1;
|
||||
convertToRGBCtx = convertToRGBCanvas.getContext("2d", { willReadFrequently: true });
|
||||
convertToRGBCache = new Map();
|
||||
|
||||
const data = await DataStore.get<CustomNicknameData>("SMYNCustomNicknames");
|
||||
customNicknames = data ?? {};
|
||||
},
|
||||
|
||||
stop() {
|
||||
toCSSCache?.clear();
|
||||
toCSSCache = null;
|
||||
toCSSProbe = null;
|
||||
convertToRGBCache?.clear();
|
||||
convertToRGBCache = null;
|
||||
convertToRGBCanvas = null;
|
||||
convertToRGBCtx = null;
|
||||
},
|
||||
|
||||
contextMenus: {
|
||||
"user-context": userContextPatch
|
||||
},
|
||||
|
|
@ -1192,6 +1292,6 @@ export default definePlugin({
|
|||
getMessageNameText,
|
||||
getMessageNameElement,
|
||||
getMentionNameElement,
|
||||
getMemberListProfilesReactionsVoiceNameText,
|
||||
getMemberListProfilesReactionsVoiceNameElement
|
||||
getTypingMemberListProfilesReactionsVoiceNameText,
|
||||
getTypingMemberListProfilesReactionsVoiceNameElement
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@
|
|||
|
||||
.smyn-gradient-unset-bg::after {
|
||||
background: unset;
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
}
|
||||
|
||||
.smyn-gradient-inherit-bg::after {
|
||||
background: inherit;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.smyn-gradient-hovered::after {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import definePlugin from "@utils/types";
|
|||
import { t } from "@utils/translation";
|
||||
import { Command } from "@vencord/discord-types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { FluxDispatcher, MessageActions } from "@webpack/common";
|
||||
import { FluxDispatcher, MessageActions, PendingReplyStore } from "@webpack/common";
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
|
|
@ -56,7 +56,6 @@ interface Track {
|
|||
}
|
||||
|
||||
const Spotify = findByPropsLazy("getPlayerState");
|
||||
const PendingReplyStore = findByPropsLazy("getPendingReply");
|
||||
|
||||
function makeCommand(name: string, formatUrl: (track: Track) => string): Command {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function toggleStreamerMode({ streamKey }: StreamEvent, value: boolean) {
|
|||
export default definePlugin({
|
||||
name: "StreamerModeOn",
|
||||
description: t("streamerModeOnStream.description"),
|
||||
authors: [Devs.Kodarru],
|
||||
authors: [Devs.IcedMarina],
|
||||
flux: {
|
||||
STREAM_CREATE: d => toggleStreamerMode(d, true),
|
||||
STREAM_DELETE: d => toggleStreamerMode(d, false)
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export const DeeplLanguages = {
|
|||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"jv": "Javanese",
|
||||
"ko": "Korean",
|
||||
"pam": "Kapampangan",
|
||||
"kk": "Kazakh",
|
||||
"kmr": "Kurdish (Kurmanji)",
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export default definePlugin({
|
|||
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::Q8lUnE::raw}",
|
||||
find: "#{intl::SEVERAL_USERS_TYPING_STRONG}",
|
||||
group: true,
|
||||
replacement: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default definePlugin({
|
|||
authors: [Devs.puv],
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::LgCPMt::raw}",
|
||||
find: "#{intl::VOICE_MESSAGES_PLAYBACK_RATE_LABEL}",
|
||||
replacement: {
|
||||
match: /(?<=onVolumeHide:\i\}\))/,
|
||||
replace: ",$self.renderDownload(arguments[0].src)"
|
||||
|
|
|
|||
|
|
@ -56,16 +56,21 @@ export default definePlugin({
|
|||
|
||||
patches: [
|
||||
// Change the max volume for sliders to allow for values above 200
|
||||
...[
|
||||
"#{intl::USER_VOLUME}",
|
||||
"currentVolume:"
|
||||
].map(find => ({
|
||||
find,
|
||||
{
|
||||
find: "#{intl::USER_VOLUME}",
|
||||
replacement: {
|
||||
match: /(?<=maxValue:)\i\.\i\?(\d+?):(\d+?)(?=,)/,
|
||||
replace: (_, higherMaxVolume, minorMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
|
||||
match: /(?<=maxValue:)\i\.isPlatformEmbedded\?(\i\.\i):\i\.\i(?=,)/,
|
||||
replace: (_, higherMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
|
||||
}
|
||||
})),
|
||||
},
|
||||
// Change the max volume for sliders to allow for values above 200
|
||||
{
|
||||
find: "currentVolume:",
|
||||
replacement: {
|
||||
match: /(?<=maxValue:)\i\.\i\?(\d+?):\d+?(?=,)/,
|
||||
replace: (_, higherMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
|
||||
}
|
||||
},
|
||||
// Patches needed for web/vesktop
|
||||
{
|
||||
find: "streamSourceNode",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class Logger {
|
|||
constructor(public name: string, public color: string = "white") { }
|
||||
|
||||
private _log(level: "log" | "error" | "warn" | "info" | "debug", levelColor: string, args: any[], customFmt = "") {
|
||||
if (IS_REPORTER && IS_WEB && !IS_VESKTOP) {
|
||||
if (IS_REPORTER && IS_WEB && !IS_VESKTOP && !IS_EQUIBOP) {
|
||||
console[level]("[Equicord]", this.name + ":", ...args);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -466,7 +466,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
|
|||
},
|
||||
nin0dev: {
|
||||
name: "nin0dev",
|
||||
id: 886685857560539176n
|
||||
id: 1395533040914141235n
|
||||
},
|
||||
Elvyra: {
|
||||
name: "Elvyra",
|
||||
|
|
@ -496,9 +496,9 @@ export const Devs = /* #__PURE__*/ Object.freeze({
|
|||
name: "puv",
|
||||
id: 469441552251355137n
|
||||
},
|
||||
Kodarru: {
|
||||
name: "Kodarru",
|
||||
id: 785227396218748949n
|
||||
IcedMarina: {
|
||||
name: "icedmarina",
|
||||
id: 594406131670188042n
|
||||
},
|
||||
nakoyasha: {
|
||||
name: "nakoyasha",
|
||||
|
|
@ -1255,7 +1255,11 @@ export const EquicordDevs = Object.freeze({
|
|||
davri: {
|
||||
name: "Davri",
|
||||
id: 457579346282938368n,
|
||||
}
|
||||
},
|
||||
yash: {
|
||||
name: "yash",
|
||||
id: 889150838658977874n,
|
||||
},
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
export function relaunch() {
|
||||
if (IS_DISCORD_DESKTOP)
|
||||
window.DiscordNative.app.relaunch();
|
||||
else if (IS_VESKTOP)
|
||||
else if (IS_VESKTOP || IS_EQUIBOP)
|
||||
window.VesktopNative.app.relaunch();
|
||||
else
|
||||
location.reload();
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export let PopoutWindowStore: t.PopoutWindowStore;
|
|||
export let ApplicationCommandIndexStore: t.ApplicationCommandIndexStore;
|
||||
export let EditMessageStore: t.EditMessageStore;
|
||||
export let QuestStore: t.QuestStore;
|
||||
export let PendingReplyStore: GenericStore;
|
||||
export let PendingReplyStore: t.PendingReplyStore;
|
||||
export let ExperimentStore: GenericStore;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export const UserUtils = {
|
|||
|
||||
export const UploadManager = findByPropsLazy("clearAll", "addFile");
|
||||
export const UploadHandler = {
|
||||
promptToUpload: findByCodeLazy("Unexpected mismatch between files and file metadata") as (files: File[], channel: t.Channel, draftType: Number) => void
|
||||
promptToUpload: findByCodeLazy("Unexpected mismatch between files and file metadata") as (files: File[], channel: t.Channel, draftType: Number) => Promise<void>
|
||||
};
|
||||
|
||||
export const ApplicationAssetUtils = mapMangledModuleLazy("getAssetImage: size must === [", {
|
||||
|
|
@ -250,3 +250,7 @@ export const MessageTypeSets: t.MessageTypeSets = findByPropsLazy("REPLYABLE", "
|
|||
export const fetchApplicationsRPC = findByCodeLazy('"Invalid Origin"', ".application");
|
||||
|
||||
export const CloudUploader = findLazy(m => m.prototype?.trackUploadFinished) as typeof t.CloudUpload;
|
||||
|
||||
export const URLUtils: t.URLUtils = findByPropsLazy("URL_REGEX", "makeUrl", "isDiscordUrl");
|
||||
export const Humanize: t.Humanize = findByPropsLazy("filesize", "relativeTime", "ordinal");
|
||||
export const EmojiUtils: t.EmojiUtils = findByPropsLazy("getEmojiColors", "getURL");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue