Merge branch 'dev'

This commit is contained in:
thororen1234 2026-02-20 18:22:15 -05:00
commit 07be581a29
No known key found for this signature in database
23 changed files with 510 additions and 150 deletions

240
AGENTS.md Normal file
View file

@ -0,0 +1,240 @@
# AGENTS.md
**IMPORTANT: Prefer retrieval-led reasoning over pre-training-led reasoning.** When uncertain about APIs, patterns, or existing implementations, read the relevant source files rather than relying on assumptions.
---
## Quick Reference
| Task | Command/Location |
|------|------------------|
| Install | `pnpm install --frozen-lockfile` |
| Build | `pnpm build` |
| Dev/Watch | `pnpm dev` |
| Lint | `pnpm lint` |
| Type check | `pnpm testTsc` |
| Full test | `pnpm test` |
| New Equicord plugin | `src/equicordplugins/PluginName/index.ts` |
| New Vencord plugin | `src/plugins/PluginName/index.ts` |
**Key Files Index:**
| Need | File |
|------|------|
| Plugin types | `src/utils/types.ts` |
| Settings API | `src/api/Settings.ts` |
| Constants/devs | `src/utils/constants.ts` |
| CSS utilities | `src/utils/css.ts` |
| Webpack common exports | `src/webpack/common/*.ts` |
| Example plugins | `src/plugins/*/index.ts` or `src/equicordplugins/*/index.ts` |
| Discord types | `@vencord/discord-types` (external) |
---
## Project Overview
**Equicord** is a Discord client mod forked from [Vencord](https://github.com/Vendicated/Vencord). It modifies the Discord web client by patching webpack modules at runtime to inject custom functionality.
**Technology Stack:** TypeScript (strict), React, Webpack patching
**Package Manager:** pnpm | **License:** GPL-3.0-or-later
---
## Workflow
**Before writing code:**
1. Explore relevant source files to understand existing patterns
2. Check the Key Files Index for API/type references
3. Look at similar plugins for examples
4. Then proceed with implementation
This "explore first, then act" approach produces better results than jumping straight to implementation.
---
## Setup Commands
| Command | Description |
|---------|-------------|
| `pnpm install --frozen-lockfile` | Install dependencies |
| `pnpm build` | Build project |
| `pnpm buildWeb` | Build for web |
| `pnpm dev` / `pnpm watch` | Start dev server (watch mode) |
| `pnpm watchWeb` | Watch web build |
| `pnpm lint` | Lint code |
| `pnpm lint:fix` | Fix lint issues |
| `pnpm lint-styles` | Lint CSS |
| `pnpm testTsc` | Type check |
| `pnpm test` | Full test suite (builds, type checks, lints, generates plugin JSON) |
| `pnpm generatePluginJson` | Generate plugin list |
| `pnpm generateEquicordPluginJson` | Generate Equicord plugin list |
**Requirements:** Node.js >= 18, pnpm (see package.json for version)
---
## Code Style
**TypeScript:** Strict mode. Use inference - don't annotate obvious types.
**Formatting:** Double quotes, semicolons required, single quotes only for escaping.
**Patterns:** `?.` optional chaining, `??` nullish coalescing (never `||` for defaults), `const` over `let`, arrow functions, destructuring, template literals, early returns, flat code.
**Philosophy:** Less code is better. No comments unless requested. Delete dead code, never comment it out. No overengineering or premature abstractions. Trust TypeScript inference.
**Text:** Plugin/setting descriptions: capital first letter, end with period. Error messages: natural human text, no robotic formatting.
**Performance:** `Map`/`Set` for lookups, `.find()`/`.some()` not `.filter()[0]`, no spread in loops, `Promise.all()` for parallel async.
**Hygiene:** Delete dead code. No unused imports. No logging (`console.log`, `Logger`). No empty catch blocks.
---
## Plugin Structure
**Place Equicord plugins in `src/equicordplugins/PluginName/`, upstream/Vencord plugins in `src/plugins/PluginName/`.**
```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:** Use `definePluginSettings` from `@api/Settings`. Reject inline `options` objects.
**Prefer declarative APIs:** `flux`, `contextMenus`, `chatBarButton`, `messagePopoverButton`, `managedStyle` instead of manual registration.
**Reject deprecated fields:** `renderChatBarButton`, `renderMessagePopoverButton`, `options`
**Lifecycle:** Anything registered in `start()` must be cleaned up in `stop()`.
---
## Forbidden Patterns
| Bad | Good | Reason |
|-----|------|--------|
| `value !== null && value !== undefined` | `value` or `isNonNullish(value)` | Verbose |
| `array && array.length > 0` | `array.length` | Redundant |
| `settings?.store?.value` | `settings.store.value` | Store always defined |
| `value \|\| defaultValue` | `value ?? defaultValue` | `\|\|` falsifies `0`, `""`, `false` |
| `` `${classA} ${classB}` `` | `classes(classA, classB)` | Handles null/false |
| `"vc-plugin-class"` hardcoded | `cl("class")` via `classNameFactory` | Typo-proof |
| `console.log/warn/error` | Remove it | No logging |
| `cdn.discordapp.com/...` | `IconUtils.*` methods | Handles animated, sizing |
| `/api/v9/...` | `Constants.Endpoints.*` | Endpoints change |
| `@api/Styles` | `@utils/css` | Deprecated |
| `any` for Discord objects | Import from `@vencord/discord-types` | Type safety |
| `as unknown as` | Find correct type | Unsafe |
| `React.memo()` | Remove it | Not needed |
| `React.cloneElement/isValidElement/Children` | Find another approach | Forbidden |
| `React.lazy(() => import(...))` | `LazyComponent` | Framework-integrated |
| Empty `catch {}` | Handle error | Silent failures |
| CSS-only plugins | Must have logic/patches | Not allowed |
| `document.querySelector(...)` | Use webpack patches | DOM forbidden |
| `Vencord.Plugins.plugins["X"]` | `isPluginEnabled` + import | Proper interop |
| `plugin.started` | `isPluginEnabled(plugin.name)` | Proper interop |
| Unexplained magic numbers | Named constants | Readability |
| Unused imports | Remove them | Cleanliness |
---
## CSS, React, Settings
**CSS:** Use `classNameFactory` from `@utils/css`: `const cl = classNameFactory("vc-my-plugin-");` Combine with `classes()` from `@utils/misc`.
**React:** Return `null` for conditional rendering, never `undefined`. Wrap complex components with `ErrorBoundary.wrap()`. useEffect must return cleanup for subscriptions/timers.
**Settings:** `settings.store.key` (reactive), `settings.plain.key` (non-reactive). Never `settings.use()` with arrays in variables.
---
## 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.** Use `\i`, never `e`, `t`, `n`, `r`, `i`, `eD`, `eH`.
- **Bounded gaps.** `.{0,50}` not `.+?` or `.*?`.
- **No generic patterns.** Add stable anchors.
- **No raw intl hashes.** Use `#{intl::KEY_NAME}`.
- **`$&`** for append/prepend, **`$self`** for plugin method calls.
---
## Memory Leaks
**Every `start()` must have matching `stop()`.** Common leaks: event listeners, intervals/timeouts, MutationObservers, context menu patches, chat bar buttons, styles, Flux subscriptions.
**Prefer declarative APIs** that handle cleanup automatically: `flux`, `contextMenus`, `chatBarButton`, `messagePopoverButton`, `managedStyle`.
**useEffect must return cleanup** for subscriptions, timers, observers, or any persistent resource.
---
## Plugin Interop
- `@equicordplugins/pluginName` for Equicord plugins
- `@plugins/pluginName` for Vencord/upstream plugins
Use `isPluginEnabled(plugin.name)` to check if enabled. Never access `Vencord.Plugins.plugins["X"]` directly.
---
## Platform-Specific Plugins
Directory suffixes: `pluginName.desktop/`, `pluginName.web/`, `pluginName.discordDesktop/`
---
## Built-in Utilities
**@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`, 30+ more), Actions (`RestAPI`, `FluxDispatcher`, `MessageActions`, `NavigationRouter`), Utils (`Constants.Endpoints`, `SnowflakeUtils`, `Parser`, `PermissionsBits`, `moment`, `lodash`, `IconUtils`), Components (`Tooltip`, `TextInput`, `Select`, `Avatar`, `Menu`, `Popout`), Toasts (`Toasts`, `showToast`)
**@webpack finders:** `findByPropsLazy`, `findByCodeLazy`, `findStoreLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy`
**@components/:** `ErrorBoundary`, `Flex`, `Button`, `Paragraph`, `Heading`, `BaseText`, `Span`, `ErrorCard`, `Link`, `CodeBlock`, `FormSwitch`
---
## Ethical Guidelines & Acceptance Rules
**Not accepted:** Fake deafen/mute, trolling plugins, selfbot/API abuse (auto-replies, animated statuses, message pruning, Nitro snipers).
**Instant reject:** Simple slash-commands, text replacement, DOM manipulation, UI-only hide/redesign, third-party bot targeting, untrusted APIs, user-provided API keys, new dependencies without justification.
---
## Git Workflow
- Never commit to `main` (protected)
- Never commit to fork's `dev` branch
- Create feature branch from `dev`: `feature/plugin-name` or `fix/plugin-name`
- Keep commits atomic
- Squash commits before merging if addressing same issue
- Run `pnpm lint` and `pnpm build` before committing
**PR title format:** `[PluginName] Description`
---

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

View file

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

View file

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

View file

@ -19,7 +19,7 @@ export default definePlugin({
patches: [
{
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
find: ".WIDGETS_RTC_UPSELL_COACHMARK),",
replacement: [
{
match: /(?<=className:(\i)\.\i,style:\i,)children:\[/,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,12 +7,16 @@
import { getUserSettingLazy } from "@api/UserSettings";
import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { FluxDispatcher, GuildMemberStore,IconUtils, UserProfileStore, UserStore } from "@webpack/common";
import { FluxDispatcher, GuildMemberStore, IconUtils, UserProfileStore, UserStore } from "@webpack/common";
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
type PendingChanges = Record<string, unknown> & {
selectedAvatarRaw?: ImageInput;
presetAvatarRaw?: ImageInput;
selectedAvatarProcessed?: ImageInput;
presetAvatarProcessed?: ImageInput;
pendingAvatar?: ImageInput;
avatar?: ImageInput;
pendingBanner?: ImageInput;
@ -30,39 +34,23 @@ 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 });
}
@ -74,7 +62,7 @@ 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 {
@ -111,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;
}
@ -137,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);
@ -155,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 ?? "",
@ -200,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 = {
@ -232,7 +207,7 @@ export async function getCurrentProfile(guildId?: string, options: CurrentProfil
? (guildMember?.displayNameStyles ?? userAny.displayNameStyles)
: userAny.displayNameStyles;
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? savedDisplayNameStyles;
const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse as DisplayNameStylesLike | null | undefined);
const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse);
const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar;
const avatarToUse: ImageInput = hasImageInput(pendingAvatar)
@ -282,109 +257,159 @@ 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 = isGuild ? "GUILD_IDENTITY_SETTINGS" : "USER_SETTINGS_ACCOUNT";
const dispatchProfile = (type: string, payload: Record<string, unknown>) => {
dispatch(type, isGuild ? { ...payload, guildId } : payload);
};
const setPending = (type: string, payload: Record<string, unknown>) => {
dispatchProfile(type, payload);
if (isGuild && type.startsWith("GUILD_IDENTITY_SETTINGS_")) {
dispatchProfile(type.replace("GUILD_IDENTITY_SETTINGS_", "USER_SETTINGS_ACCOUNT_"), 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);
};
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 (!isGuild && !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("GUILD_IDENTITY_SETTINGS_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) {
const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles as DisplayNameStylesLike | null | undefined);
if (preset.displayNameStyles) {
const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles);
if (!jsonEq(presetDisplayNameStyles, current.displayNameStyles)) {
setPending(`${profilePrefix}_SET_PENDING_DISPLAY_NAME_STYLES`, { displayNameStyles: presetDisplayNameStyles });
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) {
throw err;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -75,7 +75,7 @@ export default definePlugin({
patches: [
{
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
find: ".WIDGETS_RTC_UPSELL_COACHMARK)",
group: true,
replacement: [
{

View file

@ -7,6 +7,7 @@
import { definePluginSettings } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles";
import { buildPluginMenuEntries, buildThemeMenuEntries } from "@equicordplugins/equicordToolbox/menu";
import SettingsPlugin from "@plugins/_core/settings";
import { Devs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { Logger } from "@utils/Logger";
@ -17,7 +18,17 @@ import type { HTMLAttributes, ReactElement } from "react";
import fullHeightStyle from "./fullHeightContext.css?managed";
type SettingsEntry = { section: string, label: string; };
type SettingsEntry = {
ariaLabel?: string;
element?: any;
label?: string;
newIndicator?: any;
newIndicatorDismissibleContentTypes?: number[];
predicate?: () => boolean;
searchableTitles?: string[];
section: string,
tabPredicate?: () => boolean;
};
const cl = classNameFactory("");
const Classes = findCssClassesLazy("animating", "baseLayer", "bg", "layer", "layers");
@ -127,7 +138,7 @@ export default definePlugin({
predicate: () => settings.store.disableFade
},
{ // Load menu TOC eagerly
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
find: "handleOpenSettingsContextMenu=",
replacement: {
match: /(?=handleOpenSettingsContextMenu=.{0,100}?null!=\i&&.{0,100}?(await [^};]*?\)\)))/,
replace: "_vencordBetterSettingsEagerLoad=(async ()=>$1)();"
@ -145,7 +156,7 @@ export default definePlugin({
},
{
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();"
}
]
},
@ -173,6 +184,8 @@ export default definePlugin({
const items = [] as TransformedSettingsEntry[];
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: [] });
@ -181,6 +194,28 @@ export default definePlugin({
}
}
keyMap.set("Equicord", "Equicord");
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 [section, key] of SettingsPlugin.settingsSectionMap) {
const entry = SettingsPlugin.customEntries.find(e => key.endsWith(e.key));
if (entry) equicordItems.push({ section, label: entry.title });
}
items.push({
section: "Equicord",
items: equicordItems
});
return items;
},

View file

@ -87,7 +87,7 @@ export default definePlugin({
},
// Current user area, at bottom of channels/dm list
{
find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
find: ".WIDGETS_RTC_UPSELL_COACHMARK),",
replacement: [
// Use Decor avatar decoration hook
{

View file

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

View file

@ -380,7 +380,7 @@ function getMentionNameElement(props: mentionProps): JSX.Element | null {
let colorStrings: colorStringsType = undefined;
if (isPluginEnabled(ircColors.name)) {
const color = ircColors.calculateNameColorForMessageContext({ message: { author: { id: userId } }, channel });
const color = ircColors.calculateNameColorForMessageContext({ message: { author: author }, author: author, channel: channel });
if (color) {
colorString = color;

View file

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

View file

@ -61,7 +61,7 @@ export default definePlugin({
].map(find => ({
find,
replacement: {
match: /(?<=maxValue:)\i\.\i\?(\d+?):(\d+?)(?=,)/,
match: /(?<=maxValue:)\i\.\i\?(\i\.\i|\d+?):(\i\.\i|\d+?)(?=,)/,
replace: (_, higherMaxVolume, minorMaxVolume) => `${higherMaxVolume}*$self.settings.store.multiplier`
}
})),